Published on

Next.js App Router 캐시 꼬임·재검증 버그 해결

Authors

서버 컴포넌트 기반의 Next.js App Router는 **기본값이 ‘캐시가 된다’**에 가깝습니다. 문제는 이 기본값이 개발자가 의도한 데이터 신선도와 어긋날 때, “어제 데이터가 계속 보인다”, “revalidateTag를 호출했는데 안 바뀐다”, “어떤 사용자만 최신이고 어떤 사용자는 구버전이다” 같은 캐시 꼬임 현상으로 나타난다는 점입니다. 특히 App Router는 Route Segment 단위 정적화, fetch 캐시, ISR(재검증), 서버 액션/라우트 핸들러, CDN 캐시가 동시에 얽히기 쉬워, 한 군데만 잘못 설정해도 디버깅이 매우 고통스럽습니다.

이 글에서는 App Router에서 자주 겪는 캐시/재검증 버그를 재현 가능한 증상 → 원인 → 해결 패턴으로 정리합니다. (운영 환경에서 “원인은 앱이 아니라 인프라 캐시/프록시였다”로 귀결되는 케이스도 많아, 마지막에 CDN/Ingress 체크리스트도 포함합니다.)

1) App Router 캐시 모델: 어디가 캐시되는가

App Router에서 캐시가 생기는 지점은 크게 4개입니다.

  1. Route Segment(페이지/레이아웃) 캐시: 페이지가 정적으로 판정되면 HTML/RSC 페이로드가 캐시됩니다.
  2. fetch() 데이터 캐시: 서버 컴포넌트에서 호출한 fetch()는 기본적으로 Next의 Data Cache에 저장될 수 있습니다(옵션에 따라).
  3. ISR 재검증: revalidate(초 단위) 또는 revalidatePath/revalidateTag로 캐시 무효화.
  4. 외부 캐시(CDN/프록시/브라우저): Cache-Control 헤더 또는 프록시 설정으로 “앱이 무효화했는데도” 계속 구버전이 서빙될 수 있습니다.

핵심은 정적/동적 판정fetch 캐시 정책을 분리해서 이해하는 것입니다.

  • 페이지가 동적이어도 fetch가 캐시되면 데이터가 stale처럼 보일 수 있습니다.
  • 페이지가 정적으로 굳어버리면 revalidateTag를 호출해도 “그 페이지 자체”가 다시 계산되지 않아 효과가 없다고 느낄 수 있습니다.

2) 대표 증상 6가지와 ‘진짜 원인’

증상 A: revalidateTag()를 호출했는데 화면이 안 바뀜

  • 원인 1: 태그를 걸지 않은 fetch를 재검증하고 있음
  • 원인 2: 재검증 대상이 fetch 캐시가 아니라 “페이지 정적 결과”인데, 페이지가 정적으로 굳어 있음
  • 원인 3: 서버 액션은 실행됐지만, 실제로는 다른 경로(다른 태그/다른 URL)의 캐시가 남아 있음

증상 B: 개발 환경에선 잘 되는데 프로덕션에서만 stale

  • 원인 1: Vercel/Node 런타임 차이, Edge 런타임에서의 제약
  • 원인 2: CDN/Ingress가 s-maxage를 무시하거나, 반대로 너무 공격적으로 캐시함

증상 C: 특정 사용자(로그인 사용자)만 데이터가 꼬임

  • 원인: cookies()/headers() 사용으로 동적화되었지만, 내부 fetch가 캐시되어 사용자별 데이터가 섞임(특히 Authorization 헤더를 누락하거나 캐시 키가 동일한 경우)

증상 D: router.refresh()를 해도 최신이 안 옴

  • 원인: refresh는 “서버 컴포넌트 재요청”일 뿐, 서버의 Data Cache가 그대로면 같은 응답이 돌아옴

증상 E: revalidatePath('/foo') 했는데 /foo?bar=baz는 안 바뀜

  • 원인: 경로/쿼리/동적 세그먼트에 대한 재검증 범위를 잘못 이해함(캐시 키가 다름)

증상 F: 배포 후 일부 인스턴스만 최신/일부는 구버전

  • 원인: 멀티 인스턴스 환경에서 캐시가 노드 로컬이거나, 외부 캐시가 인스턴스별로 다르게 동작

3) 재현: 태그 재검증이 안 먹는 전형적인 코드

아래는 “태그를 재검증했는데도 데이터가 안 바뀌는” 가장 흔한 실수입니다. fetch에 태그를 달지 않았거나, no-store/force-cache를 의도와 다르게 써서 Data Cache가 기대와 다르게 동작합니다.

