- Published on
Next.js App Router RSC 캐시 꼬임 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(RSC)는 App Router의 핵심이지만, 캐시 전략을 조금만 잘못 잡아도 “방금 저장했는데 화면은 옛날 값”, “A 사용자가 본 데이터가 B에게 보임”, “리스트는 갱신됐는데 상세는 안 바뀜” 같은 캐시 꼬임을 만들기 쉽습니다. 특히 fetch 기본 캐시, 라우트 세그먼트의 정적 최적화, 그리고 태그 기반 재검증을 섞어 쓰는 시점에 문제가 자주 터집니다.
이 글에서는 App Router에서 RSC 캐시가 꼬이는 전형적인 원인을 분류하고, 재현 가능한 형태로 진단한 뒤, 가장 안전한 해결 패턴(세그먼트 설정 + fetch 캐시 제어 + revalidateTag/revalidatePath)을 코드로 정리합니다.
RSC 캐시 “꼬임”이란 무엇인가
현상은 다양하지만, 대부분 아래 중 하나로 수렴합니다.
- 데이터가 갱신됐는데도 UI가 오래된 값을 보여줌: DB는 바뀌었는데 RSC 페이로드가 캐시된 상태
- 페이지 일부만 갱신되고 일부는 안 됨: 리스트는 새로고침되는데 상세/헤더는 이전 값
- 사용자별 데이터가 섞이는 것처럼 보임: 개인화 데이터가 정적 캐시로 승격되거나, 캐시 키가 요청별로 분리되지 않음
- 배포/코드 변경 후 특정 라우트만 이상: 세그먼트 단위 캐시가 남아있거나, 태그/경로 재검증이 누락됨
App Router에서 캐시는 크게 세 층으로 생각하면 정리가 쉽습니다.
- 세그먼트(라우트) 단위 렌더링 캐시: 정적 최적화가 되면 RSC 결과가 캐시될 수 있음
- 데이터 캐시(
fetch): 서버에서 호출한fetch가 기본적으로 캐시될 수 있음 - 클라이언트 라우팅 캐시:
next/link이동 시 클라이언트가 일부 상태를 유지
“꼬임”은 보통 1)과 2)가 의도와 다르게 결합될 때 생깁니다.
제일 흔한 원인 5가지
1) 개인화 데이터를 정적 세그먼트에서 읽음
예를 들어 서버 컴포넌트에서 쿠키/헤더로 사용자 정보를 읽고, 그 값을 기반으로 fetch를 호출하는데, 해당 페이지가 정적 최적화되면 사용자별 응답이 섞이는 느낌을 줄 수 있습니다.
- 사용자 의존 데이터:
cookies()/headers()/ 세션 / 토큰 - 이런 값을 읽는 순간, 라우트가 동적으로 강제되는 경우도 있지만, 코드 구조에 따라 의도치 않은 최적화/캐시가 남는 형태로 문제를 만듭니다.
2) 서버에서 fetch를 호출했는데 기본 캐시를 그대로 둠
App Router의 서버 컴포넌트에서 fetch는 기본적으로 캐시될 수 있습니다. 즉 “DB는 바뀌었는데 fetch 결과는 캐시”인 상태가 됩니다.
3) revalidatePath/revalidateTag를 호출했지만 대상이 안 맞음
revalidatePath('/posts')를 호출했는데 실제로 캐시된 건/posts/[id]의 데이터- 태그를 달지 않았는데
revalidateTag('posts')만 호출 - 반대로 태그는 달았는데, 재검증 트리거가 서버 액션/라우트 핸들러에서 실행되지 않음
4) 동일한 URL인데 쿼리스트링/헤더 차이로 캐시 키가 기대와 다름
예를 들어 fetch('.../me')는 사용자별로 달라야 하는데, Authorization 헤더가 포함되거나 쿠키 기반이면 캐시 키 분리가 기대와 달라질 수 있습니다. 개인화 API는 원칙적으로 캐시를 끄는 편이 안전합니다.
5) 개발/프리뷰 환경에서만 재현되는 혼합 문제
Dev 모드, 프리뷰(Edge/Node), 배포 환경의 캐시 동작이 다를 수 있습니다. “로컬에서는 되는데 운영에서만 꼬임”은 fetch 캐시 또는 정적 최적화가 운영에서 더 적극적으로 적용되기 때문인 경우가 많습니다.
먼저 재현 가능한 최소 예제로 진단하기
가장 좋은 접근은 “어떤 캐시가 문제인지”를 분리하는 것입니다. 아래처럼 단계적으로 실험해보면 원인 레이어를 빠르게 좁힐 수 있습니다.
단계 A: 페이지를 강제로 동적으로 만들어보기
해당 라우트 파일에 아래를 추가해 정적 최적화를 끕니다.
// app/posts/page.tsx
export const dynamic = 'force-dynamic'
export default async function Page() {
// ...
}
이 상태에서 문제가 사라지면, 세그먼트(라우트) 캐시 또는 fetch 캐시가 정적 렌더링과 결합되어 생긴 문제일 확률이 큽니다.
단계 B: 문제 되는 fetch에 캐시 옵션을 명시
개인화 데이터 또는 자주 바뀌는 데이터라면 우선 이렇게 고정해 테스트합니다.
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store',
})
이렇게 했을 때만 문제가 사라지면, 원인은 거의 확실히 fetch 데이터 캐시입니다.
단계 C: 태그 기반 재검증이 필요한지 확인
목표가 “쓰기 후 즉시 최신 데이터”라면, 캐시를 완전히 끄는 대신 태그 기반 재검증이 더 효율적입니다. 이때는 fetch에 태그를 달고, 쓰기 동작에서 revalidateTag를 호출해야 합니다.
해결 패턴 1: 개인화/민감 데이터는 no-store로 고정
사용자별 대시보드, 내 알림, 내 결제수단 등은 캐시를 켜서 이득을 보기보다 사고 위험이 큽니다. 가장 안전한 기본값은 cache: 'no-store'입니다.
// app/dashboard/page.tsx
import { cookies } from 'next/headers'
export const dynamic = 'force-dynamic'
export default async function DashboardPage() {
const token = (await cookies()).get('token')?.value
const res = await fetch('https://api.example.com/me', {
cache: 'no-store',
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!res.ok) throw new Error('failed to load')
const me = await res.json()
return (
<div>
<h1>Dashboard</h1>
<pre>{JSON.stringify(me, null, 2)}</pre>
</div>
)
}
핵심은 “개인화 데이터는 캐시로 최적화하지 않는다”입니다. 트래픽이 커서 최적화가 필요하면, 서버 내부 캐시(예: Redis)처럼 사용자 키를 포함하는 명시적 캐시로 옮기는 게 안전합니다.
해결 패턴 2: 리스트/상세 같은 공용 데이터는 태그로 재검증
공용 데이터(게시글 목록, 상품 리스트 등)는 캐시 이점이 큽니다. 대신 “쓰기 후 갱신”을 태그로 제어합니다.
2-1) 읽기 fetch에 태그 달기
// lib/posts.ts
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
})
if (!res.ok) throw new Error('failed to load posts')
return res.json()
}
export async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: ['posts', `post:${id}`] },
})
if (!res.ok) throw new Error('failed to load post')
return res.json()
}
- 목록은
posts - 상세는
posts와post:${id}를 같이 달아두면, “전체 갱신”과 “단건 갱신”을 모두 처리하기 좋습니다.
2-2) 쓰기 후 revalidateTag 호출
서버 액션에서 쓰기를 수행하고 태그를 재검증합니다.
// app/posts/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function updatePost(id: string, title: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!res.ok) throw new Error('update failed')
// 단건 상세 + 목록 모두 갱신
revalidateTag(`post:${id}`)
revalidateTag('posts')
}
이 패턴의 장점은 다음과 같습니다.
- 읽기 경로는 캐시를 활용해 빠르게
- 쓰기 직후 필요한 캐시만 정확히 무효화
- “상세는 갱신됐는데 목록이 안 바뀜” 같은 부분 꼬임을 줄임
해결 패턴 3: revalidatePath는 “경로 기반 캐시”에만 쓰기
revalidatePath는 직관적이지만, 실제로는 “그 경로의 렌더링 결과”를 대상으로 합니다. 데이터 캐시를 태그로 구성해두지 않으면, 기대만큼 깔끔하게 떨어지지 않는 경우가 있습니다.
- 특정 페이지 하나만 확실히 다시 렌더링시키고 싶다:
revalidatePath('/posts') - 데이터 단위로 여러 페이지를 한 번에 갱신하고 싶다:
revalidateTag('posts')
혼용할 수 있지만, 대규모 앱에서는 태그 중심이 유지보수에 유리합니다.
해결 패턴 4: “정적 최적화가 되면 안 되는” 세그먼트 명시
다음 조건이 있으면 해당 세그먼트는 동적으로 두는 편이 안전합니다.
- 쿠키/헤더 기반 개인화
- 권한에 따라 데이터가 달라짐
- 실시간성이 중요한 데이터(운영 대시보드 등)
설정 예시는 아래 중 하나를 선택합니다.
// 강제 동적
export const dynamic = 'force-dynamic'
// 또는 0초 재검증(사실상 매 요청)
export const revalidate = 0
둘을 동시에 쓰기보다는 팀 컨벤션으로 하나를 택하는 편이 좋습니다.
실전에서 많이 하는 “안전한 조합” 레시피
레시피 A: 공용 리스트/상세 + 관리자 수정
- 읽기: 태그 기반 캐시
- 쓰기:
revalidateTag로 목록/상세 모두 무효화
// 읽기
await fetch('.../posts', { next: { tags: ['posts'] } })
await fetch(`.../posts/${id}`, { next: { tags: ['post:'+id] } })
// 쓰기 후
revalidateTag('posts')
revalidateTag('post:'+id)
레시피 B: 내 정보/내 알림
- 읽기:
cache: 'no-store' - 세그먼트:
dynamic = 'force-dynamic'
export const dynamic = 'force-dynamic'
await fetch('.../me', { cache: 'no-store' })
레시피 C: 외부 API가 느려서 캐시가 꼭 필요하지만, 1분 내 최신이면 됨
- 읽기:
revalidate를 짧게
await fetch('https://api.example.com/ranking', {
next: { revalidate: 60 },
})
이 경우 쓰기 트리거가 없다면 태그보다 시간 기반이 단순합니다.
캐시 꼬임 디버깅 체크리스트
- 이 페이지는 정적이어야 하나, 동적이어야 하나
- 개인화면 동적이 기본
- 문제 데이터는 어디서 가져오나
- 서버 컴포넌트
fetch - 라우트 핸들러
- 서버 액션
- 해당
fetch에cache또는next옵션이 명시되어 있나
- 아무것도 없으면 기본 캐시가 적용될 수 있음
- 쓰기 후 무효화가 “데이터 단위”로 정확한가
- 태그를 달았다면
revalidateTag - 경로만 갱신하면 안 되는 구조인지 확인
- 목록/상세가 같은 태그 체계를 공유하나
posts와post:${id}같이 계층적으로 잡으면 운영이 편함
이런 식의 디버깅 접근은 인프라 장애를 좁혀가는 방식과 유사합니다. 예를 들어 네트워크/권한/토큰 같은 레이어를 분리해 원인을 특정하는 방법론은 GitHub Actions 403 권한오류 해결 - GITHUB_TOKEN·OIDC에서 다룬 “원인 레이어 분리”와 결이 같습니다.
주의: 본문에 클라이언트 상태를 섞을 때
RSC는 서버에서 렌더링되지만, 클라이언트 컴포넌트의 상태와 섞이면 “서버는 최신인데 클라이언트가 이전 상태를 유지”하는 일이 생깁니다. 이 경우는 캐시가 아니라 상태 동기화 문제일 수 있습니다.
- 폼 제출 후
router.refresh()가 필요한지 - 서버 액션 이후에 화면이 갱신되는지
다만 무분별한 router.refresh()는 전체 RSC를 다시 받아 성능이 떨어질 수 있으니, 가능하면 태그/경로 재검증으로 서버 쪽 캐시를 올바르게 유지하는 게 우선입니다.
마무리: “캐시를 켠다”가 아니라 “캐시 키를 설계한다”
App Router에서 RSC 캐시 꼬임을 줄이는 핵심은 다음 2가지입니다.
- 개인화/권한 의존 데이터는 과감히
no-store+ 동적 세그먼트로 안전하게 - 공용 데이터는 태그를 설계하고, 쓰기 후
revalidateTag로 정확히 무효화
이 원칙을 적용하면 “어떤 사용자가 어떤 시점에 어떤 데이터를 봐야 하는지”가 명확해지고, 캐시가 성능 최적화가 아니라 장애 원인이 되는 상황을 크게 줄일 수 있습니다.
추가로, 타입 레벨에서 태그 문자열을 실수 없이 관리하고 싶다면(예: post:${id} 규칙 강제), TS의 타입 좁힘/리터럴 타입 패턴이 도움이 됩니다. 관련해서는 TS 5.5 타입 좁힘이 안 될 때 - is·satisfies도 함께 참고하면 태그/키 설계를 더 단단하게 가져갈 수 있습니다.