- Published on
Next.js 15 RSC 캐시로 stale UI 뜰 때 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 액션(Server Actions)으로 데이터를 업데이트했는데도 화면은 예전 값이 남아 있거나, 목록은 갱신됐는데 상세 페이지는 그대로인 현상은 Next.js 15의 RSC 캐시(서버 컴포넌트 결과 캐시), 그리고 그 위에 얹힌 fetch 캐시/라우트 캐시/클라이언트 라우터 캐시가 겹치면서 자주 발생합니다. 특히 App Router에서 fetch()가 기본적으로 캐싱될 수 있고, RSC 페이로드가 재사용되면 “요청은 갔는데 UI는 안 바뀐 것처럼” 보이는 stale UI가 됩니다.
이 글은 Next.js 15 기준으로 stale UI가 뜨는 대표 패턴을 재현 → 원인 분해 → 해결(무효화 전략) 순서로 정리합니다.
관련해서 빌드/캐시 튜닝 이슈는 Next.js 14 빌드 OOM·느려짐 해결 - SWC 캐시·메모리 튜닝도 함께 참고하면, “캐시가 어디에 쌓이는지” 감을 잡는 데 도움이 됩니다.
stale UI가 생기는 구조: 무엇이 캐시되나
App Router에서 화면이 갱신되지 않는 이유는 보통 아래 중 하나(또는 복합)입니다.
1) fetch() 결과 캐시
- Server Component에서 실행되는
fetch()는 설정에 따라 응답이 캐시됩니다. next: { revalidate }또는cache: 'force-cache'를 쓰면, 의도적으로 재사용됩니다.
2) RSC 결과(서버 컴포넌트 렌더 결과) 재사용
- Next는 서버 컴포넌트 트리/데이터 의존성을 기반으로 결과를 재사용할 수 있습니다.
- 데이터가 바뀌었는데도 해당 세그먼트가 “유효하다”고 판단되면 예전 RSC 페이로드가 재사용되어 stale UI가 됩니다.
3) Route Segment Cache / Router Cache (클라이언트 측)
- 클라이언트 네비게이션 시, Next 라우터는 이전에 방문한 세그먼트를 캐시해 재사용합니다.
- 서버 측 캐시를 잘 무효화해도, 클라이언트가 이전 RSC를 들고 있으면 “뒤로가기/전환”에서 stale가 보일 수 있습니다.
4) CDN/프록시 캐시(CloudFront/Vercel Edge 등)
Cache-Control헤더나 플랫폼 기본 정책으로 HTML/RSC 응답이 캐시되면, 애플리케이션 레벨 무효화만으로는 부족합니다.
1분 진단 체크리스트
아래 질문에 “예”가 나오면 해당 해결 파트를 우선 적용하세요.
- Server Component에서
fetch()를 쓰고 있고,revalidate를 줬거나 기본 캐시가 의심된다 →fetch캐시 전략 점검 - Server Action/Mutation 이후에도 같은 URL에서 값이 안 바뀐다 →
revalidatePath/revalidateTag누락 가능성 - 새로고침(F5)하면 최신인데, 클라이언트 이동에서는 stale → 라우터 캐시/
router.refresh()필요 가능성 - 특정 사용자만 stale(로그인/권한별 데이터) →
cookies()/headers()사용 여부와 캐시 분리(동적 렌더링) 점검
재현 예제: 업데이트 후 목록이 안 바뀌는 전형적인 케이스
예를 들어 게시글 목록 페이지가 Server Component이고, 내부에서 API를 fetch()로 가져온다고 가정합니다.
문제 코드(의도치 않은 캐시)
// app/posts/page.tsx (Server Component)
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
// 기본값/환경에 따라 캐싱이 일어날 수 있음
// 혹은 다음처럼 명시적으로 캐시해버린 경우
next: { revalidate: 60 },
})
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<ul>
{posts.map((p: any) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
)
}
이 상태에서 Server Action으로 글을 추가해도, 최대 60초 동안 목록이 stale로 남을 수 있습니다.
해결 1: Mutation 이후 revalidatePath()로 세그먼트 무효화
가장 “정직한” 해결은 변경이 일어난 경로를 정확히 무효화하는 것입니다.
// app/posts/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = String(formData.get('title') || '')
await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title }),
})
// 목록 페이지(세그먼트) 무효화
revalidatePath('/posts')
}
/posts가 stale UI를 유발하는 핵심 세그먼트라면, 이 한 줄로 RSC 결과와 연관 캐시가 재검증됩니다.- 목록뿐 아니라 상세(
/posts/[id])도 바뀐다면 둘 다 무효화해야 합니다.
revalidatePath()를 쓸 때 흔한 실수
- 레이아웃이 데이터를 들고 있는데 페이지만 무효화: 데이터가
layout.tsx에서 로드되면/posts만 무효화해도 레이아웃이 재사용되어 stale가 남을 수 있습니다. 이때는 레이아웃이 포함되는 경로를 무효화하거나, 태그 기반으로 더 정확히 끊는 편이 낫습니다.
해결 2: 태그 기반 캐시(revalidateTag)로 “데이터 단위” 무효화
페이지 경로가 여러 개이고(목록/상세/위젯), 같은 데이터 소스를 공유한다면 revalidateTag()가 운영 난이도를 크게 낮춥니다.
1) fetch에 tag를 붙인다
// lib/posts.ts
export async function fetchPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
})
return res.json()
}
export async function fetchPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: [`post:${id}`, 'posts'] },
})
return res.json()
}
2) Mutation 후 tag를 무효화한다
// app/posts/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function updatePost(id: string, title: string) {
await fetch(`https://api.example.com/posts/${id}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title }),
})
// 상세 + 목록 동시 무효화
revalidateTag(`post:${id}`)
revalidateTag('posts')
}
- “어느 페이지가 이 데이터를 쓰는지”를 경로로 추적하지 않아도 됩니다.
- 특히 MSA/백오피스/위젯처럼 데이터 소비 지점이 늘어날수록 태그 전략이 유리합니다.
해결 3: 정말 실시간이어야 하면 cache: 'no-store'로 캐시를 끈다
관리자 화면, 결제/재고, 알림 카운트처럼 stale이 치명적인 데이터는 캐시를 끄는 게 더 안전합니다.
// Server Component
const res = await fetch('https://api.example.com/inventory', {
cache: 'no-store',
})
- 장점: stale UI 가능성이 크게 줄어듭니다.
- 단점: 트래픽/응답시간 비용이 증가합니다.
운영에서는 보통
- 대부분은 revalidate(초 단위)
- 중요한 일부만 no-store
- Mutation 후에는 path/tag 무효화 조합이 가장 균형이 좋습니다.
해결 4: 클라이언트 네비게이션에서만 stale이면 router.refresh()를 의심
“새로고침하면 최신인데, 링크 이동/서브밋 후에는 stale”이면 클라이언트 라우터 캐시가 원인일 수 있습니다. 이때는 Mutation 이후에 현재 라우트를 refresh해 서버에서 최신 RSC를 다시 받아오게 만듭니다.
'use client'
import { useRouter } from 'next/navigation'
import { createPost } from './actions'
export function CreatePostForm() {
const router = useRouter()
return (
<form
action={async (fd) => {
await createPost(fd)
router.refresh() // 클라이언트 캐시 갱신
}}
>
<input name="title" />
<button type="submit">Create</button>
</form>
)
}
주의할 점:
router.refresh()는 만능이 아니라 “현재 라우트의 RSC를 다시 가져오게” 하는 트리거입니다.- 서버 쪽 캐시가 여전히 유효하면 refresh해도 stale이 유지될 수 있으니, 결국 핵심은
revalidatePath/Tag와 함께 써야 합니다.
해결 5: 사용자별 데이터가 섞이면 캐시가 ‘공유’되는지 확인
로그인 사용자마다 다른 데이터를 보여줘야 하는데 동일 UI가 보이는 경우는 보통 다음 때문입니다.
- 사용자별 데이터인데
fetch가 캐시되고 있다 - 쿠키/헤더 기반으로 달라져야 하는데, 서버 컴포넌트가 이를 “동적 의존성”으로 인식하지 못해 정적/캐시로 굳었다
권장 접근:
- 사용자별 데이터는 원칙적으로
cache: 'no-store'또는 매우 짧은revalidate - 또는 서버에서
cookies()/headers()를 읽어 동적 렌더링을 강제(단, 성능 영향)
// app/me/page.tsx
import { cookies } from 'next/headers'
export default async function MePage() {
const token = (await 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 <pre>{JSON.stringify(me, null, 2)}</pre>
}
운영 팁: “어디를 무효화해야 하는지”를 설계로 고정하기
stale UI는 대부분 기술 문제가 아니라 무효화 설계가 애매해서 생깁니다. 아래처럼 규칙을 정해두면 재발이 줄어듭니다.
- 데이터 도메인별 tag 네이밍 규칙
- 예:
posts,post:{id},comments,comment:{id}
- 예:
- Mutation은 반드시 해당 도메인 tag를 무효화
- 경로 무효화는 보조 수단
- “이 페이지 하나만” 확실히 갱신해야 할 때만
revalidatePath
- “이 페이지 하나만” 확실히 갱신해야 할 때만
이 패턴은 RAG/워크플로우처럼 상태 전이가 복잡한 시스템에서도 유효합니다. 캐시 무효화도 일종의 “사가의 보상/정합성” 문제이기 때문에, 상태 변화를 데이터 단위로 묶는 편이 유지보수에 강합니다. (분산 정합성 관점은 MSA 사가(Saga) 중복처리·보상트랜잭션 설계 실전도 참고할 만합니다.)
자주 겪는 케이스별 처방전
케이스 A: 목록은 갱신됐는데 상세만 stale
- 상세 fetch에
next: { tags: [post:{id}] }가 없거나 - Mutation에서
revalidateTag(post:{id})를 안 했거나 - 상세가 레이아웃에서 로드되는데 페이지 path만 무효화했을 가능성
처방:
- 상세 fetch에
post:{id}tag 부여 - 업데이트 후
revalidateTag(post:{id})
케이스 B: 한 사용자 업데이트가 다른 사용자 화면에 반영되거나 섞임
처방:
- 사용자별 데이터는
no-store - 공용 데이터만 tag/revalidate
케이스 C: 배포 후에만 stale가 심해짐
처방:
- CDN/Edge 캐시 헤더 확인
- 플랫폼의 캐시 정책과 Next 캐시 정책이 충돌하는지 확인
결론: stale UI를 “없애는” 게 아니라 “통제”하는 것
Next.js 15의 RSC 캐시는 성능을 크게 끌어올리지만, Mutation 이후 정합성을 개발자가 명시적으로 설계해야 합니다. 실전에서 가장 안정적인 조합은 다음입니다.
- 읽기(fetch):
next.tags로 데이터 단위 연결 - 쓰기(mutation):
revalidateTag()로 관련 데이터 무효화 - 화면 전환: 필요 시
router.refresh()로 클라이언트 캐시 갱신 - 실시간/개인화:
cache: 'no-store'로 안전하게 분리
이 4가지만 팀 규칙으로 고정하면, “가끔 stale UI가 뜬다”는 류의 버그 리포트가 체감상 크게 줄어듭니다.