- Published on
Next.js 14 RSC 캐시 꼬임과 stale 데이터 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(RSC) 기반의 Next.js 14 App Router를 운영하다 보면, 분명 DB는 갱신됐는데 화면은 예전 값이 계속 보이거나(= stale), 특정 사용자에게만 최신/구버전이 섞여 보이는 “캐시 꼬임” 현상을 만납니다. 특히 배포 직후, ISR과 동적 렌더링이 섞인 페이지, 그리고 서버 액션으로 데이터를 바꾼 뒤에 자주 터집니다.
이 글은 “왜 stale이 생기는지”를 캐시 계층별로 분해하고, 실제로 가장 많이 쓰는 해결법(렌더링 모드 고정, fetch 캐시 제어, revalidatePath/revalidateTag 설계, 서버 액션 이후 일관성 확보)을 코드와 함께 정리합니다.
stale이 생기는 이유: Next.js 캐시 계층 3가지
Next.js 14에서 흔히 말하는 “RSC 캐시”는 한 덩어리가 아니라 여러 캐시가 겹칩니다. stale 현상이 생기면 먼저 어느 층이 문제인지 분리해야 합니다.
1) Data Cache: fetch 결과 캐시
서버 컴포넌트에서 호출하는 fetch는 Next.js가 기본적으로 캐싱할 수 있습니다. 같은 요청을 여러 번 렌더링해도 결과를 재사용해 성능이 좋아지지만, 업데이트 이후에도 이 캐시가 살아 있으면 stale이 됩니다.
- 기본 동작은 “정적화 가능하면 캐시” 쪽으로 기울어 있습니다.
next: { revalidate: n }또는next: { tags: [...] }를 붙이면 ISR/태그 기반 무효화를 설계할 수 있습니다.- 반대로 항상 최신이 필요하면
cache: 'no-store'로 끊어야 합니다.
2) Full Route Cache: 라우트 단위 RSC 결과 캐시
App Router는 라우트 렌더 결과(RSC payload)를 캐시할 수 있습니다. 페이지가 정적/ISR로 판단되면 라우트 결과 자체가 캐시되어, 내부 fetch가 최신이어도 결과가 stale처럼 보일 수 있습니다.
- 라우트가 정적화되면, “페이지 전체가” 캐시된 결과를 서빙할 수 있습니다.
- 특정 조건에서만 동적으로 만들고 싶다면
dynamic/revalidate설정을 명시해 예측 가능하게 만들어야 합니다.
3) Client Router Cache: 클라이언트 전환 시 캐시
브라우저에서 next/link로 이동할 때, 이미 받아둔 RSC payload를 재사용하면서 stale이 보일 수 있습니다.
- 서버에서 무효화했더라도, 클라이언트가 이미 들고 있는 캐시가 남아 있으면 “이동” 시에 구 데이터가 잠깐 보이거나 계속 보일 수 있습니다.
- 서버 액션 후
router.refresh()가 필요한 케이스가 여기서 많이 발생합니다.
1단계: “이 페이지는 정적인가?”를 먼저 고정하기
stale 디버깅의 출발점은 “이 페이지가 정적으로 캐시되는지”를 확실히 하는 것입니다. 애매하게 두면 환경/트래픽/코드 구조에 따라 빌드가 정적화해버리면서 재현이 어렵습니다.
항상 최신이어야 하는 페이지(관리자, 개인화, 재고 등)
정적화 자체를 차단합니다.
// app/admin/page.tsx
export const dynamic = 'force-dynamic'
export const revalidate = 0
export default async function AdminPage() {
const res = await fetch('https://api.example.com/admin/stats', {
cache: 'no-store',
})
const data = await res.json()
return <pre>{JSON.stringify(data, null, 2)}</pre>
}
dynamic = 'force-dynamic'은 라우트 결과 캐시를 피하는 데 도움이 됩니다.cache: 'no-store'는 데이터 캐시를 끊습니다.- 둘 중 하나만으로도 해결되는 경우가 있지만, “정말 최신”이 목표라면 둘 다 명시하는 편이 운영에서 안전합니다.
ISR로 충분한 페이지(뉴스, 상품 목록 등)
“몇 초까지는 stale 허용”이라는 정책을 코드에 박습니다.
// app/products/page.tsx
export const revalidate = 60
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 60, tags: ['products'] },
})
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<ul>
{products.map((p: any) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
여기서 중요한 건 “라우트 revalidate”와 “fetch revalidate”가 섞일 때의 일관성입니다. 둘 다 60초로 맞추거나, 하나만 책임지게 설계하세요. 섞어두면 팀원이 코드를 수정하면서 의도치 않게 정적화/캐시가 바뀌기 쉽습니다.
2단계: 서버 액션 이후 stale을 없애는 정석 패턴
서버 액션으로 DB를 업데이트했는데도, 같은 페이지로 돌아오면 예전 데이터가 보이는 케이스가 가장 흔합니다. 이때 핵심은 “어느 캐시를 무효화할지”를 명확히 정하고, 액션에서 그 무효화를 수행하는 것입니다.
revalidatePath로 라우트 결과 무효화
// app/products/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function updateProductName(id: string, name: string) {
await fetch('https://api.example.com/products/update', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id, name }),
cache: 'no-store',
})
// 목록과 상세 둘 다 영향을 받는다면 둘 다 무효화
revalidatePath('/products')
revalidatePath(`/products/${id}`)
}
- 장점: 단순하고 직관적입니다.
- 단점: 경로를 정확히 알고 있어야 하고, 영향 범위가 넓어질수록 호출이 늘어납니다.
revalidateTag로 데이터 단위 무효화(추천)
태그 전략은 “어떤 데이터가 바뀌면 어떤 화면이 갱신되어야 하는가”를 데이터 중심으로 설계하게 해줍니다.
// app/products/data.ts
export async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { tags: [`product:${id}`] },
})
return res.json()
}
export async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { tags: ['products'] },
})
return res.json()
}
// app/products/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProductName(id: string, name: string) {
await fetch('https://api.example.com/products/update', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id, name }),
cache: 'no-store',
})
revalidateTag(`product:${id}`)
revalidateTag('products')
}
- 장점: 상세/목록/추천/검색 등 다양한 화면이 같은 데이터를 공유해도 태그로 일관되게 갱신됩니다.
- 단점: 태그 네이밍 규칙과 적용 범위를 팀 규약으로 잡아야 합니다.
3단계: “개인화”가 섞이면 캐시가 꼬이기 쉬운 지점
RSC는 서버에서 렌더링되기 때문에 개인화 로직을 쉽게 넣게 되는데, 이때 캐시가 섞이면 사용자 A의 결과가 사용자 B에게 보이는 형태의 사고로 이어질 수 있습니다.
다음 조건 중 하나라도 해당하면, 기본적으로 cache: 'no-store' 또는 dynamic = 'force-dynamic'을 검토하세요.
cookies()/headers()를 읽어서 분기- 세션/권한에 따라 다른 데이터
- A/B 테스트, 지역/언어 자동 감지
예시:
// app/me/page.tsx
import { cookies } from 'next/headers'
export const dynamic = 'force-dynamic'
export default async function MePage() {
const token = cookies().get('token')?.value
const res = await fetch('https://api.example.com/me', {
headers: { authorization: `Bearer ${token}` },
cache: 'no-store',
})
const me = await res.json()
return <div>{me.email}</div>
}
개인화 페이지를 ISR로 돌리거나 태그로 무효화하려고 하면, “캐시 키”가 사용자별로 안전하게 분리되는지부터 확인해야 합니다. 그렇지 않으면 stale을 넘어 데이터 누출 리스크가 됩니다.
4단계: 클라이언트 전환에서 stale이 남는 경우 router.refresh()
서버 액션을 실행한 뒤 같은 화면에서 리스트를 갱신해야 하는데, revalidateTag를 걸어도 즉시 UI가 안 바뀌는 경우가 있습니다. 이때는 클라이언트 라우터 캐시를 갱신해야 합니다.
'use client'
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { updateProductName } from './actions'
export function RenameButton({ id }: { id: string }) {
const router = useRouter()
const [pending, startTransition] = useTransition()
return (
<button
disabled={pending}
onClick={() => {
startTransition(async () => {
await updateProductName(id, 'New Name')
router.refresh()
})
}}
>
Rename
</button>
)
}
revalidateTag는 “서버 캐시 무효화”입니다.router.refresh()는 “클라이언트가 들고 있는 RSC payload를 다시 받기”입니다.- 둘은 역할이 다르므로, 즉시 반영 UX가 필요하면 함께 쓰는 패턴이 자연스럽습니다.
5단계: 디버깅 체크리스트(재현 가능한 형태로 쪼개기)
stale은 대개 “캐시 정책이 코드 곳곳에 흩어져서” 생깁니다. 아래 순서로 분해하면 해결 속도가 빨라집니다.
- 문제가 되는 페이지에서
dynamic/revalidate를 명시했는가 - 데이터 호출
fetch에cache또는next.revalidate/next.tags가 명시됐는가 - 서버 액션에서
revalidatePath또는revalidateTag를 호출하는가 - 액션 직후 UI 즉시 반영이 필요하면
router.refresh()가 있는가 - 개인화 데이터인데 캐시가 켜져 있지 않은가
캐시 문제는 CI에서도 흔히 “한 번 꼬이면 계속 느리거나 계속 틀리는” 성격을 띱니다. 캐시를 진단하는 접근 자체는 비슷하니, 빌드/배포 파이프라인 캐시 트러블슈팅 관점은 아래 글도 함께 보시면 도움이 됩니다.
6단계: 운영에서 잘 먹히는 캐시 설계 규칙 5가지
규칙 1) “기본은 명시”로 간다
fetch를 래핑한 데이터 레이어를 만들고, 거기에 tags/revalidate를 강제하세요. 페이지마다 제각각 달면 언젠가 stale이 재발합니다.
규칙 2) 태그 네이밍 규칙을 데이터 모델과 맞춘다
예: product:123, user:42, products:list 처럼 “엔티티 단위”와 “컬렉션 단위”를 분리합니다.
규칙 3) 쓰기(write) 경로는 항상 no-store
업데이트 API 호출이 캐시되면 원인 파악이 어려운 버그가 됩니다. 쓰기 요청은 습관적으로 cache: 'no-store'를 넣으세요.
규칙 4) 개인화는 정적화 금지부터 검토
개인화 페이지는 성능보다 안전/정확성이 우선인 경우가 많습니다. 먼저 동적 렌더링으로 고정한 뒤, 정말 필요할 때만 부분 최적화를 고민하세요.
규칙 5) “즉시 반영 UX”는 서버 무효화와 클라이언트 갱신을 함께
서버 액션에서 revalidateTag를 호출하고, 클라이언트에서 router.refresh()로 당겨오는 구성이 가장 예측 가능합니다.
마무리: stale은 버그가 아니라 “정책 부재”에서 온다
Next.js 14의 RSC 캐시는 성능을 크게 끌어올리지만, 정책을 명시하지 않으면 프레임워크가 정적화 가능한 방향으로 최적화하면서 stale이 “가끔” 발생합니다. 해결의 핵심은 다음 한 줄로 요약됩니다.
- 최신이 필요하면
dynamic과no-store로 고정 - ISR이면
revalidate를 일관되게 설계 - 쓰기 후에는
revalidatePath또는revalidateTag로 무효화 - 즉시 반영이 필요하면
router.refresh()까지 포함
이 네 가지를 팀 규약으로 만들면 “캐시 꼬임”은 재현 가능한 문제로 바뀌고, 운영에서도 안정적으로 제어할 수 있습니다.