// app/products/page.tsx (Server Component)
async function getProducts() {
  const res = await fetch('https://api.example.com/products');
  // ❌ tags 없음 → revalidateTag('products')가 무슨 캐시를 지워야 할지 모름
  return res.json();
}

export default async function Page() {
  const products = await getProducts();
  return (
    <ul>
      {products.map((p: any) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}
// app/actions.ts (Server Action)
'use server';
import { revalidateTag } from 'next/cache';

export async function refreshProducts() {
  // ✅ 호출은 했지만 실제로 지울 태그가 fetch에 연결돼 있지 않으면 효과 없음
  revalidateTag('products');
}

4) 해결 패턴 1: fetch에 태그/재검증 정책을 “명시적으로” 붙이기

태그 기반 재검증을 쓰려면, 해당 데이터가 들어오는 fetch반드시 태그를 달아야 합니다.

// app/products/page.tsx
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: {
      tags: ['products'],
      // 선택 1) TTL 기반 ISR
      revalidate: 60,
    },
  });

  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}

export default async function Page() {
  const products = await getProducts();
  return (
    <ul>
      {products.map((p: any) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

그리고 서버 액션/라우트 핸들러에서 태그를 재검증합니다.

// app/actions.ts
'use server';
import { revalidateTag } from 'next/cache';

export async function refreshProducts() {
  revalidateTag('products');
}

이 패턴의 장점은 “어떤 데이터 캐시를 지우는지”가 명확하다는 점입니다. 운영 장애 대응에서 이런 명시성은 디버깅 시간을 크게 줄입니다.

5) 해결 패턴 2: 사용자별/권한별 데이터는 no-store로 분리

로그인 사용자별 데이터(예: 내 주문, 내 알림, 권한 기반 가격)는 Data Cache에 넣으면 안 되는 경우가 대부분입니다. 특히 Authorization 헤더가 들어가는데 fetch가 캐시되면, 캐시 키가 기대대로 분리되지 않아 데이터가 섞이는 것처럼 보이는 사고가 납니다.

import { cookies } from 'next/headers';

async function getMyOrders() {
  const token = (await cookies()).get('token')?.value;

  const res = await fetch('https://api.example.com/me/orders', {
    headers: {
      Authorization: `Bearer ${token}`,
    },
    cache: 'no-store', // ✅ 사용자별 데이터는 캐시 금지
  });

  if (!res.ok) throw new Error('Failed to fetch my orders');
  return res.json();
}

정리하면:

  • 공개 데이터/목록/상세: next: { tags, revalidate } 조합으로 ISR + 태그 재검증
  • 사용자별 데이터: cache: 'no-store' 또는 매우 짧은 TTL + 강한 캐시 키 분리

6) 해결 패턴 3: 페이지가 “정적으로 굳는” 것을 의도적으로 제어하기

App Router는 빌드/런타임에서 페이지가 정적으로 판단되면 결과를 캐시합니다. 이때 개발자가 revalidateTag만 믿고 있으면 “왜 안 바뀌지?”가 됩니다.

다음 중 하나를 선택해 의도를 코드로 고정하세요.

옵션 A) 무조건 동적(캐시 꼬임을 먼저 끊고 원인 축소)

// app/products/page.tsx
export const dynamic = 'force-dynamic';

export default async function Page() {
  // ...
}

운영 비용(요청당 렌더링)이 증가할 수 있지만, 장애 상황에서 원인 분리에 매우 효과적입니다. 이후 안정화되면 필요한 곳만 다시 ISR로 되돌리는 전략을 추천합니다.

옵션 B) 정적 + ISR을 명시

// app/products/page.tsx
export const revalidate = 60; // Route Segment ISR

주의: Route Segment의 revalidate는 페이지/세그먼트 결과에 적용됩니다. 내부 fetchnext.revalidate와 섞이면, 어떤 레벨에서 stale이 발생하는지 헷갈릴 수 있으니 팀 컨벤션을 정하는 게 좋습니다.

7) 해결 패턴 4: revalidatePath vs revalidateTag 제대로 쓰기

  • revalidateTag(tag): 데이터 단위 무효화(여러 페이지에서 공유하는 목록/설정/카테고리 등)
  • revalidatePath(path): 경로 단위 무효화(특정 페이지/하위 트리)

실무에서는 보통 다음처럼 씁니다.

  • “상품 수정 → 상품 상세/목록 모두 갱신”: revalidateTag('product:123'), revalidateTag('products')
  • “CMS에서 특정 페이지 편집 → 그 페이지만 갱신”: revalidatePath('/pages/about')

