Published on

Next.js 14 RSC 캐시 꼬임·stale 데이터 해결법

Authors

서버 컴포넌트 기반으로 앱을 만들다 보면 “데이터를 업데이트했는데 UI가 안 바뀐다”, “어떤 사용자에겐 최신인데 어떤 사용자에겐 옛날 데이터가 보인다” 같은 현상을 한 번쯤 겪습니다. Next.js 14의 App Router + RSC 환경에서는 페이지/레이아웃 단위 캐시, fetch 캐시, 라우트 핸들러 캐시, 클라이언트 라우팅 시의 RSC 페이로드 재사용이 겹치면서, 의도치 않게 stale 데이터가 남는 경우가 많습니다.

이 글에서는 Next.js 14에서 흔히 말하는 “RSC 캐시 꼬임”을 재현 가능한 원인 단위로 분해하고, 팀에서 운영 환경까지 안전하게 가져갈 수 있는 해결책(설정/코드/운영 체크리스트)을 정리합니다. Next.js 15에서도 유사한 문제가 반복되므로, 더 최신 관점은 Next.js 15 RSC 캐시로 stale UI 뜰 때 해결법도 함께 참고하면 좋습니다.

1) Next.js 14에서 “캐시”가 의미하는 것들

Next.js 14(App Router)에서 stale이 생길 때는 대개 아래가 섞여 있습니다.

  • Route Segment Cache(정적/동적 판정): page.tsx, layout.tsx가 정적으로 판단되면 빌드/런타임에서 결과가 재사용됩니다.
  • Data Cache(fetch 캐시): 서버에서 실행되는 fetch()는 기본적으로 캐시될 수 있습니다(특히 정적 세그먼트로 판정될 때).
  • Full Route Cache(페이지 HTML/RSC 페이로드 캐시): 결과물을 경로 단위로 캐싱/재사용.
  • Client-side navigation 시의 RSC 페이로드 재사용: 브라우저에서 라우팅할 때 이전 결과와 합쳐 보이는 듯한 “UI만 stale” 착시가 생길 수 있습니다.

핵심은 “RSC라서 캐시된다”가 아니라, 정적 최적화 + fetch 캐시 + 라우트 캐시가 합쳐져서 stale이 발생한다는 점입니다.

2) 대표 증상별 원인 매핑

2.1 DB는 갱신됐는데 페이지가 안 바뀜

  • 원인 A: 페이지가 정적으로 판정되어 Full Route Cache가 재사용됨
  • 원인 B: fetch()가 Data Cache에 남아 있음
  • 원인 C: mutation 후 revalidate(무효화) 를 안 함

2.2 사용자별 데이터가 섞임(심각)

  • 원인 A: 사용자별로 달라야 하는 요청을 캐시 가능한 fetch로 처리
  • 원인 B: cookies()/headers()를 안 쓰고, 서버에서 전역 토큰/세션을 잘못 참조
  • 원인 C: CDN/프록시 캐시 헤더가 잘못 설정되어 응답이 공유됨

2.3 로컬에선 정상인데 배포하면 stale

  • 원인 A: 개발 모드는 캐시 동작이 단순/무시되는 경우가 많음
  • 원인 B: 배포 환경에서 ISR/캐시가 활성화되며 문제 노출

3) “정적 판정”을 먼저 의심하라

Next.js는 가능한 한 정적으로 만들려고 합니다. 아래 중 하나라도 충족하면 동적으로 바뀌는 경향이 있지만, 반대로 아무 것도 안 하면 정적으로 굳어 stale이 생길 수 있습니다.

3.1 강제로 동적 렌더링하기

정말로 항상 최신 데이터가 필요하면, 세그먼트를 동적으로 고정하세요.

// app/posts/page.tsx
export const dynamic = 'force-dynamic';
// 또는
export const revalidate = 0;

export default async function Page() {
  const res = await fetch('https://api.example.com/posts', {
    cache: 'no-store',
  });
  const posts = await res.json();
  return <pre>{JSON.stringify(posts, null, 2)}</pre>;
}
  • dynamic = 'force-dynamic': 해당 세그먼트는 정적으로 캐시되지 않음
  • revalidate = 0: ISR 없이 매 요청 새로 생성(사실상 동적)

단, 무조건 동적으로 만들면 성능/비용이 증가하므로 정말 필요한 경로에만 적용하는 게 좋습니다.

4) fetch 캐시가 stale의 80%를 만든다

서버 컴포넌트에서 fetch()는 Next가 관여합니다. 특히 아래 케이스가 흔한 함정입니다.

4.1 인증/개인화 API 호출에 cache 옵션이 없다

사용자별 응답이 달라지는 API라면 기본값에 기대지 말고 명시적으로 no-store를 주는 게 안전합니다.

import { cookies } from 'next/headers';

export async function getMe() {
  const token = (await cookies()).get('token')?.value;

  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: 'no-store'를 기본으로 두고, 정말 캐시가 필요할 때만 제한적으로 revalidate를 사용하세요.

4.2 최신이 필요하지만 “조금은 캐시”도 하고 싶다

이럴 땐 next: { revalidate: N }를 사용합니다.

export async function getPublicFeed() {
  const res = await fetch('https://api.example.com/feed', {
    next: { revalidate: 30 }, // 30초 동안은 캐시 재사용
  });
  return res.json();
}
  • 공개 피드/공지/카탈로그 등 “약간의 지연 허용” 데이터에 적합

5) Mutation 이후 “무효화”를 하지 않으면 stale은 정상 동작이다

게시글 작성/수정/삭제 같은 mutation 이후에, 읽기 화면이 캐시를 쓰고 있다면 명시적으로 무효화(revalidate) 해야 합니다.

