Next.js App Router: Router Cache와 tags 기반 revalidateTag 무효화 기법

Next.js App Router tags와 revalidateTag 캐시 무효화 가이드
3초 요약: 이렇게 고치세요
  • 원인: App Router는 fetch 응답을 Data Cache에 저장해 재사용하는데, DB 값이 바뀌어도 이 캐시는 그대로 남아있음
  • 해결: 시간 지나면 알아서 갱신돼도 되는 데이터 → revalidate 옵션 / 수정 이벤트가 터진 순간에만 즉시 갱신하고 싶은 데이터 → tags + revalidateTag
  • 주의: revalidateTag는 서버 쪽 캐시만 지움. 이미 열려 있던 브라우저 탭의 라우터 캐시까지 자동으로 밀어주지는 않음
fetch(url, { next: { tags: ['product-12'] } });
revalidateTag('product-12'); // 웹훅/서버 액션에서 호출
↓ 왜 이런 에러가 나는지 원리 보기

관리자 페이지에서 상품 가격이나 재고를 수정했는데, 실제 사용자 화면에는 한참 동안 예전 값이 그대로 남아있는 걸 본 적 있으실 겁니다. 매번 다시 빌드할 수도 없고, 캐시 시간을 무작정 짧게 잡으면 DB 부하만 늘어납니다.

Next.js App Router에는 이 문제를 위한 tagsrevalidateTag가 있습니다. 평소엔 정적으로 캐싱해 두다가, 데이터가 실제로 바뀐 시점에만 특정 태그가 붙은 캐시만 골라서 지우는 방식입니다. 다만 이 API를 실제로 써 보면 문서만 봐서는 잘 안 보이는 함정이 하나 있는데, 그 얘기까지 같이 정리해 보겠습니다.


왜 DB는 바뀌었는데 화면은 그대로일까요

App Router는 서버 컴포넌트 안에서 실행되는 fetch 요청을 기본적으로 캐싱합니다. 같은 요청을 다시 보내도 실제 네트워크 통신 없이 저장해 둔 응답(Data Cache)을 그대로 재사용하고, 빌드 시점에 만들어진 페이지 결과물(Full Route Cache)도 서버에 남아있다가 재사용됩니다. 덕분에 페이지는 빠르지만, DB 값이 바뀌어도 캐시가 알아서 눈치채고 갱신되지는 않습니다.

가장 단순한 대응은 { next: { revalidate: 3600 } }처럼 시간 기준으로 캐시 만료 시점을 정해두는 것입니다. 하지만 이 방식은 최대 1시간 동안은 무조건 예전 데이터가 노출된다는 뜻이기도 합니다. 반대로 { cache: 'no-store' }로 캐시를 아예 꺼버리면 매 요청마다 DB를 직접 때리게 되어 캐싱의 이점이 사라집니다. revalidateTag는 이 둘 사이의 절충안입니다. 평소엔 캐시를 그대로 쓰다가, 데이터가 실제로 바뀐 순간에만 해당 태그가 붙은 캐시 항목을 지워서 다음 요청부터 새 데이터를 가져오게 만듭니다.

시간 기반 재검증 vs tags 기반 온디맨드 재검증

시간 기반 (revalidate: N) 1시간 캐싱 설정 -> 30분 뒤 관리자가 가격 수정 -> 남은 30분 동안 접속한 사용자는 계속 예전 가격을 봄 -> 시간이 지나야만 정상화.
tags 기반 온디맨드 (revalidateTag) 평소엔 캐시된 응답으로 고속 렌더링 -> 관리자가 가격 수정 -> revalidateTag('product-12') 호출 -> 해당 태그 캐시만 즉시 만료 -> 다음 요청부터 새 값 반영.

헷갈리기 쉬운 지점: 라우터 캐시는 따로 움직입니다

