App Router의 캐시/재검증은 어떻게 이해해야 하는가

Frontend

Next.js App Router를 처음 쓰면 데이터 패칭 코드는 오히려 더 단순해 보입니다.

export default async function Page() {
  const data = await fetch('https://api.example.com/posts').then((res) => res.json());
 
  return <div>{data.length}</div>;
}

문제는 그 다음입니다.

  • 이 데이터는 캐시되는가
  • 언제 다시 가져오는가
  • 왜 수정했는데 바로 안 바뀌는가
  • revalidateno-store는 무엇이 다른가
  • revalidatePath, revalidateTag는 언제 써야 하는가

실무에서 App Router가 어렵게 느껴지는 이유는 라우팅보다 오히려 이 캐시 모델 때문인 경우가 많습니다.

특히 Pages Router의:

  • getServerSideProps
  • getStaticProps
  • ISR

감각으로 접근하면 "지금 이 페이지가 정확히 어떤 상태인 거지?"라는 혼란이 쉽게 생깁니다.

이 글에서는 App Router의 캐시/재검증을 옵션 암기식으로 설명하지 않고, 실무에서 어떤 화면에 어떤 전략을 쓰는가 기준으로 정리해보겠습니다.

한눈에 보면

먼저 아주 짧게 정리하면 이렇습니다.

  • 기본적으로 App Router에서는 fetch가 캐시와 연결됩니다
  • revalidate는 "이 데이터는 일정 시간 동안 재사용해도 된다"는 뜻에 가깝습니다
  • cache: 'no-store'는 "매 요청마다 새로 가져와라"에 가깝습니다
  • revalidatePath는 특정 경로를 다시 보게 만들 때 씁니다
  • revalidateTag는 특정 데이터 그룹을 다시 보게 만들 때 씁니다

실무 감각으로 더 줄이면 이렇게 볼 수 있습니다.

상황 보통 선택
블로그 목록, 문서, 공개 페이지 revalidate
관리자 대시보드, 실시간성 높은 데이터 cache: 'no-store'
특정 페이지 하나만 새로고침하면 되는 경우 revalidatePath
여러 페이지에서 공유하는 같은 데이터 묶음 갱신 revalidateTag

먼저 무엇이 캐시되는지부터 이해해야 한다

App Router를 처음 보면 보통 "페이지가 캐시된다"라고 이해하기 쉽습니다. 틀린 말은 아니지만, 실무에서는 데이터 요청을 먼저 보는 편이 더 낫습니다.

예를 들어:

export default async function PostsPage() {
  const posts = await fetch('https://api.example.com/posts', {
    next: { revalidate: 600 },
  }).then((res) => res.json());
 
  return <PostList posts={posts} />;
}

이 코드는 단순히 페이지를 그리는 코드가 아니라, 아래 운영 정책을 같이 담고 있습니다.

  • 이 목록 데이터는 10분 정도 재사용해도 된다
  • 10분이 지나면 다시 검증할 수 있다

즉, App Router에서는 "렌더링 코드"와 "캐시 정책"이 가까이 붙습니다.

이 점이 장점이기도 하고, 처음엔 헷갈리는 이유이기도 합니다.

Pages Router 감각과 무엇이 다를까?

Pages Router에서는 보통 이렇게 생각했습니다.

  • getServerSideProps: 요청마다 새로 실행
  • getStaticProps: 빌드 시 생성
  • revalidate: 일정 주기 ISR

즉, 페이지 전체 렌더링 전략을 먼저 골랐습니다.

반면 App Router에서는 이 질문이 조금 바뀝니다.

  • 이 데이터는 매번 최신이어야 하는가
  • 일정 시간 재사용해도 되는가
  • 어떤 단위로 다시 무효화할 것인가

즉, App Router는 페이지 전체보다 데이터 요청 단위 정책이 더 전면으로 올라옵니다.

가장 많이 쓰는 옵션 1: revalidate

어떤 의미인가?

