C
Next.js/렌더링/Lesson 05

데이터 페칭 — fetch 캐시 정책 4가지 + revalidate

35분·theory
이 챕터
2/5
TypeScript

데이터 페칭 — fetch 캐시 정책 4가지 + revalidate

💡 왜 배워야 할까요? — '캐시 정책' 이 페이지 속도를 결정한다

🎯 Pages Router 의 `getStaticProps`(SSG) · `getServerSideProps`(SSR) · ISR 옵션이 전부 **fetch 의 옵션 하나**로 통합됐습니다.
💼 fetch 가 자동으로 dedupe + 캐시되므로, '같은 데이터를 여러 컴포넌트에서 부르면 비효율' 이라는 옛 걱정이 사라집니다.
`revalidate` (시간 기반) + `tags` (수동 무효화) 조합으로 페이지마다 신선도 정책을 정확히 정할 수 있습니다.
🔗 이걸 모르면 모든 페이지가 기본 SSG 로 캐시돼 '새 글 썼는데 안 보임' 문제가 생기거나, 반대로 `no-store` 남발로 서버 부하가 늘어납니다.
🏢 실무에서는
codemaster40 의 학습 콘텐츠는 거의 안 바뀌므로 SSG (force-cache) 가 적합합니다. 반면 사용자 진도·북마크는 사용자별로 다르고 자주 바뀌니 `no-store`. 게시판·댓글 같은 콘텐츠는 1~5분 `revalidate` 가 균형점. 이 결정을 매 fetch 호출 단위로 내릴 수 있는 게 App Router 의 강점입니다.

fetch 캐시 정책 4가지 + revalidate 방법

1. 기본 — force-cache (SSG)

tsx
const res = await fetch('https://api/posts');
// 빌드 시점에 한 번 fetch → 결과가 영구 캐시됨
// 페이지 재방문해도 같은 결과 (재배포 전까지)

빠르지만 데이터가 바뀌면 반영 안 됨. 불변에 가까운 데이터에 적합.

2. no-store (SSR)

tsx
const res = await fetch('https://api/posts', {
  cache: 'no-store',
});
// 요청마다 새로 fetch — Pages Router 의 getServerSideProps 와 동일

항상 최신. 사용자별 데이터·시세·재고처럼 매 요청 신선도가 중요한 데이터.

3. revalidate (ISR — Incremental Static Regeneration)

tsx
const res = await fetch('https://api/posts', {
  next: { revalidate: 60 },
});
// 60초간 캐시, 그 후 다음 요청 시 백그라운드 재생성

캐시의 빠름 + 신선도. 블로그·문서·자주 안 바뀌지만 가끔 갱신.

4. tags + revalidateTag (수동 무효화)

tsx
// 데이터 조회 — 태그 붙임
const res = await fetch('https://api/posts', {
  next: { tags: ['posts'] },
});

// 다른 곳 (Server Action 등) 에서 태그 무효화
import { revalidateTag } from 'next/cache';
async function createPost(formData: FormData) {
  'use server';
  await db.post.create({ ... });
  revalidateTag('posts'); // 'posts' 태그가 붙은 모든 fetch 캐시 무효화
}

돌발 갱신 (글 작성 직후 등) 에 적합. ISR + tags 가 실무 표준 조합.

5. 경로 단위 무효화 — revalidatePath

tsx
import { revalidatePath } from 'next/cache';
revalidatePath('/blog');     // /blog 페이지의 모든 캐시 무효화
revalidatePath('/blog/[slug]', 'page'); // 동적 경로 전체

6. 정리 — 언제 무엇을?

데이터 성격권장 정책
거의 안 바뀜 (학습 콘텐츠·랜딩)force-cache (기본)
분 단위 갱신 OK (블로그·뉴스)revalidate: 60~300
사용자별·실시간no-store
사용자 액션 직후 즉시 갱신tags + revalidateTag
💻 🅰️ Pages Router — getStaticProps / getServerSideProps 분리
// ❌ Pages Router — 모드마다 다른 함수

// 📁 pages/blog/index.tsx (SSG)
import type { GetStaticProps } from 'next';

interface Post { id: number; title: string; }

export const getStaticProps: GetStaticProps<{ posts: Post[] }> = async () => {
  const posts = await fetch('https://api/posts').then(r => r.json());
  return {
    props: { posts },
    revalidate: 60, // ISR — 60초
  };
};

