목차
25 장

데이터 페칭과 캐싱

Server Component에서 `async / await`로 데이터를 직접 가져오는 패턴, Next.js 15의 fetch 캐시 옵션 (`force-cache` / `no-store` / `revalidate`), 라우트 레벨 옵션, 그리고 병렬 페칭과 `error.tsx`까지.

24장에서 Server와 Client의 경계를 정리했습니다. 본 챕터에서는 Server Component의 가장 강력한 기능 — 데이터 페칭이 단순해진다는 점을 본격적으로 다룹니다.

본 챕터의 패턴은 10장 (useEffect)과 21장 (fetch와 API 타이핑)에서 본 클라이언트 사이드 페칭의 보일러플레이트가 어떻게 사라지는지를 정면으로 보여 줍니다. 그리고 다음 26장 Suspense와 use()에서 본 챕터의 페칭 위에 점진적 렌더링을 얹게 됩니다.

클라이언트 사이드 페칭의 복잡함 #

기억나시나요? 10장에서 useEffect로 데이터를 가져올 때의 패턴.

기존 클라이언트 사이드 패턴 (10장 복습)
'use client';

import { useEffect, useState } from 'react';

type User = { id: number; name: string };

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => { if (!cancelled) setUser(data); })
      .catch(err => { if (!cancelled) setError(err.message); })
      .finally(() => { if (!cancelled) setLoading(false); });

    return () => { cancelled = true; };
  }, [userId]);

  if (loading) return <p>로딩 ...</p>;
  if (error) return <p>에러: {error}</p>;
  if (!user) return null;
  return <p>{user.name}</p>;
}

이게 표준 패턴이었습니다. 3개의 state, useEffect, race condition 처리, 로딩 / 에러 분기까지. 같은 작업의 보일러플레이트가 매번 반복됩니다.

Server Component에서는 #

같은 일을 Server Component로 하면 이렇게 됩니다.

src/app/users/[userId]/page.tsx
type User = { id: number; name: string };

type Props = {
  params: Promise<{ userId: string }>;
};

export default async function UserProfile({ params }: Props) {
  const { userId } = await params;
  const user: User = await fetch(`https://api.example.com/users/${userId}`)
    .then(res => res.json());

  return <p>{user.name}</p>;
}

이게 전부입니다. 차이가 한눈에 보입니다.

  • state를 만들 필요 없음 (서버에서 한 번 실행되고 끝이라 state 개념 자체가 없음)
  • 로딩 상태를 신경 안 써도 됨 (페칭이 끝나야 HTML이 클라이언트로 가니까 “로딩 중” 인 상태가 클라이언트엔 존재 안 함)
  • race condition 없음 (서버 한 번 실행, 끝)
  • 에러는 그냥 throw → 가까운 error.tsx가 잡음

이 단순함이 Server Component가 풀어내는 핵심 가치 중 하나입니다. 21장에서 정의해 둔 User 타입을 그대로 가져다 쓰면서, 동시에 클라이언트로 가는 코드는 거의 없는 코드입니다.

직접 fetch 외의 옵션들 #

Server Component는 서버에서 실행되니, 클라이언트가 못 하는 일도 가능합니다.

DB 직접 쿼리 #

DB에서 직접 가져오기 (개념 예시)
import { db } from '@/lib/db';

type Post = { slug: string; title: string; content: string };

type Props = {
  params: Promise<{ slug: string }>;
};

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await db.query<Post>('SELECT * FROM posts WHERE slug = $1', [slug]);

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

API를 별도로 안 만들어도 됩니다. 클라이언트 사이드 리액트에서는 절대 못 하는 일입니다. 브라우저에서 DB 자격증명을 노출하면 보안 사고이기 때문입니다. Server Component에서는 자격증명이 클라이언트에 안 가니 안전합니다.

파일 시스템 읽기 #

MDX 파일 직접 읽기
import fs from 'node:fs/promises';
import path from 'node:path';

type Props = {
  params: Promise<{ slug: string }>;
};

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const filePath = path.join(process.cwd(), 'posts', `${slug}.mdx`);
  const content = await fs.readFile(filePath, 'utf-8');
  // ... MDX 컴파일 ...
  return <article>{/* ... */}</article>;
}

