Published on

Next.js 14 App Router에서 RSC 캐시 꼬임 해결

Authors

서버 컴포넌트(React Server Components, RSC)를 도입한 Next.js 14 App Router는 기본값만 잘 써도 TTFB와 서버 부하를 크게 줄일 수 있습니다. 하지만 운영 환경에서 종종 “캐시가 꼬였다”라는 형태로 문제가 터집니다. 대표적으로는 (1) A 사용자의 데이터가 B 사용자에게 보이거나, (2) 로그아웃했는데도 로그인 상태 UI가 남거나, (3) 특정 페이지가 어떤 배포 이후부터는 절대 갱신되지 않거나, (4) 반대로 매 요청마다 렌더링이 폭주해 캐시가 무력화되는 현상입니다.

이 글은 Next.js 14 App Router에서 RSC 캐시가 꼬이는 전형적인 원인을 재현 가능한 형태로 분해하고, 각 케이스에 맞는 정확한 캐시 경계 설정(동적/정적), fetch 캐시 키 설계, revalidate/태그 기반 무효화, 쿠키/헤더 사용 시 주의점을 통해 해결하는 실전 가이드입니다.

관련해서 “캐시·revalidate로 TTFB 낮추기” 최적화 관점은 아래 글도 함께 보면 맥락이 이어집니다.

1) RSC 캐시 꼬임이란 무엇인가

App Router에서 한 번 그려진 RSC 결과는 여러 레이어에서 캐싱될 수 있습니다.

  • Full Route Cache: 라우트 단위 HTML/RSC 페이로드 캐시(정적 라우트에서 강하게 작동)
  • Data Cache: fetch() 결과 캐시(Next가 확장한 fetch 캐시)
  • Request Memoization: 동일 요청 내 중복 fetch 제거(같은 URL/옵션이면 한 번만 호출)
  • CDN/프록시 캐시: Vercel Edge/CDN, CloudFront 등 외부 캐시

“꼬임”은 보통 아래 중 하나로 나타납니다.

  1. 사용자별 응답이 공유 캐시에 들어감 (가장 위험)
  2. 캐시 키에 반영돼야 할 변수가 누락됨 (locale, AB 테스트, 권한, 테넌트 등)
  3. 무효화 전략이 부정확 (revalidate가 안 먹거나 너무 넓게 무효화)
  4. 동적/정적 경계가 의도와 다르게 결정됨 (cookies()/headers() 사용 유무에 의해 자동 결정)

2) 가장 흔한 원인 5가지

원인 A: 인증이 필요한 데이터인데 fetch가 캐시됨

Next의 fetch는 기본적으로 캐시될 수 있습니다(환경/옵션에 따라). 인증 API를 호출하면서 cache: 'force-cache'가 걸리거나, revalidate를 주고 “적당히 갱신되겠지”라고 생각하면 사용자 데이터가 공유 캐시에 쌓일 수 있습니다.

특히 위험한 패턴은 다음입니다.

  • Authorization 헤더/쿠키로 사용자 구분을 하는데, 캐시 키에 그 값이 반영되지 않음
  • 같은 URL로 호출하는데 사용자마다 응답이 달라짐

원인 B: cookies() / headers()를 루트 레이아웃에서 사용

app/layout.tsx 같은 상위 레벨에서 cookies()headers()를 호출하면 라우트 전체가 동적으로 바뀌거나(혹은 반대로 의도치 않게 정적화가 깨지거나), 캐시 경계가 넓어져 “왜 이 페이지가 캐시가 안 되지?” 같은 문제로 이어집니다.

또는 반대로, 상위는 정적이라고 믿었는데 하위에서 동적 데이터가 섞여 “정적 캐시에 동적 조각이 들어가는” 형태로 오해가 생깁니다.

원인 C: fetch 옵션이 일관되지 않음(키 분산/미스)

