- Published on
Next.js 14 RSC 캐시로 데이터가 안 갱신될 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버의 DB 값은 바뀌었는데 Next.js 14(App Router) 화면은 그대로라면, 대부분 RSC(React Server Components) 렌더 결과 캐시 또는 Next.js의 fetch 캐시에 걸린 상황입니다. 특히 개발 환경에서는 잘 보이던 갱신이 프로덕션에서만 멈추기도 해서, “내 API가 안 바뀌나?” 같은 엉뚱한 곳을 파기 쉽습니다.
이 글에서는 어디에서 캐시가 걸리는지를 먼저 분해하고, 그 다음에 정확히 원하는 수준(요청마다/주기적으로/이벤트 기반)으로 갱신되도록 설정하는 실전 패턴을 정리합니다.
> 캐시로 인한 문제는 원인 추적이 핵심입니다. 장애 상황에서 원인 추적 습관이 필요하다면 systemd 서비스가 반복 재시작될 때 원인 추적법처럼 “관측→가설→검증” 흐름을 적용하면 Next.js 캐시 디버깅도 훨씬 빨라집니다.
Next.js 14에서 “캐시”가 생기는 지점 3가지
App Router 기준으로 데이터가 안 바뀌는 원인은 크게 3축입니다.
1) RSC 렌더 결과 캐시(정적 렌더링/ISR)
page.tsx,layout.tsx같은 서버 컴포넌트가 빌드/요청 시점에 렌더링한 결과가 캐시될 수 있습니다.export const revalidate = ...또는export const dynamic = ...에 의해 동작이 결정됩니다.
2) fetch() 캐시(Next.js 확장)
- Next.js는 서버 컴포넌트에서 호출된
fetch()를 자동으로 캐싱할 수 있습니다. fetch(url, { cache: 'no-store' })또는next: { revalidate, tags }로 제어합니다.
3) 라우터 캐시/클라이언트 네비게이션 캐시
- 페이지 이동 시
router.push()등으로 이동하면, Next가 클라이언트 측 라우터 캐시를 활용해 즉시 전환합니다. - 서버 데이터가 바뀌었는데도 클라가 이전 트리를 재사용하면 “안 바뀐 것처럼” 보일 수 있습니다.
이 글의 핵심은 1)과 2)입니다. 대부분은 fetch 캐시 또는 정적 렌더링이 원인입니다.
증상별로 원인 빠르게 가르는 체크리스트
A. 새로고침(F5)해도 안 바뀐다
- RSC 결과가 정적으로 캐시됐거나(
revalidate/dynamic), - 서버
fetch()가 캐시되어 같은 응답을 재사용하는 중.
B. 하드 리프레시/시크릿 창에서는 바뀌는데, 일반 탭에서만 안 바뀐다
- 브라우저 캐시/서비스워커 가능성도 있지만,
- App Router의 클라이언트 네비게이션 캐시가 영향을 주는 케이스도 있습니다.
C. 개발 환경에서는 잘 되는데 배포하면 안 바뀐다
- 개발 모드는 캐시가 느슨/비활성인 경우가 많아, 프로덕션에서만 문제가 드러납니다.
- 특히
fetch()기본 캐싱, 정적 최적화가 프로덕션에서 강하게 적용됩니다.
가장 흔한 원인: 서버 컴포넌트의 fetch가 캐시됨
서버 컴포넌트에서 아래처럼 호출하면, Next는 상황에 따라 결과를 캐시할 수 있습니다.
// app/posts/page.tsx (Server Component)
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return (
<ul>
{posts.map((p: any) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
이때 API 서버는 이미 갱신된 데이터를 주는데도, Next 쪽에서 이전 응답을 재사용하면 “데이터가 안 바뀐다”가 됩니다.
해결 1: 요청마다 최신 데이터가 필요하면 cache: 'no-store'
// app/posts/page.tsx
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store',
});
const posts = await res.json();
return (
<ul>
{posts.map((p: any) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
- 장점: 항상 최신
- 단점: 트래픽/응답시간 증가(매 요청 원격 호출)
해결 2: “N초마다 최신”이면 next.revalidate
// app/posts/page.tsx
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 30 }, // 30초마다 재검증
});
const posts = await res.json();
return (
<ul>
{posts.map((p: any) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
- 장점: 캐시 이점 유지 + 주기적 갱신
- 단점: “즉시 반영”은 아님
페이지 자체가 정적으로 굳어버리는 케이스: dynamic/revalidate 설정
데이터가 자주 바뀌는데 페이지가 정적으로 최적화되면, fetch 설정만 바꿔도 기대만큼 안 움직일 수 있습니다.
해결 3: 페이지를 강제로 동적 렌더링
// app/posts/page.tsx
export const dynamic = 'force-dynamic';
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return <pre>{JSON.stringify(posts, null, 2)}</pre>;
}
또는 페이지 단위로 ISR을 주고 싶다면:
// app/posts/page.tsx
export const revalidate = 60; // 이 페이지는 60초 단위로 ISR
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return <pre>{JSON.stringify(posts, null, 2)}</pre>;
}
주의할 점:
export const revalidate(페이지 레벨)과fetch(..., { next: { revalidate } })(요청 레벨)을 섞으면, 의도와 다르게 더 공격적으로 캐시되거나 갱신 타이밍이 꼬였다고 느낄 수 있습니다.- “이 페이지는 항상 최신”이 목표면 페이지를 동적으로 + fetch no-store 조합이 가장 단순합니다.
가장 실전적인 해법: 태그 기반 캐시 무효화(revalidateTag)
운영에서 많이 원하는 건 이겁니다.
- 평소엔 캐시로 빠르게 제공
- 글/상품이 수정되면 그 순간 즉시 관련 페이지만 갱신
Next.js 14에서는 fetch에 태그를 붙이고, 변경 이벤트에서 태그를 무효화하는 패턴을 씁니다.
1) fetch에 tags 부여
// app/posts/page.tsx
import 'server-only';
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
const posts = await res.json();
return (
<ul>
{posts.map((p: any) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
2) 서버 액션/라우트 핸들러에서 revalidateTag 호출
// app/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function updatePost(id: string, title: string) {
// 1) DB 업데이트 또는 API 호출
await fetch(`https://api.example.com/posts/${id}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title }),
cache: 'no-store',
});
// 2) posts 태그 캐시 무효화
revalidateTag('posts');
}
이 패턴의 장점:
- 캐시를 포기하지 않으면서도 “수정 즉시 반영”이 가능
- 어떤 페이지/컴포넌트가
tags: ['posts']를 달고 있든 함께 갱신
운영에서 캐시 무효화는 “전파/일관성” 문제가 자주 얽힙니다. 인증/키 회전처럼 캐시 때문에 장애가 나는 대표적인 사례는 Node.js JWT 검증 실패 - kid·JWKS 캐시로 401 잡기도 함께 참고하면, 캐시 설계 시 어떤 지점이 위험해지는지 감이 빨리 옵니다.
클라이언트 네비게이션에서만 안 바뀔 때: router.refresh()
서버 데이터는 갱신됐는데, 클라이언트에서 같은 라우트로 이동하거나 back/forward에서 이전 트리를 재사용하면 갱신이 늦어 보일 수 있습니다.
예: 폼 저장 후 목록으로 돌아왔는데 목록이 그대로인 경우.
'use client';
import { useRouter } from 'next/navigation';
export function SaveButton() {
const router = useRouter();
return (
<button
onClick={async () => {
await fetch('/api/posts/123', { method: 'PUT' });
router.refresh(); // 현재 라우트의 RSC를 다시 가져오게 함
}}
>
저장
</button>
);
}
router.refresh()는 “현재 라우트에 대해 서버 컴포넌트 트리를 재요청”하는 개념입니다.- 다만 서버 쪽 fetch가 캐시로 고정돼 있으면 refresh를 해도 똑같은 데이터가 올 수 있으니, 앞서 설명한
no-store/revalidate/tags가 먼저입니다.
디버깅: 지금 무엇이 캐시되고 있는지 확인하는 법
1) 가장 작은 재현 코드로 분리
- 페이지에서 DB/외부 API/내부 API를 다 섞어 쓰면 원인 파악이 느립니다.
fetch()하나만 남기고cache: 'no-store'를 붙였을 때 해결되면, 원인은 99% fetch 캐시입니다.
2) 응답에 타임스탬프를 넣어 “정말로 같은 응답인지” 확인
API 응답에 generatedAt 같은 값을 넣고 화면에 찍어보면, 브라우저 캐시인지 Next 캐시인지 감이 잡힙니다.
// app/debug/page.tsx
export default async function DebugPage() {
const res = await fetch('https://api.example.com/debug-time', {
next: { revalidate: 60 },
});
const data = await res.json();
return (
<div>
<div>generatedAt: {data.generatedAt}</div>
<div>value: {data.value}</div>
</div>
);
}
3) 인프라 캐시/CDN도 의심(특히 배포 후)
Vercel/CloudFront/Ingress 캐시 정책이 응답을 잡고 있을 수도 있습니다. “서버는 바뀌었는데 특정 지역/특정 POP에서만 안 바뀐다”면 애플리케이션 캐시가 아니라 엣지 캐시일 수 있습니다.
인프라 관점의 “어디가 병목/오류 지점인지”를 좁히는 접근은 쿠버네티스에서도 동일합니다. 예를 들어 502/504처럼 겉보기엔 앱 문제 같지만 LB/Ingress/타임아웃이 원인인 경우가 많습니다. 필요하면 EKS에서 ALB Ingress 502 Bad Gateway 원인 9가지처럼 계층별로 체크하는 방식이 도움이 됩니다.
추천 조합(운영 기준)
1) 관리자/대시보드처럼 “항상 최신”
- 페이지:
export const dynamic = 'force-dynamic' - 데이터:
fetch(..., { cache: 'no-store' })
2) 사용자-facing 목록/상세처럼 “대부분 캐시 + 가끔 갱신”
- 데이터:
fetch(..., { next: { revalidate: 30 } }) - 페이지: 필요 시
export const revalidate = 30
3) 커머스/콘텐츠처럼 “수정 즉시 반영 + 캐시 효율”
- 데이터:
fetch(..., { next: { tags: [...] } }) - 변경 이벤트:
revalidateTag('...')/ 필요 시revalidatePath('/...')
마무리: ‘안 갱신’은 버그가 아니라 설정의 결과다
Next.js 14의 RSC 캐시는 성능을 크게 올려주지만, 갱신 전략을 명시하지 않으면 “왜 안 바뀌지?”가 됩니다. 해결의 순서는 단순합니다.
- 문제를 RSC 렌더 캐시 vs fetch 캐시 vs 라우터 캐시로 분리
- 목표에 맞게
no-store/revalidate/tags + revalidateTag중 하나를 선택 - 클라이언트 네비게이션 이슈는
router.refresh()로 마무리
이 3단계만 습관화하면, Next.js 14에서 데이터 갱신 문제로 시간을 태우는 일이 크게 줄어듭니다.