Published on

Next.js App Router RSC 캐시 꼬임 해결 가이드

Authors

서버 컴포넌트(RSC)는 App Router의 핵심이지만, 캐시 전략을 조금만 잘못 잡아도 “방금 저장했는데 화면은 옛날 값”, “A 사용자가 본 데이터가 B에게 보임”, “리스트는 갱신됐는데 상세는 안 바뀜” 같은 캐시 꼬임을 만들기 쉽습니다. 특히 fetch 기본 캐시, 라우트 세그먼트의 정적 최적화, 그리고 태그 기반 재검증을 섞어 쓰는 시점에 문제가 자주 터집니다.

이 글에서는 App Router에서 RSC 캐시가 꼬이는 전형적인 원인을 분류하고, 재현 가능한 형태로 진단한 뒤, 가장 안전한 해결 패턴(세그먼트 설정 + fetch 캐시 제어 + revalidateTag/revalidatePath)을 코드로 정리합니다.

RSC 캐시 “꼬임”이란 무엇인가

현상은 다양하지만, 대부분 아래 중 하나로 수렴합니다.

  • 데이터가 갱신됐는데도 UI가 오래된 값을 보여줌: DB는 바뀌었는데 RSC 페이로드가 캐시된 상태
  • 페이지 일부만 갱신되고 일부는 안 됨: 리스트는 새로고침되는데 상세/헤더는 이전 값
  • 사용자별 데이터가 섞이는 것처럼 보임: 개인화 데이터가 정적 캐시로 승격되거나, 캐시 키가 요청별로 분리되지 않음
  • 배포/코드 변경 후 특정 라우트만 이상: 세그먼트 단위 캐시가 남아있거나, 태그/경로 재검증이 누락됨

App Router에서 캐시는 크게 세 층으로 생각하면 정리가 쉽습니다.

  1. 세그먼트(라우트) 단위 렌더링 캐시: 정적 최적화가 되면 RSC 결과가 캐시될 수 있음
  2. 데이터 캐시(fetch): 서버에서 호출한 fetch가 기본적으로 캐시될 수 있음
  3. 클라이언트 라우팅 캐시: 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
  • 상세는 postspost:${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 },
})

이 경우 쓰기 트리거가 없다면 태그보다 시간 기반이 단순합니다.

캐시 꼬임 디버깅 체크리스트

  1. 이 페이지는 정적이어야 하나, 동적이어야 하나
  • 개인화면 동적이 기본
  1. 문제 데이터는 어디서 가져오나
  • 서버 컴포넌트 fetch
  • 라우트 핸들러
  • 서버 액션
  1. 해당 fetchcache 또는 next 옵션이 명시되어 있나
  • 아무것도 없으면 기본 캐시가 적용될 수 있음
  1. 쓰기 후 무효화가 “데이터 단위”로 정확한가
  • 태그를 달았다면 revalidateTag
  • 경로만 갱신하면 안 되는 구조인지 확인
  1. 목록/상세가 같은 태그 체계를 공유하나
  • postspost:${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도 함께 참고하면 태그/키 설계를 더 단단하게 가져갈 수 있습니다.