동일한 리소스를 가져오는데 페이지마다 next: { revalidate } 값이 다르거나, 어떤 곳은 no-store, 어떤 곳은 force-cache를 쓰면 캐시 히트가 깨지고 “어떤 경로로 들어오면 오래된 데이터, 어떤 경로는 최신” 같은 현상이 생깁니다.

원인 D: 프록시/CDN이 Vary를 무시하거나 과도하게 캐시

Next 내부 캐시는 괜찮은데, 앞단 CDN이 CookieAuthorization이 있는 요청을 캐시해버리면 사용자 데이터 섞임이 발생합니다. 운영에서 “로컬에서는 재현 안 되는데 프로덕션에서만” 터지는 전형적인 케이스입니다.

원인 E: 태그 기반 무효화 설계가 없음(또는 너무 광범위)

revalidatePath/revalidateTag를 쓰지 않고 시간 기반 revalidate: 60만 두면, 변경 직후 1분 동안은 계속 구버전을 볼 수 있습니다. 반대로 모든 변경에 revalidatePath('/')를 남발하면 캐시가 계속 깨져 성능이 무너집니다.

3) 문제 재현: 사용자 데이터가 섞이는 전형적인 코드

아래는 서버 컴포넌트에서 “내 프로필”을 가져오는 예시인데, 잘못된 캐시 설정으로 인해 사용자별 응답이 공유될 수 있는 형태입니다.

// app/me/page.tsx (Server Component)
import { cookies } from 'next/headers'

async function getMe() {
  const token = cookies().get('session')?.value

  const res = await fetch('https://api.example.com/me', {
    headers: {
      Authorization: `Bearer ${token}`,
    },
    // ❌ 위험: 인증 응답을 캐시(공유)할 가능성
    cache: 'force-cache',
  })

  if (!res.ok) throw new Error('failed')
  return res.json()
}

export default async function Page() {
  const me = await getMe()
  return <pre>{JSON.stringify(me, null, 2)}</pre>
}

이 코드는 “URL은 동일하지만 사용자마다 응답이 다른” 대표 케이스입니다. force-cache는 물론이고, 애매한 revalidate를 주는 것도 위험합니다. 인증이 개입되는 순간 **원칙적으로는 no-store**로 시작해야 안전합니다.

4) 해결 전략: 3가지 원칙으로 정리

원칙 1: 사용자별 데이터는 기본 no-store

  • 인증이 필요한 API 응답
  • 권한/플랜/테넌트에 따라 달라지는 응답
  • 장바구니/알림/개인화 추천

이런 데이터는 “캐시하면 안 되는 데이터”가 아니라, “캐시하려면 사용자 단위 캐시 키를 설계해야 하는 데이터”입니다. 대부분의 서비스는 사용자 단위 캐시 키를 Next fetch 캐시에 안전하게 얹기 어렵기 때문에, 일단 no-store가 정답인 경우가 많습니다.

async function getMeNoStore(token: string) {
  const res = await fetch('https://api.example.com/me', {
    headers: { Authorization: `Bearer ${token}` },
    cache: 'no-store', // ✅ 사용자별 응답은 저장하지 않음
  })
  if (!res.ok) throw new Error('failed')
  return res.json()
}

추가로, 인증 데이터 fetch를 여러 컴포넌트에서 부르면 비용이 커질 수 있는데, 이때는 “요청 단위 중복 제거(메모이제이션)”를 활용하는 편이 안전합니다.

// lib/me.ts
import { cache } from 'react'

export const getMe = cache(async (token: string) => {
  const res = await fetch('https://api.example.com/me', {
    headers: { Authorization: `Bearer ${token}` },
    cache: 'no-store',
  })
  if (!res.ok) throw new Error('failed')
  return res.json()
})

cache()는 “서버 런타임에서 영구 캐시”가 아니라, 동일 렌더링/요청 컨텍스트에서 중복 호출을 줄이는 목적으로 이해하는 게 안전합니다.

