C
Next.js/SEO/Lesson 15

Revalidation 전략 — revalidatePath · revalidateTag · time-based

30분·theory
이 챕터
2/2
TypeScript

Revalidation 전략 — revalidatePath · revalidateTag · time-based

💡 왜 배워야 할까요? — '새 글 썼는데 안 보임' 버그 1등 원인

🎯 Next.js 의 fetch 는 기본 캐시. mutation 후 무효화 안 하면 사용자는 옛 데이터를 봅니다.
💼 4가지 도구 — `revalidatePath`·`revalidateTag`·`revalidate: N`·`cache: 'no-store'` 의 사용처가 다름.
잘못 쓰면 캐시 효과 0 (every request SSR) 또는 데이터 stale (영구 캐시) 양극단.
🔗 Server Action·웹훅·관리자 페이지 — mutation 흐름에서 정확한 무효화 시점이 핵심.
📈 **한 줄 규칙**: 데이터 fetch 에 `tags` 붙이고, mutation Server Action 끝에 `revalidateTag(...)` — 이게 90% 답.
🏢 실무에서는
블로그 글 작성 → 목록 페이지 + 상세 페이지 모두 무효화. 댓글 추가 → 댓글 섹션만 무효화. 관리자가 product 가격 수정 → 그 product 카드 + 검색 결과 + 카테고리 페이지 모두 무효화. 각 시나리오마다 다른 도구.

4 도구 — 사용처 매트릭스

1. 4 도구 정리

도구위치효과
revalidate: N (fetch 옵션)페이지·컴포넌트빌드 시 캐시, N초 후 백그라운드 재생성 (ISR)
cache: 'no-store'페이지·컴포넌트캐시 안 함, 매 요청 SSR
revalidatePath('/x')Server Action·Route Handler/x 페이지의 모든 fetch 캐시 무효화
revalidateTag('tag')Server Action·Route Handlertags 가 'tag' 인 fetch 만 정확히 무효화

2. tags 시스템 — 가장 강력한 패턴

ts
// 📁 app/posts/page.tsx — fetch 에 태그 붙임
const posts = await fetch('https://api/posts', {
  next: { tags: ['posts'] },
}).then(r => r.json());
ts
// 📁 app/posts/actions.ts — mutation 후 그 태그 무효화
'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');
  // 'posts' 태그가 붙은 모든 fetch 캐시 무효화 (페이지·컴포넌트 가리지 않음)
}

revalidatePath 보다 정확 — 같은 태그를 여러 페이지의 fetch 에 붙여두면 한 번에 다 무효화.

3. 계층적 태그 전략

ts
// 글 목록 페이지
fetch('/api/posts', { next: { tags: ['posts'] } });

// 글 상세 페이지
fetch(`/api/posts/${id}`, { next: { tags: ['posts', `post-${id}`] } });

// 댓글
fetch(`/api/posts/${id}/comments`, { next: { tags: [`post-${id}`, `comments-${id}`] } });
ts
// 댓글 추가 → 그 글의 댓글만 무효화 (전체 글 목록은 그대로)
revalidateTag(`comments-${id}`);

// 글 수정 → 그 글과 목록 모두 무효화
revalidateTag('posts');       // 목록
revalidateTag(`post-${id}`);  // 상세

// 글 삭제 → 모든 글 관련 캐시 무효화
revalidateTag('posts');

4. revalidatePath — 경로 단위 무효화

ts
revalidatePath('/posts');                    // /posts 페이지의 모든 fetch
revalidatePath('/posts/[slug]', 'page');     // 동적 경로 전체
revalidatePath('/blog', 'layout');           // layout 까지 포함 (모든 자식)

tags 만큼 정확하진 않지만 마이그레이션·임시 처리에 유용.

5. 시간 기반 revalidate (ISR)

ts
fetch('https://api/posts', {
  next: { revalidate: 60 },  // 60초간 캐시, 그 후 백그라운드 재생성
});

// 페이지 레벨 — 페이지의 모든 fetch 의 최대 revalidate
export const revalidate = 60;

mutation 이 없거나 드문 데이터 (블로그·문서·랜딩) 에 적합.

6. 정리 — 의사결정 플로우

code
mutation 이 자주 일어남?
├─ 예 → tags + revalidateTag (정확한 무효화)
└─ 아니오
   └─ 데이터가 시간으로 만료됨?
      ├─ 예 → revalidate: N (ISR)
      └─ 아니오 → 기본 force-cache (영구 캐시)

사용자별 데이터?
└─ cache: 'no-store' (SSR 매번)
💻 🅰️ tags 없이 — revalidatePath 만 사용
// ❌ revalidatePath 만 — 모든 관련 경로를 알고 있어야 함

// 📁 app/posts/page.tsx
async function fetchPosts() {
  return fetch('https://api/posts').then(r => r.json());
  // 기본 force-cache
}

// 📁 app/sidebar/RecentPosts.tsx (다른 컴포넌트에서도 사용)
async function fetchRecentPosts() {
  return fetch('https://api/posts?limit=5').then(r => r.json());
}