revalidate는 이 데이터를 일정 시간 동안 재사용할 수 있다는 뜻에 가깝습니다.

export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts', {
    next: { revalidate: 600 },
  }).then((res) => res.json());
 
  return <PostList posts={posts} />;
}

이 코드는 보통 이렇게 읽으면 됩니다.

  • 블로그 목록은 10분 정도 stale해도 괜찮다
  • 대신 매 요청마다 API를 다시 치고 싶지는 않다

즉, revalidate는 "실시간은 아니지만 너무 오래된 데이터도 싫다"는 상황에 잘 맞습니다.

어떤 화면에 잘 맞을까?

  • 블로그 목록
  • 문서 페이지
  • 상품 목록
  • 공개 마케팅 페이지

이런 화면은 보통 초 단위 최신성이 중요하지 않습니다. 그래서 revalidate가 가장 자연스럽습니다.

실무 예시

// app/articles/page.tsx
export default async function ArticlesPage() {
  const articles = await fetch('https://api.example.com/articles', {
    next: { revalidate: 300 },
  }).then((res) => res.json());
 
  return (
    <ul>
      {articles.map((article: { id: string; title: string }) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  );
}

이 화면은 5분 정도 캐시해도 문제가 크지 않다면 꽤 좋은 선택입니다.

가장 많이 쓰는 옵션 2: cache: 'no-store'

어떤 의미인가?

no-store는 매 요청마다 새 데이터를 가져오겠다는 의미에 가깝습니다.

export default async function DashboardPage() {
  const summary = await fetch('https://api.example.com/dashboard', {
    cache: 'no-store',
  }).then((res) => res.json());
 
  return <DashboardSummary summary={summary} />;
}

즉, "캐시보다 최신성이 중요하다"는 선언입니다.

어떤 화면에 잘 맞을까?

  • 관리자 대시보드
  • 주문 현황
  • 재고 현황
  • 사용자별 개인화 데이터

이런 화면은 예전 데이터가 보이면 오히려 문제가 됩니다. 그래서 no-store가 더 낫습니다.

실무에서 자주 하는 오해

no-store를 남발하면 가장 단순해 보입니다. 왜냐하면 "캐시 때문에 안 바뀌는 문제"가 줄기 때문입니다.

하지만 모든 요청에 no-store를 쓰면:

  • 서버 부하가 커지고
  • 응답 속도 이점이 줄고
  • App Router의 캐시 장점을 거의 못 쓰게 됩니다

즉, no-store는 안전한 기본값이 아니라 최신성이 정말 중요한 경우의 선택지에 가깝습니다.

revalidateno-store를 어떻게 고를까?

이 둘 사이에서 가장 많이 고민합니다.

아래처럼 생각하면 비교적 단순해집니다.

revalidate가 맞는 경우

  • 공개 데이터
  • 조금 stale해도 큰 문제 없음
  • API 비용이나 응답 속도 최적화가 중요함

no-store가 맞는 경우

  • 사용자별 데이터
  • 관리 화면
  • 매 요청 최신성이 중요함
  • stale 데이터가 실제 장애처럼 느껴짐

예를 들어 같은 서비스 안에서도 화면마다 다를 수 있습니다.

/blog           -> revalidate: 600
/products       -> revalidate: 300
/dashboard      -> no-store
/admin/orders   -> no-store

즉, App Router에서는 "서비스 전체 전략 하나"보다 화면과 데이터 성격에 맞는 전략이 더 중요합니다.

revalidatePath는 언제 쓸까?

여기서부터는 "캐시된 데이터를 다시 보게 만드는 방법"입니다.

예를 들어 관리자 화면에서 게시글을 수정했다고 해보겠습니다.

  • 게시글 상세 페이지도 갱신돼야 하고
  • 게시글 목록 페이지도 갱신돼야 할 수 있습니다

특정 경로 하나를 다시 보게 하고 싶다면 revalidatePath를 씁니다.

// app/actions.ts
'use server';
 
import { revalidatePath } from 'next/cache';
 
export async function updatePost(postId: string, formData: FormData) {
  await fetch(`https://api.example.com/posts/${postId}`, {
    method: 'PATCH',
    body: JSON.stringify({
      title: formData.get('title'),
    }),
  });
 
  revalidatePath('/posts');
  revalidatePath(`/posts/${postId}`);
}

이 코드는 이렇게 읽으면 됩니다.

  • 데이터 수정은 했고
  • /posts 목록과 /posts/[id] 상세를 다시 보게 만든다

즉, revalidatePath경로 기준 무효화입니다.

어떤 상황에 잘 맞을까?

  • 목록 페이지 하나
  • 상세 페이지 하나
  • 수정 후 돌아갈 특정 경로가 명확한 경우

즉, 영향 범위가 경로로 설명될 때 가장 직관적입니다.

revalidateTag는 언제 쓸까?

revalidatePath가 경로 기준이라면, revalidateTag데이터 그룹 기준입니다.

예를 들어 같은 상품 데이터가 여러 화면에서 쓰인다고 해보겠습니다.

  • 메인 추천 상품
  • 카테고리 목록
  • 상품 상세

이런 경우 경로를 하나씩 다 적는 것은 불편합니다. 대신 태그를 붙여서 관리할 수 있습니다.

// app/products/page.tsx
export default async function ProductsPage() {
  const products = await fetch('https://api.example.com/products', {
    next: { tags: ['products'], revalidate: 300 },
  }).then((res) => res.json());
 
  return <ProductList products={products} />;
}
// app/page.tsx
export default async function HomePage() {
  const featuredProducts = await fetch('https://api.example.com/products/featured', {
    next: { tags: ['products'], revalidate: 300 },
  }).then((res) => res.json());
 
  return <FeaturedProducts items={featuredProducts} />;
}

이제 상품 수정 이후에는:

'use server';
 
import { revalidateTag } from 'next/cache';
 
export async function updateProduct(productId: string, formData: FormData) {
  await fetch(`https://api.example.com/products/${productId}`, {
    method: 'PATCH',
    body: JSON.stringify({
      name: formData.get('name'),
    }),
  });
 
  revalidateTag('products');
}

이렇게 할 수 있습니다.

즉, revalidateTag는 "이 데이터 그룹을 쓰는 화면 전체를 다시 보게 하자"는 발상입니다.

어떤 상황에 잘 맞을까?

  • 같은 데이터가 여러 페이지에서 재사용될 때
  • 영향 범위를 경로보다 데이터 단위로 설명하는 편이 자연스러울 때
  • 상품, 게시글, 카테고리처럼 공통 데이터 그룹이 있을 때

revalidatePathrevalidateTag는 무엇이 다를까?

이 둘은 자주 헷갈립니다.

revalidatePath

  • 경로 기준
  • 특정 페이지나 레이아웃 갱신에 직관적
  • 수정 이후 돌아갈 화면이 명확할 때 좋음

revalidateTag

  • 데이터 기준
  • 여러 페이지에서 공유하는 데이터를 한 번에 갱신하기 좋음
  • 재사용 범위가 넓을수록 유리함

아래처럼 보면 간단합니다.

게시글 수정 후 /posts, /posts/123만 다시 보면 됨
-> revalidatePath
 
상품 데이터가 홈, 목록, 상세, 추천 영역에 모두 퍼져 있음
-> revalidateTag

즉, 경로로 설명되면 revalidatePath, 데이터 묶음으로 설명되면 revalidateTag가 더 자연스럽습니다.

실무 예제로 한 번에 묶어보자

예시 1. 블로그

블로그 목록은 약간 stale해도 괜찮습니다.

export default async function PostsPage() {
  const posts = await fetch('https://api.example.com/posts', {
    next: { revalidate: 600, tags: ['posts'] },
  }).then((res) => res.json());
 
  return <PostList posts={posts} />;
}

게시글 발행 후에는:

'use server';
 
import { revalidateTag } from 'next/cache';
 
export async function publishPost(postId: string) {
  await fetch(`https://api.example.com/posts/${postId}/publish`, {
    method: 'POST',
  });
 
  revalidateTag('posts');
}

즉, 블로그처럼 여러 화면이 같은 데이터를 공유하는 경우 태그가 잘 맞습니다.

예시 2. 관리자 주문 대시보드

주문 현황은 stale하면 곤란합니다.

export default async function AdminOrdersPage() {
  const orders = await fetch('https://api.example.com/admin/orders', {
    cache: 'no-store',
  }).then((res) => res.json());
 
  return <OrderTable orders={orders} />;
}

이 화면은 굳이 재검증보다 최신 데이터를 바로 보는 편이 낫습니다.

예시 3. 상품 관리

상품은 목록, 홈 추천, 상세에서 동시에 쓰입니다.

export async function getProducts() {
  return fetch('https://api.example.com/products', {
    next: { tags: ['products'], revalidate: 300 },
  }).then((res) => res.json());
}

수정 후에는:

'use server';
 
import { revalidateTag } from 'next/cache';
 
export async function saveProduct(productId: string, payload: { name: string }) {
  await fetch(`https://api.example.com/products/${productId}`, {
    method: 'PATCH',
    body: JSON.stringify(payload),
  });
 
  revalidateTag('products');
}

이 경우는 경로보다 태그가 훨씬 관리하기 쉽습니다.

자주 하는 실수

1. 모든 것을 no-store로 두는 것

처음에는 가장 덜 헷갈립니다. 하지만 결국 캐시 이점을 거의 다 버리게 됩니다.

2. 반대로 모든 것을 revalidate로 두는 것

관리자 화면이나 사용자 개인 데이터까지 이렇게 두면 stale 데이터로 인한 혼란이 커집니다.

3. 경로 갱신이 필요한데 태그를 안 붙이는 것

수정 후 "왜 홈은 바뀌었는데 목록은 안 바뀌지?" 같은 문제가 생기기 쉽습니다.

4. 영향 범위를 생각하지 않고 revalidatePath만 반복하는 것

경로가 많아지면 유지보수가 어려워집니다. 이럴 때는 태그가 더 낫습니다.

5. 캐시 정책을 화면이 아니라 API 기준으로만 생각하는 것

같은 API를 쓰더라도 화면마다 기대하는 최신성은 다를 수 있습니다.

즉, 기준은 API 자체보다 이 데이터를 어디서 어떻게 보여주는가에 더 가깝습니다.

어떻게 판단하면 좋을까?

실무에서는 아래 순서로 보면 가장 덜 헷갈립니다.

  1. 이 데이터가 stale해도 되는 시간을 먼저 정한다
  2. stale하면 안 되면 no-store
  3. 조금 stale해도 되면 revalidate
  4. 수정 후 특정 경로만 다시 보면 되면 revalidatePath
  5. 여러 화면에서 공유하는 데이터면 revalidateTag

즉, 옵션을 외우는 것보다 데이터의 최신성 요구사항과 영향 범위를 먼저 정하는 편이 훨씬 낫습니다.

정리하면

App Router의 캐시/재검증을 한 줄로 줄이면 이렇습니다.

페이지를 어떻게 렌더링할지보다, 데이터를 얼마나 오래 믿을 수 있고 언제 다시 무효화할지를 코드 가까이에서 정하는 방식입니다.

그래서 핵심은 옵션 이름이 아니라 아래 질문입니다.

  • 이 데이터는 얼마나 최신이어야 하는가
  • stale하면 실제로 문제가 되는가
  • 수정 이후 어떤 화면이 같이 갱신돼야 하는가
  • 그 범위는 경로 기준인가, 데이터 그룹 기준인가

이 질문에 답할 수 있으면:

  • revalidate
  • no-store
  • revalidatePath
  • revalidateTag

는 훨씬 덜 헷갈립니다.

같이 보면 좋은 글