원칙 2: 공유 캐시를 쓰려면 캐시 키를 명시적으로 분리

사용자별이 아니라 “테넌트별/로케일별/AB그룹별”처럼 제한된 차원으로만 달라지는 데이터는 공유 캐시가 가능합니다. 이때는 URL 자체에 키를 포함시키는 방식이 가장 단순하고 안전합니다.

// ✅ locale을 쿼리로 포함해 캐시 키 분리
async function getCatalog(locale: string) {
  const res = await fetch(`https://api.example.com/catalog?locale=${locale}`, {
    next: { revalidate: 300 },
  })
  if (!res.ok) throw new Error('failed')
  return res.json()
}

헤더로만 구분하면 중간 캐시(CDN)에서 Vary 처리가 꼬일 수 있으니, 가능하면 URL로 승격하는 편이 운영 난이도가 낮습니다.

원칙 3: 시간 기반 revalidate만 쓰지 말고 태그 무효화를 섞기

상품/게시글/설정값처럼 “대부분은 캐시해도 되지만, 변경 직후에는 즉시 반영돼야” 하는 데이터는 revalidateTag 조합이 좋습니다.

  • 읽기: fetch(..., { next: { tags: ['post:123'], revalidate: 3600 } })
  • 쓰기(관리자 수정): revalidateTag('post:123')
