- Published on
Next.js 14 RSC 캐시 꼬임·stale 데이터 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트 기반으로 앱을 만들다 보면 “데이터를 업데이트했는데 UI가 안 바뀐다”, “어떤 사용자에겐 최신인데 어떤 사용자에겐 옛날 데이터가 보인다” 같은 현상을 한 번쯤 겪습니다. Next.js 14의 App Router + RSC 환경에서는 페이지/레이아웃 단위 캐시, fetch 캐시, 라우트 핸들러 캐시, 클라이언트 라우팅 시의 RSC 페이로드 재사용이 겹치면서, 의도치 않게 stale 데이터가 남는 경우가 많습니다.
이 글에서는 Next.js 14에서 흔히 말하는 “RSC 캐시 꼬임”을 재현 가능한 원인 단위로 분해하고, 팀에서 운영 환경까지 안전하게 가져갈 수 있는 해결책(설정/코드/운영 체크리스트)을 정리합니다. Next.js 15에서도 유사한 문제가 반복되므로, 더 최신 관점은 Next.js 15 RSC 캐시로 stale UI 뜰 때 해결법도 함께 참고하면 좋습니다.
1) Next.js 14에서 “캐시”가 의미하는 것들
Next.js 14(App Router)에서 stale이 생길 때는 대개 아래가 섞여 있습니다.
- Route Segment Cache(정적/동적 판정):
page.tsx,layout.tsx가 정적으로 판단되면 빌드/런타임에서 결과가 재사용됩니다. - Data Cache(fetch 캐시): 서버에서 실행되는
fetch()는 기본적으로 캐시될 수 있습니다(특히 정적 세그먼트로 판정될 때). - Full Route Cache(페이지 HTML/RSC 페이로드 캐시): 결과물을 경로 단위로 캐싱/재사용.
- Client-side navigation 시의 RSC 페이로드 재사용: 브라우저에서 라우팅할 때 이전 결과와 합쳐 보이는 듯한 “UI만 stale” 착시가 생길 수 있습니다.
핵심은 “RSC라서 캐시된다”가 아니라, 정적 최적화 + fetch 캐시 + 라우트 캐시가 합쳐져서 stale이 발생한다는 점입니다.
2) 대표 증상별 원인 매핑
2.1 DB는 갱신됐는데 페이지가 안 바뀜
- 원인 A: 페이지가 정적으로 판정되어 Full Route Cache가 재사용됨
- 원인 B:
fetch()가 Data Cache에 남아 있음 - 원인 C: mutation 후 revalidate(무효화) 를 안 함
2.2 사용자별 데이터가 섞임(심각)
- 원인 A: 사용자별로 달라야 하는 요청을 캐시 가능한 fetch로 처리
- 원인 B:
cookies()/headers()를 안 쓰고, 서버에서 전역 토큰/세션을 잘못 참조 - 원인 C: CDN/프록시 캐시 헤더가 잘못 설정되어 응답이 공유됨
2.3 로컬에선 정상인데 배포하면 stale
- 원인 A: 개발 모드는 캐시 동작이 단순/무시되는 경우가 많음
- 원인 B: 배포 환경에서 ISR/캐시가 활성화되며 문제 노출
3) “정적 판정”을 먼저 의심하라
Next.js는 가능한 한 정적으로 만들려고 합니다. 아래 중 하나라도 충족하면 동적으로 바뀌는 경향이 있지만, 반대로 아무 것도 안 하면 정적으로 굳어 stale이 생길 수 있습니다.
3.1 강제로 동적 렌더링하기
정말로 항상 최신 데이터가 필요하면, 세그먼트를 동적으로 고정하세요.
// app/posts/page.tsx
export const dynamic = 'force-dynamic';
// 또는
export const revalidate = 0;
export default async function Page() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store',
});
const posts = await res.json();
return <pre>{JSON.stringify(posts, null, 2)}</pre>;
}
dynamic = 'force-dynamic': 해당 세그먼트는 정적으로 캐시되지 않음revalidate = 0: ISR 없이 매 요청 새로 생성(사실상 동적)
단, 무조건 동적으로 만들면 성능/비용이 증가하므로 정말 필요한 경로에만 적용하는 게 좋습니다.
4) fetch 캐시가 stale의 80%를 만든다
서버 컴포넌트에서 fetch()는 Next가 관여합니다. 특히 아래 케이스가 흔한 함정입니다.
4.1 인증/개인화 API 호출에 cache 옵션이 없다
사용자별 응답이 달라지는 API라면 기본값에 기대지 말고 명시적으로 no-store를 주는 게 안전합니다.
import { cookies } from 'next/headers';
export async function getMe() {
const token = (await cookies()).get('token')?.value;
const res = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) throw new Error('failed');
return res.json();
}
포인트:
- 개인화 데이터는
cache: 'no-store'를 기본으로 두고, 정말 캐시가 필요할 때만 제한적으로revalidate를 사용하세요.
4.2 최신이 필요하지만 “조금은 캐시”도 하고 싶다
이럴 땐 next: { revalidate: N }를 사용합니다.
export async function getPublicFeed() {
const res = await fetch('https://api.example.com/feed', {
next: { revalidate: 30 }, // 30초 동안은 캐시 재사용
});
return res.json();
}
- 공개 피드/공지/카탈로그 등 “약간의 지연 허용” 데이터에 적합
5) Mutation 이후 “무효화”를 하지 않으면 stale은 정상 동작이다
게시글 작성/수정/삭제 같은 mutation 이후에, 읽기 화면이 캐시를 쓰고 있다면 명시적으로 무효화(revalidate) 해야 합니다.
Next.js 14에서는 대표적으로 두 가지가 있습니다.
revalidatePath(path): 경로 단위 무효화revalidateTag(tag): 태그 단위 무효화(권장, 확장성 좋음)
5.1 태그 기반 캐시 무효화 패턴(권장)
읽기(fetch) 쪽에 태그를 붙이고, 쓰기(action/route handler)에서 해당 태그를 무효화합니다.
// app/lib/posts.ts
export async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: [`post:${id}`], revalidate: 3600 },
});
if (!res.ok) throw new Error('failed');
return res.json();
}
// app/posts/[id]/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function updatePost(id: string, payload: { title: string }) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('update failed');
// 읽기 캐시 무효화
revalidateTag(`post:${id}`);
}
이 패턴의 장점:
- 경로가 여러 개(상세/목록/추천/검색)여도 같은 태그로 한 번에 정리 가능
- “어떤 페이지가 이 데이터를 쓰는지”를 경로로 역추적하지 않아도 됨
5.2 revalidatePath를 쓸 때의 주의점
import { revalidatePath } from 'next/cache';
revalidatePath(`/posts/${id}`);
revalidatePath('/posts');
- 목록/상세 등 관련 경로를 빠짐없이 무효화해야 합니다.
- 경로가 늘어날수록 누락으로 stale이 다시 발생하기 쉽습니다.
6) Route Handler에서의 캐시/헤더 실수
app/api/*/route.ts에서 응답 헤더가 캐시 가능하게 나가면, CDN/브라우저가 공유 캐시를 해버릴 수 있습니다. 개인화 응답이라면 아래를 고려하세요.
// app/api/me/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const res = NextResponse.json({ ok: true });
// 개인화라면 보수적으로
res.headers.set('Cache-Control', 'no-store');
return res;
}
특히 사내 프록시/CloudFront/Fastly 같은 계층이 있으면, Next 내부 캐시가 아니라 엣지 캐시가 stale의 진짜 범인일 수 있습니다.
7) 캐시 꼬임을 줄이는 설계 가이드
7.1 데이터 성격을 3분류로 나눠 기본 정책을 고정
- Public(공개/공유 가능):
revalidate+tags적극 사용 - User-specific(개인화): 기본
no-store고정, 필요한 경우만 짧은revalidate - Strict real-time(즉시 반영):
no-store+ 동적 세그먼트(필요 시)
팀 규칙으로 정해두면 “어떤 fetch는 캐시되고 어떤 fetch는 안 되고” 같은 랜덤성이 줄어듭니다.
7.2 태그 네이밍 컨벤션을 만든다
예:
post:${id}post:listuser:${userId}:profile
이렇게 해두면 mutation 시 무효화가 단순해지고, 장애 시 원인 추적도 쉬워집니다.
7.3 디버깅 체크리스트
stale이 보이면 아래 순서로 확인하세요.
- 이 페이지/레이아웃이 정적으로 판정됐나? (
dynamic,revalidate설정) - 해당 데이터 fetch가 캐시되고 있나? (
cache,next.revalidate,next.tags) - mutation 이후 무효화가 호출되나? (
revalidateTag/Path) - Route Handler/프록시/CDN이 캐시하고 있나? (
Cache-Control,Vary) - 사용자별 응답인데 공유 캐시가 적용됐나? (인증 헤더/쿠키 포함 응답)
캐시 문제는 본질적으로 “어딘가에 저장되어 재사용되는 것”이 원인이라, GitHub Actions 캐시 미스처럼 계층별 키/정책을 분해해서 보는 접근이 도움이 됩니다. 캐시 계층을 진단하는 관점은 GitHub Actions 캐시 안 먹힘 원인 7가지도 비슷한 사고방식을 제공합니다.
8) 실전 예시: 게시글 수정 후 상세/목록 stale 동시 해결
요구사항:
- 목록은 60초 캐시
- 상세는 10분 캐시
- 수정하면 즉시 둘 다 최신화
// app/lib/posts.ts
export async function getPostList() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60, tags: ['post:list'] },
});
return res.json();
}
export async function getPostDetail(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { revalidate: 600, tags: [`post:${id}`] },
});
return res.json();
}
// app/posts/[id]/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function editPost(id: string, data: any) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('patch failed');
// 상세 + 목록 동시 무효화
revalidateTag(`post:${id}`);
revalidateTag('post:list');
}
이렇게 하면:
- 평소에는 캐시로 빠르게 제공
- 수정 직후에는 태그 무효화로 즉시 최신화
- 경로가 늘어나도 태그만 맞추면 확장 가능
9) 결론: “명시”가 캐시 꼬임을 끝낸다
Next.js 14의 RSC 캐시는 강력하지만, 기본 동작에 맡기면 서비스 특성(개인화/실시간/쓰기 빈도)에 따라 stale이 쉽게 노출됩니다. 해결의 핵심은 다음 3가지입니다.
- 정적/동적을 명시: 필요한 곳에만
dynamic/revalidate를 정확히 선언 - fetch 캐시 정책을 명시: 개인화는
no-store, 공유 데이터는revalidate + tags - mutation 후 무효화를 명시:
revalidateTag중심으로 일관된 설계
여기까지 적용했는데도 특정 환경에서만 간헐적으로 stale/불일치가 난다면, Next 내부 캐시가 아니라 네트워크/인프라 계층(프록시 DNS, CDN, LB)에서의 간헐 장애가 원인일 수 있습니다. 그런 경우엔 앱 로그뿐 아니라 인프라 지표도 함께 보며 원인을 좁혀가야 합니다.