예시:

// app/admin/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';

export async function updateProduct(productId: string, payload: any) {
  await fetch(`https://api.example.com/products/${productId}`, {
    method: 'PUT',
    body: JSON.stringify(payload),
    headers: { 'Content-Type': 'application/json' },
  });

  // ✅ 공유 목록/상세를 태그로 갱신
  revalidateTag('products');
  revalidateTag(`product:${productId}`);

  // ✅ 특정 경로를 확실히 갱신하고 싶다면 path도 병행
  revalidatePath('/products');
  revalidatePath(`/products/${productId}`);
}

쿼리스트링이 중요한 페이지라면(예: /products?sort=popular) 캐시 키가 달라질 수 있으므로, 태그 중심으로 묶어두는 편이 운영이 쉽습니다.

8) 장애 대응 디버깅 체크리스트(“꼬임”을 빠르게 풀기)

8.1 서버에서 지금 무엇이 캐시되는지 확인

  1. 문제가 되는 fetchcache: 'no-store'를 임시로 걸어 즉시 현상이 사라지는지 확인
  2. 페이지에 export const dynamic = 'force-dynamic'를 임시 적용해 정적화 문제인지 확인
  3. next: { tags: [...] }가 실제로 모든 데이터 fetch에 붙어있는지 점검

이 3단계로 대개 “페이지 정적화 문제” vs “fetch 캐시 문제”를 10분 내로 분리할 수 있습니다.

8.2 운영 인프라(CDN/Ingress)가 캐시를 덮어쓰는지 확인

App Router 재검증이 정상이어도, 앞단이 캐시하면 사용자는 계속 구버전을 봅니다. 특히 NGINX Ingress나 CDN에서 Cache-Control을 변형하거나, 응답 크기/버퍼 문제로 예외 응답이 캐시되는 경우도 있습니다. 인그레스 튜닝 관점은 EKS NGINX Ingress 400·413 해결 - body·버퍼 튜닝도 함께 참고하면 좋습니다.

또한 “일부 요청만 실패/일부만 stale”처럼 보일 때는 네트워크/DNS의 간헐 문제가 캐시 문제로 오인되는 경우가 있습니다. 클러스터 환경이라면 EKS에서 CoreDNS 정상인데 DNS가 간헐 실패할 때 같은 체크리스트로 원인을 좁혀보세요.

프론트 성능 이슈가 캐시 꼬임처럼 보이는 케이스도 있습니다(예: 새 데이터는 왔는데 렌더링이 느려 이전 상태처럼 느껴짐). 이 경우엔 Chrome INP 급등? Long Task 추적·해결 가이드처럼 Long Task를 먼저 제거하는 게 체감 해결로 이어집니다.

9) 추천 아키텍처: “태그 설계”를 먼저 하고 캐시를 얹기

캐시 꼬임을 줄이는 가장 현실적인 방법은, 데이터 도메인별로 태그 네이밍을 표준화하는 것입니다.

  • 목록: products, posts, categories
  • 단건: product:ID, post:ID
  • 관계/집계: product:ID:reviews, category:ID:products

그리고 규칙을 고정합니다.

  • 모든 서버 fetch는 next.tags 또는 cache: 'no-store' 중 하나를 반드시 선택
  • 관리 기능(쓰기)은 반드시 관련 태그를 재검증
  • 페이지 경로 재검증은 “최후의 보루”로 사용(경로 폭발 방지)

이렇게 하면 “어떤 캐시를 지워야 하는지”가 팀 단위로 공유되고, 재검증이 누락되어 발생하는 버그가 급감합니다.

10) 마무리: 캐시 버그를 ‘설정’이 아니라 ‘설계’로 다루기

Next.js App Router의 캐시는 성능을 크게 올려주지만, 기본 동작을 정확히 이해하지 못하면 재검증이 무력화되거나 사용자별 데이터가 섞이는 형태로 장애가 납니다. 해결의 핵심은 다음 3줄로 요약됩니다.

  • 데이터 fetch마다 캐시 정책을 명시하라(next.tags/revalidate 또는 no-store).
  • 재검증은 태그 중심으로 설계하고, 경로 재검증은 보조로 써라.
  • 프로덕션에선 CDN/Ingress 캐시가 재검증을 가로채지 않는지 반드시 확인하라.

위 패턴대로 정리하면 “캐시 꼬임”은 재현 가능하고, 수정도 예측 가능해집니다. 운영 중이라면 먼저 force-dynamic/no-store로 증상을 끊어내고, 태그 설계를 도입해 점진적으로 ISR을 복구하는 접근이 가장 안전합니다.