fs 같은 Node.js 모듈을 Server Component에서 그대로 import 해 쓸 수 있습니다. 같은 파일에 'use client'가 있었다면 빌드 에러였을 코드가, Server Component에서는 자연스럽게 동작합니다.

Next.js의 fetch 캐싱 #

Next.js는 fetch를 한 번 더 감싸서 자동 캐싱을 제공합니다. 같은 URL을 여러 번 fetch 해도 (같은 요청 내라면) 한 번만 실제 호출이 일어납니다. 그리고 빌드 시점이나 런타임에 캐싱 동작도 제어할 수 있습니다.

기본 동작 — 요청 내 dedup #

같은 페이지 안에서 여러 컴포넌트가 같은 데이터를 fetch 해도 실제로는 한 번만 호출됩니다.

동일 fetch가 여러 번 호출되는 상황
async function getUser(id: number) {
  return fetch(`https://api.example.com/users/${id}`).then(r => r.json());
}

export default async function Page() {
  const userA = await getUser(1);  // 실제 호출
  const userB = await getUser(1);  // 캐시에서 가져옴 (자동)
  // ...
  return null;
}

getUser를 두 번 호출했지만 실제 HTTP 요청은 한 번만 일어납니다. Next.js가 같은 페이지 렌더링 중의 동일 fetch를 중복 제거 (deduplication) 해 줍니다.

캐시 옵션 — cachenext.revalidate #

fetch의 두 번째 인자로 캐싱 동작을 제어할 수 있습니다.

캐싱 옵션 예시
// 1. 영구 캐시 (정적 데이터, 거의 안 바뀜)
fetch(url, { cache: 'force-cache' });

// 2. 캐시 안 함 (매번 새로 요청)
fetch(url, { cache: 'no-store' });

// 3. N초마다 재검증 (자주 바뀌는 데이터)
fetch(url, { next: { revalidate: 60 } });

// 4. 태그 기반 재검증 (수동 무효화 가능)
fetch(url, { next: { tags: ['posts'] } });

각각 어떤 상황에 쓰는지.

  • force-cache — 빌드 타임에 한 번 가져와 영구 캐시. 거의 안 바뀌는 데이터 (정적 페이지 정보, 카테고리 목록 등)
  • no-store — 항상 새로 가져옴. 사용자별로 다른 데이터, 실시간성 중요한 정보
  • revalidate: 60 — 60초 동안은 캐시, 그 이후 첫 요청에서 백그라운드 갱신. 블로그 글 목록 같은 “거의 정적이지만 가끔 바뀜”
  • tags — 코드에서 revalidateTag('posts')를 호출해 수동으로 무효화. 글이 등록되거나 삭제될 때

기본값은 Next.js 15부터 no-store (즉, 캐시 안 함)로 바뀌었습니다. 이전 버전을 따라 하는 자료에서 force-cache가 디폴트라고 적혀 있을 수 있는데, 최신 기준으로는 명시적으로 cache 옵션을 설정 하는 게 좋습니다.

노트
캐싱은 Next.js의 가장 강력한 기능 중 하나이자 가장 헷갈리는 부분이기도 합니다. 본 챕터에서는 기본만 다루고, 본격적인 캐시 전략은 실제 프로젝트의 요구사항에 맞춰 결정합니다. 처음에는 cache: 'no-store'로 시작해 안전하게 동작시킨 후, 성능이 필요할 때 캐시를 추가해 나가는 접근이 안전합니다.

라우트 레벨 옵션 — revalidate, dynamic #

페이지 단위로 동작을 제어할 수도 있습니다.

src/app/posts/page.tsx
export const revalidate = 60;  // 이 페이지 전체를 60초마다 재생성

export default async function PostsPage() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  return <ul>{/* ... */}</ul>;
}
dynamic 옵션
export const dynamic = 'force-dynamic';  // 매 요청마다 새로 렌더 (캐시 안 함)

페이지 전체의 캐시 정책을 한 줄로 표현하는 것입니다. 데이터가 자주 바뀌는 페이지에는 dynamic을, 거의 안 바뀌는 페이지에는 revalidate를 사용합니다.

병렬 페칭 — Promise.all #

여러 데이터를 가져와야 할 때, 순차 await을 쓰면 워터폴이 됩니다.