Next.js 14에서는 대표적으로 두 가지가 있습니다.

  • revalidatePath(path): 경로 단위 무효화
  • revalidateTag(tag): 태그 단위 무효화(권장, 확장성 좋음)

5.1 태그 기반 캐시 무효화 패턴(권장)

읽기(fetch) 쪽에 태그를 붙이고, 쓰기(action/route handler)에서 해당 태그를 무효화합니다.

// app/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/posts/[id]/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function updatePost(id: string, payload: { title: string }) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    method: 'PUT',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error('update failed');

  // 읽기 캐시 무효화
  revalidateTag(`post:${id}`);
}

이 패턴의 장점:

  • 경로가 여러 개(상세/목록/추천/검색)여도 같은 태그로 한 번에 정리 가능
  • “어떤 페이지가 이 데이터를 쓰는지”를 경로로 역추적하지 않아도 됨

5.2 revalidatePath를 쓸 때의 주의점

import { revalidatePath } from 'next/cache';

revalidatePath(`/posts/${id}`);
revalidatePath('/posts');
  • 목록/상세 등 관련 경로를 빠짐없이 무효화해야 합니다.
  • 경로가 늘어날수록 누락으로 stale이 다시 발생하기 쉽습니다.

6) Route Handler에서의 캐시/헤더 실수

app/api/*/route.ts에서 응답 헤더가 캐시 가능하게 나가면, CDN/브라우저가 공유 캐시를 해버릴 수 있습니다. 개인화 응답이라면 아래를 고려하세요.

// app/api/me/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const res = NextResponse.json({ ok: true });
  // 개인화라면 보수적으로
  res.headers.set('Cache-Control', 'no-store');
  return res;
}

특히 사내 프록시/CloudFront/Fastly 같은 계층이 있으면, Next 내부 캐시가 아니라 엣지 캐시가 stale의 진짜 범인일 수 있습니다.

7) 캐시 꼬임을 줄이는 설계 가이드

7.1 데이터 성격을 3분류로 나눠 기본 정책을 고정

  • Public(공개/공유 가능): revalidate + tags 적극 사용
  • User-specific(개인화): 기본 no-store 고정, 필요한 경우만 짧은 revalidate
  • Strict real-time(즉시 반영): no-store + 동적 세그먼트(필요 시)

팀 규칙으로 정해두면 “어떤 fetch는 캐시되고 어떤 fetch는 안 되고” 같은 랜덤성이 줄어듭니다.

7.2 태그 네이밍 컨벤션을 만든다

예:

  • post:${id}
  • post:list
  • user:${userId}:profile

이렇게 해두면 mutation 시 무효화가 단순해지고, 장애 시 원인 추적도 쉬워집니다.

7.3 디버깅 체크리스트

stale이 보이면 아래 순서로 확인하세요.

  1. 이 페이지/레이아웃이 정적으로 판정됐나? (dynamic, revalidate 설정)
  2. 해당 데이터 fetch가 캐시되고 있나? (cache, next.revalidate, next.tags)
  3. mutation 이후 무효화가 호출되나? (revalidateTag/Path)
  4. Route Handler/프록시/CDN이 캐시하고 있나? (Cache-Control, Vary)
  5. 사용자별 응답인데 공유 캐시가 적용됐나? (인증 헤더/쿠키 포함 응답)

캐시 문제는 본질적으로 “어딘가에 저장되어 재사용되는 것”이 원인이라, GitHub Actions 캐시 미스처럼 계층별 키/정책을 분해해서 보는 접근이 도움이 됩니다. 캐시 계층을 진단하는 관점은 GitHub Actions 캐시 안 먹힘 원인 7가지도 비슷한 사고방식을 제공합니다.

8) 실전 예시: 게시글 수정 후 상세/목록 stale 동시 해결

요구사항:

  • 목록은 60초 캐시
  • 상세는 10분 캐시
  • 수정하면 즉시 둘 다 최신화
// app/lib/posts.ts
export async function getPostList() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60, tags: ['post:list'] },
  });
  return res.json();
}

export async function getPostDetail(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: { revalidate: 600, tags: [`post:${id}`] },
  });
  return res.json();
}
// app/posts/[id]/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function editPost(id: string, data: any) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    method: 'PATCH',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(data),
  });
  if (!res.ok) throw new Error('patch failed');

  // 상세 + 목록 동시 무효화
  revalidateTag(`post:${id}`);
  revalidateTag('post:list');
}

이렇게 하면:

  • 평소에는 캐시로 빠르게 제공
  • 수정 직후에는 태그 무효화로 즉시 최신화
  • 경로가 늘어나도 태그만 맞추면 확장 가능

9) 결론: “명시”가 캐시 꼬임을 끝낸다

Next.js 14의 RSC 캐시는 강력하지만, 기본 동작에 맡기면 서비스 특성(개인화/실시간/쓰기 빈도)에 따라 stale이 쉽게 노출됩니다. 해결의 핵심은 다음 3가지입니다.

  • 정적/동적을 명시: 필요한 곳에만 dynamic/revalidate를 정확히 선언
  • fetch 캐시 정책을 명시: 개인화는 no-store, 공유 데이터는 revalidate + tags
  • mutation 후 무효화를 명시: revalidateTag 중심으로 일관된 설계

여기까지 적용했는데도 특정 환경에서만 간헐적으로 stale/불일치가 난다면, Next 내부 캐시가 아니라 네트워크/인프라 계층(프록시 DNS, CDN, LB)에서의 간헐 장애가 원인일 수 있습니다. 그런 경우엔 앱 로그뿐 아니라 인프라 지표도 함께 보며 원인을 좁혀가야 합니다.