- Published on
Next.js App Router 캐시 꼬임·재검증 버그 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트 기반의 Next.js App Router는 **기본값이 ‘캐시가 된다’**에 가깝습니다. 문제는 이 기본값이 개발자가 의도한 데이터 신선도와 어긋날 때, “어제 데이터가 계속 보인다”, “revalidateTag를 호출했는데 안 바뀐다”, “어떤 사용자만 최신이고 어떤 사용자는 구버전이다” 같은 캐시 꼬임 현상으로 나타난다는 점입니다. 특히 App Router는 Route Segment 단위 정적화, fetch 캐시, ISR(재검증), 서버 액션/라우트 핸들러, CDN 캐시가 동시에 얽히기 쉬워, 한 군데만 잘못 설정해도 디버깅이 매우 고통스럽습니다.
이 글에서는 App Router에서 자주 겪는 캐시/재검증 버그를 재현 가능한 증상 → 원인 → 해결 패턴으로 정리합니다. (운영 환경에서 “원인은 앱이 아니라 인프라 캐시/프록시였다”로 귀결되는 케이스도 많아, 마지막에 CDN/Ingress 체크리스트도 포함합니다.)
1) App Router 캐시 모델: 어디가 캐시되는가
App Router에서 캐시가 생기는 지점은 크게 4개입니다.
- Route Segment(페이지/레이아웃) 캐시: 페이지가 정적으로 판정되면 HTML/RSC 페이로드가 캐시됩니다.
fetch()데이터 캐시: 서버 컴포넌트에서 호출한fetch()는 기본적으로 Next의 Data Cache에 저장될 수 있습니다(옵션에 따라).- ISR 재검증:
revalidate(초 단위) 또는revalidatePath/revalidateTag로 캐시 무효화. - 외부 캐시(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는 페이지/세그먼트 결과에 적용됩니다. 내부 fetch의 next.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 서버에서 지금 무엇이 캐시되는지 확인
- 문제가 되는
fetch에cache: 'no-store'를 임시로 걸어 즉시 현상이 사라지는지 확인 - 페이지에
export const dynamic = 'force-dynamic'를 임시 적용해 정적화 문제인지 확인 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을 복구하는 접근이 가장 안전합니다.