revalidateTag가 지우는 건 서버 쪽 Data Cache와 Full Route Cache입니다. 그런데 App Router에는 브라우저 쪽에도 캐시가 하나 더 있습니다. 사용자가 <Link>로 페이지를 이동할 때 쓰는 라우터 캐시(Client-side Router Cache)로, 한 번 방문한 라우트의 렌더링 결과를 브라우저 메모리에 잠깐 들고 있다가 뒤로/앞으로 가기나 재방문 시 재사용합니다.

문제는 웹훅에서 revalidateTag를 호출해도 이 라우터 캐시까지는 건드리지 못한다는 점입니다. 이미 그 페이지를 열어둔 채로 다른 곳을 갔다가 링크를 눌러 다시 돌아오는 사용자는, 서버 캐시가 갱신됐어도 브라우저에 남아있던 예전 렌더링 결과를 그대로 보게 됩니다. 반면 주소창에 직접 입력하거나 새로고침(하드 내비게이션)을 하면 라우터 캐시를 거치지 않고 서버에 새로 요청하므로 곧바로 최신 데이터가 보입니다. "새로고침하면 되는데 링크 타고 들어가면 안 바뀐다"는 문의가 나온다면 대부분 이 차이 때문입니다.


실제로 겪었던 사례

예전에 외주로 맡았던 쇼핑몰 프로젝트에서 상품 상세 페이지를 Next.js App Router로 옮긴 적이 있습니다. 상품 데이터를 product-{id} 태그로 묶어두고, 어드민에서 가격이나 재고를 바꾸면 웹훅이 /api/revalidate를 호출해 revalidateTag를 실행하는 구조로 설계했습니다. 테스트해보니 잘 동작해서 별생각 없이 배포했습니다.

그런데 QA 담당자가 "가격을 내렸는데 상세 페이지에서는 여전히 예전 가격이 보인다"고 리포트를 올렸습니다. 처음엔 웹훅이 아예 안 불렸거나 시크릿 토큰이 안 맞는 줄 알고 서버 로그부터 뒤졌는데, 웹훅은 정상 호출되고 있었고 revalidateTag도 에러 없이 실행되고 있었습니다. 한참 헤매다가 QA가 상품 목록에서 링크를 클릭해서 상세 페이지로 들어갔다는 걸 알게 됐고, 그제서야 서버 캐시는 이미 지워졌는데 브라우저 쪽 라우터 캐시가 예전 화면을 물고 있었다는 걸 깨달았습니다. 새로고침을 해보라고 하니 바로 새 가격이 떴습니다.

결국 상품처럼 가격 정합성이 중요한 라우트는 next.config.jsexperimental.staleTimes에서 dynamic 값을 0에 가깝게 낮춰서 라우터 캐시를 사실상 짧게 유지하도록 바꿨습니다. 성능보다 정합성이 우선인 화면이라면 이쪽을 먼저 의심해 보시길 권합니다.


실전 코드: tags 바인딩 및 revalidateTag 구현

서버 컴포넌트에서 태그를 달아 데이터를 가져오고, 웹훅으로 호출되는 Route Handler에서 이를 무효화하는 구조입니다.

Step 1. 서버 컴포넌트에서 fetch에 태그 달기 (page.tsx)

// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';

interface Product {
  id: string;
  name: string;
  price: number;
}

async function getProduct(id: string): Promise<Product | null> {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    // 이 요청 결과를 식별할 태그를 지정
    next: { tags: ['products', `product-${id}`] },
  });

  if (!res.ok) return null;
  return res.json();
}

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);

  if (!product) {
    notFound();
  }

  return (
    <main style={{ padding: '30px', fontFamily: 'sans-serif' }}>
      <h1 style={{ fontSize: '24px', color: '#1e293b' }}>{product.name}</h1>
      <p style={{ fontSize: '18px', color: '#0f172a', fontWeight: 'bold' }}>
        판매가: {product.price.toLocaleString()}원
      </p>
    </main>
  );
}

