Published on

Next.js App Router RSC 캐시 꼬임 해결법

Authors

서버 컴포넌트(RSC) 기반의 Next.js App Router는 기본값만으로도 빠른 응답을 만들 수 있지만, 캐시가 한 번 꼬이면 증상이 매우 애매하게 나타납니다. 대표적으로 다음과 같은 상황이 발생합니다.

  • DB나 API 데이터는 변경됐는데 페이지가 계속 이전 값을 보여준다
  • 특정 사용자만 최신 데이터가 안 보이거나, 반대로 다른 사용자의 데이터가 섞여 보인다
  • router.refresh()를 호출해도 일부 컴포넌트만 갱신되고 나머지는 그대로다
  • 배포 후 한동안만 이상하고 시간이 지나면 정상으로 보인다

이 글은 “RSC 캐시 꼬임”을 원인별로 분해해서, 어떤 레이어의 캐시가 문제인지 빠르게 식별하고, 안전하게 무효화하거나 캐시 전략을 재설계하는 방법을 정리합니다.

참고로 성능 최적화 관점(캐시를 무작정 끄지 않고 INP/체감 성능을 지키는 방법)은 아래 글도 같이 보면 연결이 잘 됩니다.

RSC 캐시가 꼬이는 이유: 캐시 레이어가 여러 개라서

App Router에서 “캐시”라고 부르는 것은 사실 한 덩어리가 아닙니다. 최소 아래 레이어들이 겹칩니다.

  1. Full Route Cache: 정적으로 렌더된 라우트 결과(HTML/RSC 페이로드)를 캐시
  2. Data Cache: fetch() 응답을 캐시(요청 옵션에 따라)
  3. Request Memoization: 같은 렌더 패스 내 동일 fetch() 호출을 중복 제거
  4. Client Router Cache: 클라이언트 측 라우터가 RSC 페이로드를 캐시
  5. CDN/프록시 캐시: Vercel Edge, CloudFront 등 외부 캐시

“꼬임”은 보통 A 레이어는 갱신됐는데 B 레이어가 갱신되지 않은 비대칭에서 발생합니다. 예를 들어 Data Cache를 revalidate로 갱신했는데 Full Route Cache가 여전히 정적이면 페이지는 그대로일 수 있습니다.

가장 흔한 증상 6가지와 원인 맵

1) DB 업데이트 후 페이지가 계속 예전 값

  • 원인 후보
    • 라우트가 여전히 정적(Full Route Cache)으로 남아 있음
    • fetch()가 기본 캐시 정책으로 Data Cache에 남아 있음
    • unstable_cache로 감싼 함수가 무효화되지 않음

2) router.refresh() 했는데 일부만 바뀜

  • 원인 후보
    • 바뀌지 않는 쪽이 정적 라우트/정적 세그먼트로 고정
    • 클라이언트 라우터 캐시가 남아 있고, 서버 쪽 revalidate가 트리거되지 않음

3) 사용자별 데이터가 섞여 보임(치명)

  • 원인 후보
    • 인증 쿠키/헤더 기반 개인화인데 라우트가 정적으로 캐시됨
    • fetch()가 사용자별이어야 하는데 cache: 'force-cache' 또는 기본 캐시로 공유됨
    • unstable_cache 키에 사용자 식별자가 포함되지 않음

4) 개발환경에서는 정상인데 프로덕션에서만 꼬임

  • 원인 후보
    • 개발 모드에서는 캐시가 다르게 동작(상대적으로 덜 공격적)
    • 배포 환경에서 CDN 캐시가 추가로 개입

5) 배포 직후 특정 경로만 오래된 상태

  • 원인 후보
    • ISR/재검증 타이밍과 트래픽 패턴 문제
    • 태그 기반 무효화를 안 하고 시간 기반만 사용

6) 특정 API만 항상 stale

  • 원인 후보
    • fetch() 옵션이 라우트 수준 설정과 충돌
    • revalidate 값이 기대와 다르게 상속

재현 가능한 최소 예제로 이해하기

아래는 “개인화 데이터를 정적으로 캐시해서 사용자 데이터가 섞이는” 전형적인 실수 예시입니다.

잘못된 예: 사용자별 API를 기본 캐시로 호출

app/profile/page.tsx

import { cookies } from 'next/headers'

export default async function ProfilePage() {
  const session = cookies().get('session')?.value

  const res = await fetch('https://api.example.com/me', {
    headers: {
      Authorization: `Bearer ${session}`,
    },
    // 실수 포인트: 기본값이 캐시로 남을 수 있음
    // cache 옵션을 명시하지 않으면 환경/버전에 따라 기대와 다르게 캐시될 수 있다.
  })

  const me = await res.json()

  return (
    <div>
      <h1>{me.name}</h1>
    </div>
  )
}

