Next.js App Router에서 데이터 흐름 설계 — fetch는 어디서 해야 하는가
Article
fetch 위치가 왜 중요한가
Next.js App Router에서 데이터를 가져오는 위치는 최소 4곳입니다.
- Page 컴포넌트
- Layout 컴포넌트
- 개별 Server Component 안
- Server Action 또는 Route Handler
"어디서든 가져오면 되는 거 아닌가?"라고 생각할 수 있습니다. 저도 처음엔 그랬습니다. 그런데 어디서 가져오느냐에 따라 캐싱 동작, 리렌더링 범위, 로딩 UI 표시 방식이 전부 달라집니다.
선택지 1: Page에서 fetch
가장 직관적인 방식입니다.
// app/products/page.tsx
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }
}).then(res => res.json());
return (
<div>
<h1>상품 목록</h1>
<ProductList products={products} />
<ProductStats count={products.length} />
</div>
);
}
장점:
- 페이지 단위로 데이터 흐름이 명확합니다
- props drilling이지만, 한 단계 정도는 괜찮습니다
단점:
- 하위 컴포넌트가 많아지면 props가 깊어집니다
- 페이지 전체가 데이터 로딩을 기다려야 합니다
적합한 경우: 단순한 페이지, 데이터 소스가 1~2개일 때
선택지 2: Layout에서 fetch
// app/dashboard/layout.tsx
export default async function DashboardLayout({ children }) {
const user = await getUser();
return (
<div className="flex">
<Sidebar user={user} />
<main>{children}</main>
</div>
);
}
중요한 특성: Layout은 네비게이션 시 리렌더링되지 않습니다.
/dashboard/analytics에서 /dashboard/settings로 이동해도, Layout은 다시 실행되지 않습니다. user 데이터는 처음 한 번만 fetch됩니다.
장점:
- 여러 페이지에서 공유하는 데이터에 적합합니다
- 불필요한 재요청을 방지합니다
단점:
- 데이터가 오래될 수 있습니다 (Layout이 리렌더링되지 않으므로)
- 자식 페이지에 props로 전달할 수 없습니다 (
children은 그냥 ReactNode)
적합한 경우: 사용자 정보, 네비게이션 데이터처럼 자주 바뀌지 않는 공유 데이터
선택지 3: 개별 컴포넌트에서 fetch
이게 App Router가 밀고 있는 패턴입니다.
// app/products/page.tsx
export default function ProductsPage() {
return (
<div>
<h1>상품 목록</h1>
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
<Suspense fallback={<StatsLoading />}>
<ProductStats />
</Suspense>
</div>
);
}
// components/ProductList.tsx
async function ProductList() {
const products = await fetch('https://api.example.com/products').then(res => res.json());
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
// components/ProductStats.tsx
async function ProductStats() {
const stats = await fetch('https://api.example.com/stats').then(res => res.json());
return <div>총 {stats.total}개 상품</div>;
}
장점:
- 각 컴포넌트가 자신의 데이터를 책임집니다. 의존성이 없습니다.
- Suspense와 함께 쓰면 병렬 로딩이 가능합니다. ProductList와 ProductStats가 동시에 로딩됩니다.
- 로딩 UI를 컴포넌트 단위로 제어할 수 있습니다.
단점:
- 같은 데이터를 여러 컴포넌트에서 요청할 수 있습니다 (하지만 Next.js가 자동으로 중복 제거합니다)
적합한 경우: 대부분의 경우. 특히 독립적인 데이터 소스가 여러 개일 때.
선택지 4: Server Action
사용자 인터랙션에 의한 데이터 변경에 씁니다.
// actions/cart.ts
'use server';
export async function addToCart(productId: string) {
const user = await getUser();
await db.cart.create({
data: { userId: user.id, productId }
});
revalidatePath('/cart');
}
// components/AddButton.tsx
'use client';
import { addToCart } from '@/actions/cart';
export function AddButton({ productId }: { productId: string }) {
return (
<button onClick={() => addToCart(productId)}>
담기
</button>
);
}
장점:
- API Route를 만들 필요가 없습니다
- 타입 안전성이 보장됩니다 (서버 함수를 직접 호출)
revalidatePath/revalidateTag로 캐시를 정확히 무효화할 수 있습니다
적합한 경우: 폼 제출, 장바구니 추가, 좋아요 같은 쓰기(mutation) 작업
실무 시나리오: 목록 + 상세 페이지
에이전시에서 가장 흔한 패턴인 "목록 → 상세" 구조를 설계해볼게요.
목록 페이지
// app/products/page.tsx
import { Suspense } from 'react';
export default function ProductsPage() {
return (
<div>
<h1>상품 목록</h1>
<Suspense fallback={<div>로딩 중...</div>}>
<ProductList />
</Suspense>
</div>
);
}
async function ProductList() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 300 } // 5분 캐시
}).then(res => res.json());
return (
<ul>
{products.map(product => (
<li key={product.id}>
<a href={`/products/${product.id}`}>{product.name}</a>
</li>
))}
</ul>
);
}
상세 페이지
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
export default async function ProductDetailPage({
params
}: {
params: { id: string }
}) {
const product = await fetch(`https://api.example.com/products/${params.id}`, {
next: { tags: [`product-${params.id}`] }
}).then(res => res.json());
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<Suspense fallback={<div>리뷰 로딩 중...</div>}>
<Reviews productId={params.id} />
</Suspense>
</div>
);
}
설계 포인트:
- 목록은
revalidate: 300으로 5분마다 갱신 - 상세는
tags를 써서 상품 수정 시 해당 상품만 캐시 무효화 - 리뷰는 별도 컴포넌트로 분리해서 상세 정보와 병렬 로딩
판단 기준 정리
| 상황 | 추천 위치 | 이유 |
|---|---|---|
| 페이지 전체에서 쓰는 단일 데이터 | Page | 단순하고 명확 |
| 여러 페이지에서 공유하는 데이터 | Layout | 한 번만 fetch, 캐시 |
| 독립적인 데이터 소스 여러 개 | 개별 컴포넌트 | 병렬 로딩, Suspense |
| 사용자 액션에 의한 데이터 변경 | Server Action | API Route 불필요 |
| 클라이언트 상태에 따른 동적 fetch | Route Handler + Client | useEffect 필요 시 |
데이터를 어디서 가져오느냐는 단순한 코드 배치가 아닙니다. 캐싱 전략, 로딩 UX, 유지보수 구조를 동시에 결정하는 설계 행위입니다.
"일단 되게" 만들지 말고, 왜 여기서 가져오는지 설명할 수 있어야 합니다.