export default function Blog({ posts }: { posts: Post[] }) {
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// 📁 pages/dashboard.tsx (SSR — 매 요청)
import type { GetServerSideProps } from 'next';

export const getServerSideProps: GetServerSideProps<{ user: User }> = async (ctx) => {
  const user = await fetchUser(ctx.req.cookies.session);
  return { props: { user } };
};

export default function Dashboard({ user }: { user: User }) {
  return <h1>안녕, {user.name}</h1>;
}

// 단점:
// - 패칭 모드를 페이지 단위로 결정 (한 페이지 안에서 일부만 ISR 같은 거 불가)
// - 데이터를 props 로 직렬화 → 큰 객체는 비효율
💻 🅱️ App Router — fetch 옵션 하나로 통합
// ✅ App Router — fetch 옵션으로 캐시 정책 결정

interface Post { id: number; title: string; }
interface User { id: number; name: string; }

// 📁 app/blog/page.tsx — ISR 60초
export default async function Blog() {
  const posts: Post[] = await fetch('https://api/posts', {
    next: { revalidate: 60, tags: ['posts'] },
  }).then(r => r.json());

  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// 📁 app/dashboard/page.tsx — SSR (매 요청)
import { cookies } from 'next/headers';

export default async function Dashboard() {
  const session = cookies().get('session')?.value;
  const user: User = await fetch(`https://api/users/me`, {
    cache: 'no-store', // 매 요청
    headers: { cookie: `session=${session}` },
  }).then(r => r.json());

  return <h1>안녕, {user.name}</h1>;
}

// 📁 app/mixed/page.tsx — 한 페이지 안에서 서로 다른 정책
export default async function Mixed() {
  // 정적 콘텐츠 — 영구 캐시
  const intro = await fetch('https://api/intro').then(r => r.json());

  // 빈도 낮은 갱신 — 5분
  const posts = await fetch('https://api/posts', {
    next: { revalidate: 300 },
  }).then(r => r.json());

  // 사용자 데이터 — 매번
  const me = await fetch('https://api/me', {
    cache: 'no-store',
  }).then(r => r.json());

  return <Layout intro={intro} posts={posts} me={me} />;
}

// 📁 app/posts/actions.ts — 글 작성 후 캐시 무효화
'use server';
import { revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.post.create({
    data: { title: formData.get('title') as string },
  });
  revalidateTag('posts'); // tags: ['posts'] 가 붙은 fetch 캐시 모두 무효화
}

💡 💡 Next.js 데이터 페칭 실전 5

1. 기본은 force-cache — 모르면 캐시된다
빌드 시 fetch 하고 영구 캐시. 데이터가 바뀌어도 안 보이는 이유 1위.

2. revalidate 시간 정하기 휴리스틱

  • 콘텐츠가 분당 1회 미만 바뀜 → 60
  • 시간당 몇 번 → 300
  • 거의 안 바뀜 → 3600 이상 또는 무한 (force-cache + tags)

3. tags 와 revalidateTag 조합이 가장 강력
글 작성·수정·삭제 액션에서 revalidateTag('posts') 한 줄이면 관련 캐시 전부 무효화.

4. 같은 URL 은 자동 dedup — 호출 횟수 걱정 X
같은 렌더링 안에서 fetch('/api/me') 를 여러 컴포넌트에서 호출해도 실제 네트워크 요청은 1번.

5. cookies()·headers() 를 호출하면 자동 dynamic

tsx
import { cookies } from 'next/headers';
const c = cookies(); // 이 줄이 있으면 force-cache 무시되고 매 요청 SSR

fetch 캐시 모드와 무관하게 페이지가 dynamic 으로 전환됨.

⚡ 직접 실행해보기 — 캐시 정책 시뮬레이터

각 fetch 호출의 캐시 정책이 페이지 신선도에 어떤 영향을 주는지 시뮬레이션합니다.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

Next.js App Router 의 `fetch(url)` (옵션 없음) 의 기본 캐시 동작은?
💡 Next.js 13/14 의 fetch 기본값은 `force-cache` 입니다. 옵션 없이 fetch 만 호출하면 빌드 시점에 한 번 호출되고 결과가 영구 캐시됩니다 (재배포 전까지). 데이터를 갱신하려면 `revalidate`·`tags`·`no-store` 중 하나를 명시해야 합니다. ⚠️ Next.js 15 부터는 기본값이 `no-store` 로 바뀌었다는 RFC 가 있어 버전 확인이 필요합니다.
데이터 페칭 — fetch 캐시 정책 4가지 - Next.js