이 코드는 “헤더가 다르니 당연히 사용자별로 분리되겠지”라는 착각을 유발합니다. 하지만 Next의 Data Cache 관점에서는 캐시 키 구성/정적 최적화 여부에 따라 공유 캐시처럼 동작할 여지가 생깁니다.

올바른 예: 개인화는 명시적으로 no-store

import { cookies } from 'next/headers'

export default async function ProfilePage() {
  const session = cookies().get('session')?.value

  const res = await fetch('https://api.example.com/me', {
    headers: {
      Authorization: `Bearer ${session}`,
    },
    cache: `no-store`,
  })

  const me = await res.json()

  return <h1>{me.name}</h1>
}

개인화 데이터는 원칙적으로 cache: no-store``가 가장 안전합니다. 성능이 걱정되면 아래 “태그 기반 캐시”나 “사용자별 키 캐시”로 정교화합니다.

진단 체크리스트: 어디 캐시가 문제인지 10분 안에 찾기

1) 라우트가 정적인지 동적인지부터 확정

page.tsx 또는 layout.tsx에 아래가 있는지 확인합니다.

  • export const dynamic = 'force-static' 또는 export const revalidate = ... 존재 여부
  • cookies() / headers() / searchParams 사용 여부(동적 신호)

동적이어야 하는데 정적으로 굳는다면, 가장 빠른 임시 진단은 아래를 걸어 “꼬임이 캐시 때문인지” 확인하는 것입니다.

export const dynamic = `force-dynamic`

이걸로 문제가 사라지면, 원인은 거의 확실하게 Full Route Cache 또는 Data Cache 전략입니다.

2) fetch() 옵션을 전부 명시해서 상속/기본값을 제거

캐시 꼬임의 절반은 “어딘가에서 기본값이 캐시로 잡혔다”입니다. 문제 구간의 fetch()에 아래 중 하나를 명시합니다.

  • 개인화/실시간: cache: no-store``
  • 준실시간: next: { revalidate: 10 }
  • 강한 캐시: cache: force-cache``

예시:

await fetch('https://api.example.com/posts', {
  next: { revalidate: 30 },
})

3) 태그 기반 무효화가 실제로 호출되는지 확인

쓰기 요청(POST/PUT/DELETE)에서 revalidateTag() 또는 revalidatePath()를 호출했는지, 그리고 그 호출이 서버에서 실행되는지 확인합니다.

  • Route Handler에서 호출했는가
  • Server Action에서 호출했는가
  • 클라이언트에서 직접 호출하려고 하진 않았는가

해결 패턴 1: 쓰기 이후 태그 기반으로 정확히 무효화

시간 기반 revalidate만 쓰면 “갱신 타이밍”이 운에 맡겨져 꼬임처럼 보일 때가 많습니다. 가장 추천하는 방식은 읽기 데이터에 태그를 달고, 쓰기 시 태그를 무효화하는 패턴입니다.

읽기: fetch()에 태그 부여

export async function getPostList() {
  const res = await fetch('https://api.example.com/posts', {
    next: {
      tags: [`posts`],
      revalidate: 300,
    },
  })

  if (!res.ok) throw new Error('Failed to fetch')
  return res.json()
}

쓰기: Route Handler에서 revalidateTag()

app/api/posts/route.ts

import { revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  const body = await req.json()

  // DB insert/update 수행
  // await db.post.create(...)

  revalidateTag(`posts`)

  return Response.json({ ok: true })
}

이렇게 하면 “5분마다 갱신될 수도”가 아니라, 쓰기 직후 관련 읽기 캐시가 즉시 무효화됩니다.

해결 패턴 2: revalidatePath()는 범위를 좁혀서 사용

revalidatePath()는 강력하지만, 무효화 범위가 넓어지면 예상치 못한 부작용(다른 세그먼트까지 갱신, 트래픽 스파이크)이 생길 수 있습니다.

예시:

import { revalidatePath } from 'next/cache'

export async function POST() {
  // ...write
  revalidatePath(`/posts`)
  revalidatePath(`/posts/new`)
  return Response.json({ ok: true })
}
  • 리스트/상세 페이지가 많으면 태그 기반이 더 유지보수에 유리합니다.

해결 패턴 3: unstable_cache를 쓸 때 “키 설계”로 꼬임 방지

DB 직접 호출을 캐시하고 싶어서 unstable_cache를 쓰는 경우가 있습니다. 이때 가장 큰 함정은 캐시 키에 컨텍스트가 빠지는 것입니다.

잘못된 예: 사용자별 결과를 동일 키로 캐시

import { unstable_cache } from 'next/cache'

