기술 포스트

Next.js App Router에서 데이터 흐름 설계 — fetch는 어디서 해야 하는가


Article

fetch 위치가 왜 중요한가

Next.js App Router에서 데이터를 가져오는 위치는 최소 4곳입니다.

  1. Page 컴포넌트
  2. Layout 컴포넌트
  3. 개별 Server Component
  4. 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, 유지보수 구조를 동시에 결정하는 설계 행위입니다.

"일단 되게" 만들지 말고, 왜 여기서 가져오는지 설명할 수 있어야 합니다.