// lib/posts.ts
export async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: {
      tags: [`post:${id}`],
      revalidate: 3600,
    },
  })
  if (!res.ok) throw new Error('failed')
  return res.json()
}
// app/admin/posts/[id]/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function updatePost(id: string, payload: any) {
  await fetch(`https://api.example.com/posts/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
    cache: 'no-store',
  })

  // ✅ 해당 글 캐시만 정확히 무효화
  revalidateTag(`post:${id}`)
}

이 패턴은 “캐시가 안 풀려서 구버전이 계속 보인다” 문제를 구조적으로 제거합니다.

5) App Router에서 캐시 경계(동적/정적) 명시하기

RSC 캐시 꼬임의 절반은 “이 페이지가 지금 정적이야? 동적이야?”가 모호해서 생깁니다. 아래 옵션으로 의도를 명확히 하세요.

완전 동적 페이지로 고정

로그인 상태, 개인화, 실시간성이 강한 페이지라면 라우트 자체를 동적으로 박아두는 게 안전합니다.

// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'
export const revalidate = 0

export default async function Dashboard() {
  // 사용자별 데이터는 no-store로
  return <div>...</div>
}

완전 정적(또는 ISR)로 고정

마케팅 페이지/문서처럼 사용자와 무관한 페이지는 정적으로 고정해 캐시를 최대화합니다.

// app/docs/page.tsx
export const dynamic = 'force-static'
export const revalidate = 3600

export default function Docs() {
  return <div>static docs</div>
}

주의: force-static인 페이지에서 cookies()/headers()를 호출하면 의도와 충돌할 수 있습니다. 사용자별 분기가 필요하면 해당 분기 부분을 클라이언트 컴포넌트로 분리하거나, 페이지 자체를 동적으로 전환해야 합니다.

6) “로그인/로그아웃 UI가 안 바뀜” 케이스의 정석 처리

가장 자주 보이는 증상 중 하나가 “로그아웃했는데 헤더에 사용자 이름이 남는다”입니다. 보통은 다음 중 하나입니다.

  • 헤더(서버 컴포넌트)가 정적으로 캐시됨
  • 헤더에서 사용자 정보를 가져오는 fetch가 캐시됨

해결은 간단합니다.

  1. 헤더가 사용자별이면 헤더를 포함하는 레이아웃/페이지를 동적으로 만들거나
  2. 헤더의 사용자 정보 fetch를 no-store로 바꾸고
  3. 필요하면 클라이언트에서 세션 상태를 구독해 UI를 갱신
// app/(site)/layout.tsx
export const dynamic = 'force-dynamic'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {/* 사용자별 헤더라면 상위 레이아웃도 동적이 안전 */}
        {children}
      </body>
    </html>
  )
}

만약 “본문은 정적 캐시를 유지하고 헤더만 개인화”하고 싶다면, 헤더를 클라이언트 컴포넌트로 내려서 세션을 CSR로 처리하는 방식이 운영 난이도가 낮습니다(정적 캐시와 개인화를 섞는 전형적인 절충안).

7) 디버깅 체크리스트: 어디에서 캐시가 꼬였는지 찾기

  1. 해당 라우트가 정적/동적인지 확인
    • dynamic/revalidate 설정
    • cookies()/headers() 사용 위치
  2. 문제 데이터 fetch의 옵션 확인
    • 인증/개인화면 cache: 'no-store'부터
    • 공유 캐시라면 URL에 키 포함
  3. 동일 리소스에 서로 다른 캐시 정책이 섞였는지 확인
    • 어떤 곳은 revalidate: 60, 어떤 곳은 no-store
  4. CDN/프록시 캐시 확인
    • Set-Cookie 있는 응답이 캐시되는지
    • Vary가 기대대로 동작하는지
  5. 무효화가 실제로 실행되는지 확인
    • revalidateTag/revalidatePath 호출 위치가 서버 액션/route handler인지

운영에서 “원인을 못 찾겠다”면, 우선 사용자별 데이터 fetch를 모두 no-store로 바꿔 증상이 사라지는지 확인하세요. 사라진다면 원인은 90% 확률로 “사용자별 응답이 공유 캐시에 들어간 것”입니다.

8) 권장 아키텍처: 캐시 정책을 한 곳에서 강제하기

프로젝트가 커지면 페이지마다 fetch 옵션이 제각각이 되어 다시 꼬입니다. 데이터 레이어를 만들고 정책을 중앙에서 강제하세요.

// lib/http.ts
type CacheMode =
  | { kind: 'private' } // 사용자별
  | { kind: 'public'; revalidate: number; tags?: string[] }

export async function apiFetch<T>(
  url: string,
  opts: RequestInit & { cacheMode: CacheMode; token?: string }
): Promise<T> {
  const { cacheMode, token, ...init } = opts

  const headers = new Headers(init.headers)
  if (token) headers.set('Authorization', `Bearer ${token}`)

  const nextOpts =
    cacheMode.kind === 'public'
      ? { next: { revalidate: cacheMode.revalidate, tags: cacheMode.tags } }
      : {}

  const res = await fetch(url, {
    ...init,
    headers,
    ...(cacheMode.kind === 'private' ? { cache: 'no-store' } : {}),
    ...nextOpts,
  })

  if (!res.ok) throw new Error(`api error ${res.status}`)
  return res.json()
}

이렇게 해두면 “인증 응답을 실수로 캐시”하는 사고가 줄고, 태그/리밸리데이트 정책도 일관되게 유지됩니다.

9) 마무리: 꼬임을 ‘버그’가 아니라 ‘경계 설계’로 다루기

Next.js 14 App Router의 RSC 캐시는 강력하지만, 기본값만 믿고 가면 인증/개인화가 섞이는 순간 사고가 납니다. 해결의 핵심은 다음 3줄로 요약됩니다.

  • 사용자별 데이터는 no-store (필요하면 요청 단위 메모이제이션으로 비용만 절감)
  • 공유 캐시는 URL에 키를 포함해 캐시 키를 명시적으로 분리
  • 시간 기반 revalidate + 태그 무효화로 “즉시 반영” 요구를 정확히 만족

캐시 최적화 자체가 목표라면 위에서 링크한 글(RSC 캐시·revalidate로 TTFB 낮추기)과 함께, 이번 글의 “꼬임 방지 경계 설정”을 같이 적용하는 것이 운영에서 가장 안전한 조합입니다.