export const getMe = unstable_cache(
  async (userId: string) => {
    // return db.user.findUnique({ where: { id: userId } })
    return { id: userId, name: 'demo' }
  },
  [`me`],
  { revalidate: 60 }
)

위 코드는 인자로 userId를 받지만, 키 배열이 고정이라면(혹은 의도와 다르게 구성되면) 캐시가 섞일 수 있습니다.

개선: 키에 식별자를 포함하거나 태그로 무효화

import { unstable_cache } from 'next/cache'

export const getMe = (userId: string) =>
  unstable_cache(
    async () => {
      return { id: userId, name: 'demo' }
    },
    [`me`, userId],
    { revalidate: 60, tags: [`me:${userId}`] }
  )()

쓰기 시에는 다음처럼 무효화합니다.

import { revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  const { userId } = await req.json()

  // ...update user

  revalidateTag(`me:${userId}`)
  return Response.json({ ok: true })
}

해결 패턴 4: 개인화 페이지는 캐시를 끄고, 공용 데이터만 캐시

실무에서 가장 안전한 절충안은 다음입니다.

  • 페이지는 동적(dynamic = force-dynamic``)으로 두고
  • 공용 데이터(카테고리, 공지, 설정 등)만 revalidate 또는 태그 캐시
  • 사용자별 데이터는 no-store

예시:

export const dynamic = `force-dynamic`

async function getPublicConfig() {
  return fetch('https://api.example.com/config', {
    next: { revalidate: 600, tags: [`config`] },
  }).then(r => r.json())
}

async function getUserNoti(userToken: string) {
  return fetch('https://api.example.com/notifications', {
    headers: { Authorization: `Bearer ${userToken}` },
    cache: `no-store`,
  }).then(r => r.json())
}

이 구조는 “섞이면 안 되는 것”과 “캐시해도 되는 것”을 분리해서 꼬임 가능성을 크게 줄입니다.

해결 패턴 5: 클라이언트에서 최신화가 필요하면 router.refresh()를 올바르게 사용

쓰기 이후 화면을 갱신할 때는 다음 조합이 흔합니다.

  • 서버: revalidateTag() 또는 revalidatePath()로 서버 캐시 무효화
  • 클라이언트: router.refresh()로 현재 라우트의 RSC 페이로드 재요청

예시(클라이언트 컴포넌트):

'use client'

import { useRouter } from 'next/navigation'

export function LikeButton({ postId }: { postId: string }) {
  const router = useRouter()

  return (
    <button
      onClick={async () => {
        await fetch(`/api/posts/${postId}/like`, { method: `POST` })
        router.refresh()
      }}
    >
      Like
    </button>
  )
}

중요한 포인트는 router.refresh() 자체가 서버의 Data Cache를 “자동으로 무효화”해주지는 않는다는 점입니다. 서버에서 무효화가 제대로 되어 있어야, refresh가 의미 있는 최신 데이터를 받습니다.

운영에서 자주 하는 실수: “캐시를 껐는데도” stale인 이유

cache: no-store``로 바꿨는데도 stale이면, 다음을 의심합니다.

  • CDN이 응답을 캐시하고 있음(특히 API 도메인이 별도일 때)
  • 브라우저/서비스워커 캐시
  • 백엔드 자체 캐시(서버 메모리, Redis, GraphQL 캐시 등)

캐시는 프론트만의 문제가 아니라서, 캐시가 안 맞을 때의 접근법은 GitHub Actions 캐시나 서버 캐시 트러블슈팅과 구조가 비슷합니다. “어디에 캐시가 있는지 레이어를 쪼개는 것”이 핵심입니다.

권장 설계 가이드(요약)

  • 사용자별 데이터: fetch(..., { cache: no-store })
  • 공용 데이터: fetch(..., { next: { tags: [...], revalidate: N } })
  • 쓰기 요청: 반드시 revalidateTag() 또는 revalidatePath()
  • unstable_cache: 키에 컨텍스트 포함(특히 사용자/테넌트/로케일)
  • 문제가 애매하면 진단용으로 dynamic = force-dynamic``를 걸어 캐시 원인인지 먼저 분리

마무리: “꼬임”은 버그가 아니라 설계 부채인 경우가 많다

RSC 캐시는 성능을 크게 올려주지만, 개인화/쓰기 흐름이 있는 서비스에서는 “기본값”만으로 안정성을 확보하기 어렵습니다. 캐시가 꼬이는 순간을 없애려면, 다음 두 가지를 습관처럼 가져가면 좋습니다.

  1. 읽기에는 태그를 붙이고, 쓰기에서 태그를 무효화한다
  2. 개인화 데이터는 공유 캐시에 절대 올리지 않는다

이 원칙만 지켜도 “가끔씩만 stale” 같은 가장 디버깅 어려운 문제를 크게 줄일 수 있습니다.