Published on

Next.js 15 RSC 캐시로 stale UI 뜰 때 해결법

Authors

서버 액션(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분 진단 체크리스트

아래 질문에 “예”가 나오면 해당 해결 파트를 우선 적용하세요.

  1. Server Component에서 fetch()를 쓰고 있고, revalidate를 줬거나 기본 캐시가 의심된다fetch 캐시 전략 점검
  2. Server Action/Mutation 이후에도 같은 URL에서 값이 안 바뀐다revalidatePath/revalidateTag 누락 가능성
  3. 새로고침(F5)하면 최신인데, 클라이언트 이동에서는 stale → 라우터 캐시/router.refresh() 필요 가능성
  4. 특정 사용자만 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는 대부분 기술 문제가 아니라 무효화 설계가 애매해서 생깁니다. 아래처럼 규칙을 정해두면 재발이 줄어듭니다.

  1. 데이터 도메인별 tag 네이밍 규칙
    • 예: posts, post:{id}, comments, comment:{id}
  2. Mutation은 반드시 해당 도메인 tag를 무효화
  3. 경로 무효화는 보조 수단
    • “이 페이지 하나만” 확실히 갱신해야 할 때만 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가 뜬다”는 류의 버그 리포트가 체감상 크게 줄어듭니다.