// 📁 app/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.post.create({ data: { title: formData.get('title') as string } });

  // 모든 관련 경로를 일일이 무효화
  revalidatePath('/posts');           // 글 목록
  revalidatePath('/');                // 홈에 RecentPosts 있음
  revalidatePath('/sidebar', 'layout'); // 사이드바에 RecentPosts 있음
  // 한 곳이라도 빠뜨리면 그 페이지엔 옛 데이터
}

// 단점:
// - 데이터를 사용하는 모든 페이지를 mutation 코드가 알아야 함
// - 새 페이지 추가하면 mutation 코드도 수정 필요
// - 사이드바·푸터 등 글로벌 컴포넌트가 관련 데이터 쓰면 layout 전체 무효화
💻 🅱️ tags 시스템 — 데이터 단위 무효화
// ✅ tags + revalidateTag — 데이터 단위 무효화

// 📁 app/posts/page.tsx
async function fetchPosts() {
  return fetch('https://api/posts', {
    next: { tags: ['posts'] },
  }).then(r => r.json());
}

// 📁 app/posts/[id]/page.tsx
async function fetchPost(id: string) {
  return fetch(`https://api/posts/${id}`, {
    next: { tags: ['posts', `post-${id}`] },
  }).then(r => r.json());
}

// 📁 app/posts/[id]/Comments.tsx
async function fetchComments(id: string) {
  return fetch(`https://api/posts/${id}/comments`, {
    next: { tags: [`post-${id}`, `comments-${id}`] },
  }).then(r => r.json());
}

// 📁 app/sidebar/RecentPosts.tsx (어디서든)
async function fetchRecentPosts() {
  return fetch('https://api/posts?limit=5', {
    next: { tags: ['posts'] },
  }).then(r => r.json());
}

// 📁 app/posts/actions.ts — mutation 코드는 태그만 알면 됨
'use server';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  await db.post.create({ data: { title: formData.get('title') as string } });
  revalidateTag('posts');
  // 'posts' 태그가 붙은 모든 fetch (목록·홈 RecentPosts·사이드바 등) 자동 무효화
}

export async function updatePost(id: number, formData: FormData) {
  await db.post.update({
    where: { id },
    data: { title: formData.get('title') as string },
  });
  revalidateTag('posts');           // 목록 (제목 바뀜)
  revalidateTag(`post-${id}`);      // 상세 + 댓글 (댓글은 post-{id} 도 태그)
}

export async function addComment(postId: number, formData: FormData) {
  await db.comment.create({
    data: { postId, text: formData.get('text') as string },
  });
  revalidateTag(`comments-${postId}`); // 그 글의 댓글만 (전체 글 목록·다른 글은 그대로)
}

export async function deletePost(id: number) {
  await db.post.delete({ where: { id } });
  revalidateTag('posts');
  revalidateTag(`post-${id}`);
  redirect('/posts');
}

// 장점:
// - mutation 코드가 데이터 사용처를 몰라도 됨
// - 새 페이지에 같은 fetch 추가해도 mutation 수정 X
// - 정확도 ↑ (필요한 태그만, 무관한 페이지 캐시 유지)
// - on-demand 가능 — webhook 받아서 revalidateTag 호출도 가능

💡 💡 Revalidation 실전 5

1. 기본 정책 — fetch 에 tags, mutation 에 revalidateTag
90% 의 경우 이 한 패턴이면 끝.

2. revalidatePath 는 fallback 용
tags 가 어렵거나 빠른 마이그레이션 때만. 정밀도가 낮음.

3. cache: 'no-store' 는 진짜 사용자별 데이터에만
남발하면 모든 페이지가 SSR → 서버 부하 + 느림. 대신 부분만 Client Component 분리.

4. revalidate: N 휴리스틱

code
분당 1회 미만 변경    → revalidate: 60
시간당 몇 번         → revalidate: 300~600
거의 안 바뀜         → force-cache (기본) + tags

5. on-demand 패턴 — 웹훅으로 외부에서 무효화

ts
// app/api/revalidate/route.ts — 외부에서 호출
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const secret = req.nextUrl.searchParams.get('secret');
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  }
  const { tag } = await req.json();
  revalidateTag(tag);
  return NextResponse.json({ revalidated: true });
}

CMS·외부 시스템이 데이터 바꿀 때 알림 보내서 자동 무효화.

⚡ 직접 실행해보기 — tags 기반 무효화 시뮬레이션

여러 페이지가 다양한 태그로 캐시되어 있을 때 revalidateTag 가 정확히 무엇을 무효화하는지.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

글 상세 페이지의 댓글 섹션 fetch 만 무효화하고 싶을 때 가장 정확한 도구는?
💡 **tags 기반 무효화** 가 가장 정확합니다. 댓글 fetch 에 `comments-42` 태그를 붙이고, 댓글 추가 Server Action 에서 `revalidateTag('comments-42')` 호출하면 그 글의 댓글만 정확히 무효화됩니다. 글 본문·다른 글·사이드바는 모두 캐시 유지. revalidatePath 는 너무 광범위, no-store 는 캐시 자체를 포기.
먼저 읽으면 좋은 개념: Metadata API — generateMetadata + OG/Twitter
다음 추천: Python 완전 정복
Revalidation 전략 — revalidatePath / revalidateTag - Next.js