정적인 화면에 숨을 불어넣는 순간
지금까지 만든 '작은 기록 상점'은 꽤 그럴듯했습니다. 페이지도 있고, 이동도 되고, 상세 페이지도 열렸습니다. 그런데 아직은 조금 얌전합니다. 마치 진열장은 멋지게 꾸몄는데, 안에 진짜 상품이 들어오기 전인 가게처럼 말이죠.
이제부터는 화면에 실제 데이터를 흘려보낼 차례입니다.
Next.js의 App Router에서는 이 작업이 생각보다 자연스럽습니다. 서버 컴포넌트에서 async 함수를 만들고, 그 안에서 fetch()를 await 하면 됩니다. 게다가 Next.js는 서버에서 사용하는 fetch에 캐시와 재검증 기능까지 덧붙여 두어서, "항상 새로 가져올지", "조금 저장해 둘지", "몇 초마다 새로 확인할지"를 요청마다 정할 수 있습니다.1
쉽게 말해 오늘의 목표는 이렇습니다.
정적인 카드 모양을 보여주던 화면을, 진짜 기록 목록과 진짜 기록 상세를 보여주는 화면으로 바꾸는 것입니다.
fetch의 기본 모양부터 아주 천천히
가장 먼저, 데이터 가져오기의 기본 모양부터 눈에 익혀 봅시다.
const res = await fetch('https://example.com/api/items')
if (!res.ok) {
throw new Error('데이터를 불러오지 못했습니다.')
}
const data = await res.json()이 코드는 세 걸음으로 이해하면 됩니다.
첫 번째, fetch()로 주소에 요청을 보냅니다.
두 번째, res.ok로 요청이 성공했는지 확인합니다. 서버가 404나 500 같은 상태를 돌려줘도, fetch 자체가 무조건 에러를 던지는 것은 아니기 때문에 이 확인이 중요합니다.
세 번째, res.json()으로 응답 본문을 자바스크립트 데이터로 바꿉니다.
여기까지만 보면 그냥 익숙한 웹의 fetch 같습니다. 맞습니다. 그런데 Next.js의 서버에서는 여기에 한 가지 힘이 더 붙습니다. 요청마다 캐시 전략을 정할 수 있다는 점입니다.1
우리 프로젝트에서는 어디서 가져오면 좋을까요?
'작은 기록 상점'은 결국 우리만의 백엔드나 데이터베이스와 연결될 수 있습니다. 하지만 처음 배우는 단계에서는 공개 테스트 API를 잠시 빌려 쓰는 방법이 가장 부담이 적습니다.
이번 장에서는 연습용으로 공개 API의 게시글 데이터를 기록 데이터처럼 다뤄 보겠습니다. 제목은 기록 제목으로, 본문은 기록 내용으로 생각하면 됩니다. 옷만 갈아입히면 지금 프로젝트 연습에는 충분합니다.
그리고 중요한 감각 하나를 여기서 잡아두면 좋습니다.
서버 컴포넌트에서 데이터를 가져올 때는 보통 페이지 파일 안에 직접 긴 fetch 코드를 늘어놓기보다, lib 폴더 같은 곳에 함수를 따로 빼 두는 편이 좋습니다. 그러면 페이지는 화면 그리기에 집중하고, 데이터 가져오기 코드는 한곳에서 관리할 수 있습니다.
파일 구조는 이런 느낌으로 가볍게 잡아 보겠습니다.
app/
records/
page.tsx
[id]/
page.tsx
lib/
records.ts기록 목록과 상세 데이터를 가져오는 함수 만들기
먼저 lib/records.ts 파일을 만듭니다.
const BASE_URL = 'https://jsonplaceholder.typicode.com'
type PostResponse = {
userId: number
id: number
title: string
body: string
}
export type RecordSummary = {
id: number
title: string
summary: string
category: string
}
export type RecordDetail = {
id: number
title: string
content: string
category: string
writer: string
}
function pickCategory(id: number) {
const categories = ['오늘의 기록', '마음 수집', '문장 보관함']
return categories[id % categories.length]
}
export async function fetchRecordList(): Promise<RecordSummary[]> {
const res = await fetch(`${BASE_URL}/posts?_limit=12`, {
next: { revalidate: 60 },
})
if (!res.ok) {
throw new Error('기록 목록을 불러오지 못했습니다.')
}
const posts: PostResponse[] = await res.json()
return posts.map((post) => ({
id: post.id,
title: post.title,
summary: post.body.slice(0, 80) + '...',
category: pickCategory(post.id),
}))
}
export async function fetchRecordDetail(id: string): Promise<RecordDetail | null> {
const res = await fetch(`${BASE_URL}/posts/${id}`, {
cache: 'no-store',
})
if (res.status === 404) {
return null
}
if (!res.ok) {
throw new Error('기록 상세를 불러오지 못했습니다.')
}
const post: PostResponse = await res.json()
return {
id: post.id,
title: post.title,
content: post.body,
category: pickCategory(post.id),
writer: `기록지기 #${post.userId}`,
}
}이 코드에서 눈여겨볼 부분은 두 군데입니다.
fetchRecordList()에서는 next: { revalidate: 60 }을 사용했습니다. 이 뜻은 "최대 60초 동안은 저장된 데이터를 재사용하고, 그 뒤에는 새로 확인하자"에 가깝습니다. 자주 바뀌지는 않지만 너무 오래 묵히고 싶지도 않은 목록 화면에 잘 어울립니다.1
반대로 fetchRecordDetail()에서는 cache: 'no-store'를 넣었습니다. 이 설정은 요청할 때마다 새로 가져오겠다는 뜻입니다. 아주 최신 상태가 중요할 때 쓸 수 있습니다.1
조금 재밌게 비유하면 이렇습니다.
목록은 가게 입구 전광판입니다. 1분쯤은 같은 문구를 보여줘도 괜찮습니다.
상세는 점원이 손님에게 꺼내 보여주는 진짜 상품 카드입니다. 바뀌었다면 바로 새것을 보여주는 편이 더 자연스럽습니다.
목록 페이지를 진짜 데이터로 바꾸기
이제 app/records/page.tsx를 만들어 목록 화면에 데이터를 붙여 봅시다.
import Link from 'next/link'
import { fetchRecordList } from '@/lib/records'
export default async function RecordsPage() {
const records = await fetchRecordList()
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<div className="mb-8">
<h1 className="text-3xl font-bold text-stone-900">작은 기록 상점</h1>
<p className="mt-2 text-stone-600">
오늘은 어떤 기록을 집어 가고 싶나요?
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{records.map((record) => (
<Link
key={record.id}
href={`/records/${record.id}`}
className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm transition hover:-translate-y-1 hover:shadow-md"
>
<span className="inline-block rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-600">
{record.category}
</span>
<h2 className="mt-4 text-lg font-semibold text-stone-900">
{record.title}
</h2>
<p className="mt-2 text-sm leading-6 text-stone-600">
{record.summary}
</p>
<p className="mt-4 text-sm font-medium text-amber-700">
자세히 보기 →
</p>
</Link>
))}
</div>
</main>
)
}여기서 흐름은 아주 간단합니다.
페이지 컴포넌트가 async이고, 그 안에서 await fetchRecordList()를 호출합니다. 그러면 서버가 먼저 기록 목록을 받아 온 뒤, 그 데이터로 카드 목록을 만들어 브라우저에 보내줍니다.
이 방식이 좋은 이유는 초보자에게도 무척 분명합니다.
useEffect로 따로 요청을 시작하지 않아도 됩니다.첫 화면부터 데이터가 들어간 HTML을 만들 수 있습니다.
목록 출력 코드가 그냥 일반 JSX처럼 읽힙니다.
즉, "화면을 그리고 나서 나중에 데이터 채우기"보다, "데이터를 준비해서 화면 만들기"에 더 가깝습니다. App Router의 서버 컴포넌트가 편한 이유가 바로 여기에 있습니다.
상세 페이지도 fetch로 연결하기
이제 카드만 멋지고 상세는 텅 비어 있으면 조금 섭섭하겠죠. 동적 라우트와 연결해서 상세 페이지도 진짜 데이터로 바꿔 보겠습니다.
app/records/[id]/page.tsx 파일은 이렇게 만들 수 있습니다.
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { fetchRecordDetail } from '@/lib/records'
type PageProps = {
params: Promise<{ id: string }>
}
export default async function RecordDetailPage({ params }: PageProps) {
const { id } = await params
const record = await fetchRecordDetail(id)
if (!record) {
notFound()
}
return (
<main className="mx-auto max-w-3xl px-6 py-10">
<Link
href="/records"
className="inline-block text-sm font-medium text-stone-500 hover:text-stone-900"
>
← 목록으로 돌아가기
</Link>
<article className="mt-6 rounded-3xl border border-stone-200 bg-white p-8 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
{record.category}
</span>
<span className="text-sm text-stone-500">{record.writer}</span>
</div>
<h1 className="mt-5 text-3xl font-bold leading-tight text-stone-900">
{record.title}
</h1>
<p className="mt-6 whitespace-pre-line leading-8 text-stone-700">
{record.content}
</p>
</article>
</main>
)
}이 페이지에서 중요한 포인트는 세 가지입니다.
첫 번째, URL의 id를 받아서 그 값으로 상세 데이터를 요청합니다.
두 번째, 데이터가 없으면 notFound()를 호출해 404 화면으로 보냅니다. 억지로 빈 페이지를 보여주는 것보다 훨씬 자연스럽습니다.
세 번째, 목록 페이지와 상세 페이지가 서로 같은 데이터 가져오기 파일을 공유합니다. 이런 구조는 나중에 API 주소가 바뀌어도 수정할 곳이 적어서 편합니다.
여기까지 만들면 사용자는 목록에서 카드를 보고, 클릭하면 상세로 들어가고, 상세 페이지에서는 실제 제목과 내용을 읽을 수 있습니다. 이제야 비로소 "서비스 같다"는 느낌이 납니다.
왜 페이지 안에서 직접 fetch하지 않고 함수로 뺐을까요?
물론 이렇게도 쓸 수 있습니다.
export default async function Page() {
const res = await fetch('https://...')
const data = await res.json()
return <div>...</div>
}간단한 예제에서는 충분히 괜찮습니다. 하지만 프로젝트가 조금만 커져도 데이터 가져오기 코드가 이 페이지 저 페이지에 흩어지기 시작합니다.
그래서 lib/records.ts처럼 따로 빼 두면 좋은 점이 생깁니다.
목록과 상세가 같은 규칙으로 데이터를 다룰 수 있습니다.
제목, 요약, 카테고리 같은 변환 작업을 한곳에서 처리할 수 있습니다.
나중에 외부 API를 우리 백엔드 API로 바꿀 때도 수정 범위가 줄어듭니다.
즉, 페이지는 "어떻게 보여줄까"를 맡고, lib 함수는 "어떻게 가져올까"를 맡는 셈입니다.
역할 분담이 깔끔하면 코드도 덜 지칩니다.
캐시, no-store, revalidate를 감으로 이해하기
Next.js의 서버 fetch는 여기서 진짜 매력을 보여줍니다. 요청마다 신선도를 조절할 수 있기 때문입니다.1
항상 새로 가져오고 싶을 때
await fetch(url, { cache: 'no-store' })이 옵션은 "매 요청마다 새 데이터를 가져와 줘"에 가깝습니다. 관리자 화면, 주문 상태, 방금 수정된 정보처럼 최신성이 매우 중요할 때 어울립니다.
조금 저장해 두고 싶을 때
await fetch(url, { next: { revalidate: 60 } })이 옵션은 "최대 60초까진 저장해 두고, 그다음엔 다시 확인해 줘"라는 뜻입니다. 뉴스 목록, 상품 목록, 게시글 목록처럼 적당히 빨라야 하고 적당히 최신이어야 할 때 잘 맞습니다.
캐시를 강하게 활용하고 싶을 때
await fetch(url, { cache: 'force-cache' })이 옵션은 가능한 한 저장된 결과를 재사용하려는 쪽입니다. 자주 바뀌지 않는 데이터에 유리합니다.1
여기서 초보자가 자주 하는 실수가 하나 있습니다.
cache: 'no-store'와 next: { revalidate: 60 }를 한 번에 같이 넣고 싶어지는 순간이 옵니다. 하지만 둘은 방향이 다릅니다. 하나는 "저장하지 마", 다른 하나는 "저장하되 주기적으로 새로 확인해"에 가깝기 때문에 서로 충돌합니다.1
쉽게 말해 냉장고 문에 "절대 보관 금지" 스티커를 붙여 놓고, 동시에 "1분 뒤 다시 꺼내 먹자"라고 적는 셈입니다. 냉장고도 당황합니다.
개발 중인데 데이터가 안 바뀌어 보일 수 있습니다
여기서 한 번쯤 고개를 갸웃할 수 있습니다.
"분명 no-store를 넣었는데 왜 방금 바꾼 데이터가 바로 안 보이지?"
개발 모드에서는 HMR 때문에 fetch 결과가 잠깐 재사용되어 보일 수 있습니다. 즉, 개발 편의를 위해 로컬에서 약간의 캐시가 보이는 상황이 생길 수 있다는 뜻입니다. 이럴 때는 전체 새로고침을 하거나 다른 페이지로 이동했다가 다시 돌아오면 반영되는 경우가 많습니다.1
이건 초보자에게 꽤 헷갈리는 순간이라 꼭 기억해 두면 좋습니다.
코드가 틀린 게 아니라, 개발 환경이 조금 똑똑하게 굴고 있는 것일 수 있습니다.
같은 fetch를 여러 군데서 불러도 너무 겁먹지 않아도 됩니다
Next.js는 같은 렌더링 과정 안에서 같은 URL과 같은 옵션으로 하는 GET 요청을 자동으로 메모이제이션합니다. 즉, 같은 요청을 여러 컴포넌트가 동시에 필요로 해도 한 번만 실행하고 결과를 함께 쓸 수 있다는 뜻입니다.1
이 말은 초보자에게 아주 반가운 소식입니다.
모든 데이터를 무조건 최상위 페이지에서 한 번에 가져와서 아래로 길게 props를 내려보내지 않아도 된다는 뜻이니까요.
필요한 컴포넌트가 필요한 데이터를 직접 가져오는 구조도 충분히 생각해 볼 수 있습니다. 물론 처음에는 너무 잘게 쪼개기보다, 지금처럼 lib 함수 하나를 중심으로 정리하는 방식이 가장 이해하기 쉽습니다.
자주 만나는 실수와 피하는 법
데이터 가져오기를 처음 붙일 때는 비슷한 곳에서 자꾸 넘어집니다. 몇 가지만 미리 알고 있으면 훨씬 덜 헤맵니다.
응답 확인 없이 바로 res.json() 하기
가장 흔한 실수입니다. 서버가 실패 응답을 보냈는데도 무조건 JSON으로 바꾸려다가 이상한 오류를 만날 수 있습니다.
항상 이런 습관을 들이면 좋습니다.
if (!res.ok) {
throw new Error('문제가 생겼습니다.')
}페이지 파일에 데이터 가공 코드까지 다 몰아넣기
처음에는 편해 보이지만, 조금만 지나면 페이지가 길고 무거워집니다.
데이터 가져오기와 변환은 lib로, 화면 출력은 page.tsx로 나누는 습관이 오래 갑니다.
실시간이 아닌데 무조건 no-store 쓰기
최신성만 생각하면 뭐든 no-store를 넣고 싶어집니다. 하지만 모든 요청을 매번 새로 가져오면 느려질 수 있습니다.
목록처럼 조금 저장해 둬도 괜찮은 화면은 revalidate가 더 균형이 좋습니다.
오늘 만든 흐름을 한 번에 정리해 보기
이제 '작은 기록 상점'의 데이터 흐름은 이렇게 읽을 수 있습니다.
목록 페이지가 열리면 서버에서 기록 목록을 가져옵니다.
가져온 데이터는 카드 모양으로 정리되어 화면에 출력됩니다.
사용자가 카드 하나를 누르면 상세 페이지로 이동합니다.
상세 페이지는 URL의 id를 받아 해당 기록 하나를 다시 가져옵니다.
이렇게 되면 앱은 더 이상 "예쁜 껍데기"가 아니라, 요청하고, 받아오고, 보여주는 살아 있는 웹이 됩니다.
이 감각이 매우 중요합니다. 이후에 폼을 보내고, 데이터를 바꾸고, Route Handlers로 API를 만들고, 캐싱을 다듬는 모든 장면이 결국 이 흐름 위에 쌓이기 때문입니다.
마무리
Next.js의 App Router에서 데이터 가져오기는 생각보다 단순합니다.
서버 컴포넌트에서 async 함수를 만들고, fetch()를 await 하고, res.ok를 확인한 뒤, 필요한 형태로 데이터를 정리해 화면에 뿌리면 됩니다.
그리고 그 위에 Next.js가 캐시와 재검증이라는 똑똑한 손잡이를 얹어 줍니다. 덕분에 우리는 페이지마다 "얼마나 신선해야 하는지"를 선택할 수 있습니다.1
오늘의 핵심을 딱 세 문장으로 줄이면 이렇습니다.
목록과 상세 데이터는 서버에서 가져오면 시작이 훨씬 편하다.
fetch는 단순하지만,res.ok확인과 캐시 전략 선택이 중요하다.데이터를 붙이는 순간, 정적인 화면은 서비스로 변하기 시작한다.
다음 단계에서는 이제 데이터를 읽는 것에서 한 걸음 더 나아가, 사용자의 입력으로 데이터를 바꾸는 것을 해보게 됩니다. 그때부터 '작은 기록 상점'은 진짜로 손님을 받기 시작합니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.