Step 2. 웹훅을 받아 revalidateTag를 호출하는 Route Handler (route.ts)

// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';

export async function POST(request: NextRequest) {
  try {
    const { targetTag, secretToken } = await request.json();

    // 시크릿 토큰으로 아무나 캐시를 못 지우게 막기
    if (secretToken !== process.env.REVALIDATE_SECRET) {
      return NextResponse.json({ message: '허가되지 않은 접근' }, { status: 401 });
    }

    if (!targetTag) {
      return NextResponse.json({ message: '태그명이 누락되었습니다.' }, { status: 400 });
    }

    // 해당 태그가 붙은 서버 캐시를 무효화
    revalidateTag(targetTag);

    return NextResponse.json({
      revalidated: true,
      message: `태그 [${targetTag}]의 캐시를 무효화했습니다.`,
    });
  } catch (err) {
    return NextResponse.json({ message: '서버 내부 에러' }, { status: 500 });
  }
}

해결 코드의 라인별 동작 해설

getProducttags: ['products', `product-${id}`]는 이 fetch 결과를 'products'라는 넓은 그룹 태그와 'product-12' 같은 개별 태그, 두 개의 이름표로 캐시에 등록해 둡니다. 나중에 이 중 어느 태그로 revalidateTag를 호출해도 이 캐시 항목이 지워집니다.

관리자가 12번 상품 가격을 바꾸면 어드민 서버가 /api/revalidate{ "targetTag": "product-12", "secretToken": "..." } 형태의 POST 요청을 보냅니다. 토큰이 맞으면 revalidateTag('product-12')가 실행되고, Next.js는 그 태그가 붙은 캐시 항목을 만료 처리합니다. 이후 서버로 새로 들어오는 요청(하드 내비게이션, 새로고침, 다른 사용자의 첫 방문 등)은 캐시 미스가 나서 getProduct가 다시 실행되고 최신 데이터를 받아옵니다.

단, 위에서 짚었듯 이미 그 페이지를 열어둔 브라우저 탭이 라우터 캐시를 갖고 있다면 링크 이동만으로는 안 바뀔 수 있습니다. 이 부분까지 즉시 반영하고 싶다면 수정이 일어나는 화면에서 router.refresh()를 같이 호출하거나, next.config.js에서 staleTimes.dynamic 값을 낮춰서 라우터 캐시가 오래 버티지 않도록 조정하는 방법이 있습니다.


요약: revalidatePath vs revalidateTag

상황에 따라 골라 쓸 수 있는 두 API를 정리했습니다.

API 동작 범위 적합한 상황
revalidatePath(path) 지정한 경로 하나의 캐시를 전부 무효화. 그 경로에서 쓴 데이터가 다른 페이지와 태그를 공유해도 그 페이지는 그대로 캐시된 채 남음 특정 페이지 레이아웃이 바뀐 경우
revalidateTag(tag) 경로와 무관하게 같은 태그를 쓰는 모든 페이지의 캐시를 함께 무효화 상품, 게시글처럼 여러 화면에서 재사용되는 단일 데이터 갱신
추가 추천 자료 및 공식 레퍼런스

revalidateTag의 정확한 파라미터와 최신 변경 사항은 Next.js 공식 문서에서 확인할 수 있습니다.

호마다의 웹개발 팁

tags와 revalidateTag를 다루려면 Cache-Control 헤더 같은 HTTP 캐시의 기본기가 먼저 서 있어야 이해가 빠릅니다. 관련 기초는 [호마다의 IT 개발 입문 블로그] 에서 확인하실 수 있습니다.

댓글

이 블로그의 인기 게시물

TypeScript: Unknown vs Any 타입 차이점과 안전한 타입 가드(Type Guard)

Next.js 하이드레이션 오류 원인과 해결법 완벽 정리

Next.js App Router에서 getServerSideProps 대체 및 데이터 페칭법