🐢 순차 페칭 (느림)
const user = await getUser(id);     // 100ms 소요
const posts = await getPosts(id);   // 또 100ms. 총 200ms

데이터들이 서로 의존하지 않는다면 병렬로 가져오는 게 좋습니다.

🚀 병렬 페칭
const [user, posts] = await Promise.all([
  getUser(id),
  getPosts(id),
]);
// 둘이 동시에 시작 → 100ms (가장 느린 쪽 기준)

Promise.all로 묶으면 두 요청이 동시에 시작됩니다. Server Component에서 흔히 쓰는 패턴입니다.

더 좋은 방법 — 컴포넌트별로 분리 #

각 데이터를 자기를 사용하는 컴포넌트에서 직접 가져오면, Next.js의 자동 dedup과 결합해 매우 자연스러운 병렬 처리가 됩니다.

src/app/users/[id]/page.tsx
type Props = {
  params: Promise<{ id: string }>;
};

export default async function UserPage({ params }: Props) {
  const { id } = await params;
  return (
    <div>
      <UserHeader userId={id} />
      <UserPosts userId={id} />
    </div>
  );
}

async function UserHeader({ userId }: { userId: string }) {
  const user = await getUser(userId);
  return <h1>{user.name}</h1>;
}

async function UserPosts({ userId }: { userId: string }) {
  const posts = await getPosts(userId);
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

Server Component는 자기 자신이 async 함수 일 수 있어서, 위처럼 하위 컴포넌트도 각자 데이터 페칭을 할 수 있습니다. 리액트가 이들을 병렬로 실행해 주고요.

이 패턴의 또 다른 장점은 각 부분이 자기 데이터에만 의존 한다는 점입니다. UserHeaderUserPosts의 데이터가 늦게 오든 말든 자기 일을 진행할 수 있고, 다음 26장의 Suspense와 결합하면 빠르게 준비된 부분부터 화면에 보여 주는 streaming도 가능해집니다.

에러 처리 — error.tsx #

Server Component에서 페칭이 실패하면 그냥 throw 하면 됩니다. Next.js는 가까운 error.tsx를 찾아 보여 줍니다.

src/app/posts/error.tsx
'use client';

type Props = {
  error: Error & { digest?: string };
  reset: () => void;
};

export default function ErrorBoundary({ error, reset }: Props) {
  return (
    <div style={{ padding: '24px' }}>
      <h2>문제가 발생했습니다.</h2>
      <p>{error.message}</p>
      <button onClick={reset}>다시 시도</button>
    </div>
  );
}

이 파일이 있으면 /posts 또는 그 하위 페이지에서 에러가 나면 이 화면이 보입니다. 'use client'가 필요한 이유는 reset이 클라이언트 사이드 동작 (onClick 이벤트 등)이기 때문입니다.

직접 해보기 — GitHub API로 작은 사이트 #

23장의 사이트를 진짜 데이터를 가져오는 형태로 발전시켜 봅시다. GitHub의 공개 API를 사용합니다 (인증 없이 시간당 제한이 있지만 학습용으로 충분).

src/app/repos/[owner]/[repo]/page.tsx:

src/app/repos/[owner]/[repo]/page.tsx
type Repo = {
  full_name: string;
  description: string | null;
  stargazers_count: number;
  forks_count: number;
  watchers_count: number;
  language: string | null;
  html_url: string;
};

type Props = {
  params: Promise<{ owner: string; repo: string }>;
};

export default async function RepoPage({ params }: Props) {
  const { owner, repo } = await params;

  const data: Repo = await fetch(
    `https://api.github.com/repos/${owner}/${repo}`,
    { next: { revalidate: 300 } }  // 5분 캐시
  ).then(res => {
    if (!res.ok) throw new Error('Repo not found');
    return res.json();
  });

  return (
    <div style={{ padding: '24px' }}>
      <h1>{data.full_name}</h1>
      <p>{data.description}</p>
      <ul>
        <li> {data.stargazers_count.toLocaleString()}</li>
        <li>🍴 {data.forks_count.toLocaleString()}</li>
        <li>👁 {data.watchers_count.toLocaleString()}</li>
        <li> 언어: {data.language ?? '미상'}</li>
      </ul>
      <a href={data.html_url} target="_blank" rel="noopener">GitHub에서 보기</a>
    </div>
  );
}

src/app/repos/error.tsx:

src/app/repos/error.tsx
'use client';

type Props = {
  error: Error & { digest?: string };
  reset: () => void;
};

export default function ErrorBoundary({ error, reset }: Props) {
  return (
    <div style={{ padding: '24px' }}>
      <h2>저장소를 찾을  없습니다</h2>
      <p style={{ color: '#888', fontSize: '14px' }}>{error.message}</p>
      <button onClick={reset}>다시 시도</button>
    </div>
  );
}

src/app/page.tsx에 링크들을 추가:

src/app/page.tsx (수정)
import Link from 'next/link';

export default function HomePage() {
  return (
    <div style={{ padding: '24px' }}>
      <h1>GitHub 저장소 보기</h1>
      <ul>
        <li><Link href="/repos/facebook/react">facebook/react</Link></li>
        <li><Link href="/repos/vercel/next.js">vercel/next.js</Link></li>
        <li><Link href="/repos/curtisdev/this-does-not-exist">존재하지 않는 저장소</Link></li>
      </ul>
    </div>
  );
}

각 링크를 눌러 보세요.

  • 정상 저장소: GitHub에서 가져온 정보가 화면에 표시됨
  • 존재하지 않는 저장소: error.tsx가 가로채서 에러 화면이 표시됨
  • 5분 안에 다시 방문: 캐시된 결과가 즉시 표시됨

여기서 일어난 모든 일이 서버에서입니다. 브라우저로 가는 자바스크립트는 거의 없습니다. 페이지를 보는 사용자 입장에선 일반 정적 HTML과 구분이 안 되는 빠른 응답이고, 개발자 입장에선 그냥 await fetch(...) 한 줄로 끝나는 단순한 코드입니다.

연습문제 #

  1. dedup 직접 확인. 위 RepoPage에서 같은 fetch를 두 번 호출하도록 만들어 보세요. console.time / console.timeEnd로 시간을 재 보고, 캐시 옵션을 cache: 'no-store'로 바꿔서 동일 fetch가 두 번 실제로 발생하는지 비교합니다. dev 서버 콘솔에 찍히는 fetch 횟수도 같이 관찰해 보세요.
  2. revalidate 옵션 실험. repos/[owner]/[repo]/page.tsxrevalidate: 300revalidate: 5로 바꾼 뒤, 페이지를 새로고침할 때 GitHub API의 X-RateLimit-Remaining 헤더가 5초 단위로만 줄어드는지 확인합니다. 그 후 페이지에 export const dynamic = 'force-dynamic'을 추가해 매 요청마다 새로 fetch 되도록 바꿔 보세요.
  3. 병렬 페칭 vs 컴포넌트별 분리. 한 페이지에서 두 사용자 정보를 가져오는 코드를 (a) Promise.all로 묶는 방식과 (b) 두 자식 Server Component가 각자 fetch 하는 방식으로 각각 작성하고, dev tools의 네트워크 탭에서 요청 타이밍을 비교합니다. 둘이 거의 같은 시간을 보이는 이유를 본문 내용 (자동 dedup + 자식 컴포넌트 병렬 실행)으로 설명해 보세요.

한 줄 요약: Server Component의 데이터 페칭은 async function + await fetch(...) 한 줄이면 끝이고, 로딩 / 에러 / race condition 보일러플레이트가 사라진다. Next.js의 fetch는 요청 내 자동 dedup과 함께 cache / next.revalidate / next.tags로 캐시 정책을 세밀하게 제어한다 (Next.js 15부터는 명시적 지정이 안전). 라우트 레벨에는 export const revalidateexport const dynamic이 있다. 독립 데이터는 Promise.all 또는 자식 컴포넌트별로 fetch를 분리해 자연스럽게 병렬 처리하고, 에러는 throw → error.tsx가 잡는다.

다음 챕터 #

다음 26장 Suspense와 use()에서는 본 챕터에서 만든 페이지가 모든 데이터가 다 도착할 때까지 흰 화면 인 문제를 풀어 봅니다. <Suspense>loading.tsx로 준비된 부분부터 점진적으로 보여 주는 streaming을 익히고, 리액트 19에서 새로 안정화된 use() 훅으로 Server가 만든 Promise를 Client에서 풀어 쓰는 패턴까지 정리합니다.

X