Published on

Next.js 14 RSC 캐시로 데이터가 안 갱신될 때

Authors

서버의 DB 값은 바뀌었는데 Next.js 14(App Router) 화면은 그대로라면, 대부분 RSC(React Server Components) 렌더 결과 캐시 또는 Next.js의 fetch 캐시에 걸린 상황입니다. 특히 개발 환경에서는 잘 보이던 갱신이 프로덕션에서만 멈추기도 해서, “내 API가 안 바뀌나?” 같은 엉뚱한 곳을 파기 쉽습니다.

이 글에서는 어디에서 캐시가 걸리는지를 먼저 분해하고, 그 다음에 정확히 원하는 수준(요청마다/주기적으로/이벤트 기반)으로 갱신되도록 설정하는 실전 패턴을 정리합니다.

> 캐시로 인한 문제는 원인 추적이 핵심입니다. 장애 상황에서 원인 추적 습관이 필요하다면 systemd 서비스가 반복 재시작될 때 원인 추적법처럼 “관측→가설→검증” 흐름을 적용하면 Next.js 캐시 디버깅도 훨씬 빨라집니다.

Next.js 14에서 “캐시”가 생기는 지점 3가지

App Router 기준으로 데이터가 안 바뀌는 원인은 크게 3축입니다.

1) RSC 렌더 결과 캐시(정적 렌더링/ISR)

  • page.tsx, layout.tsx 같은 서버 컴포넌트가 빌드/요청 시점에 렌더링한 결과가 캐시될 수 있습니다.
  • export const revalidate = ... 또는 export const dynamic = ...에 의해 동작이 결정됩니다.

2) fetch() 캐시(Next.js 확장)

  • Next.js는 서버 컴포넌트에서 호출된 fetch()자동으로 캐싱할 수 있습니다.
  • fetch(url, { cache: 'no-store' }) 또는 next: { revalidate, tags }로 제어합니다.

3) 라우터 캐시/클라이언트 네비게이션 캐시

  • 페이지 이동 시 router.push() 등으로 이동하면, Next가 클라이언트 측 라우터 캐시를 활용해 즉시 전환합니다.
  • 서버 데이터가 바뀌었는데도 클라가 이전 트리를 재사용하면 “안 바뀐 것처럼” 보일 수 있습니다.

이 글의 핵심은 1)과 2)입니다. 대부분은 fetch 캐시 또는 정적 렌더링이 원인입니다.

증상별로 원인 빠르게 가르는 체크리스트

A. 새로고침(F5)해도 안 바뀐다

  • RSC 결과가 정적으로 캐시됐거나(revalidate/dynamic),
  • 서버 fetch()가 캐시되어 같은 응답을 재사용하는 중.

B. 하드 리프레시/시크릿 창에서는 바뀌는데, 일반 탭에서만 안 바뀐다

  • 브라우저 캐시/서비스워커 가능성도 있지만,
  • App Router의 클라이언트 네비게이션 캐시가 영향을 주는 케이스도 있습니다.

C. 개발 환경에서는 잘 되는데 배포하면 안 바뀐다

  • 개발 모드는 캐시가 느슨/비활성인 경우가 많아, 프로덕션에서만 문제가 드러납니다.
  • 특히 fetch() 기본 캐싱, 정적 최적화가 프로덕션에서 강하게 적용됩니다.

가장 흔한 원인: 서버 컴포넌트의 fetch가 캐시됨

서버 컴포넌트에서 아래처럼 호출하면, Next는 상황에 따라 결과를 캐시할 수 있습니다.

// app/posts/page.tsx (Server Component)
export default async function PostsPage() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  return (
    <ul>
      {posts.map((p: any) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

이때 API 서버는 이미 갱신된 데이터를 주는데도, Next 쪽에서 이전 응답을 재사용하면 “데이터가 안 바뀐다”가 됩니다.

해결 1: 요청마다 최신 데이터가 필요하면 cache: 'no-store'

// app/posts/page.tsx
export default async function PostsPage() {
  const res = await fetch('https://api.example.com/posts', {
    cache: 'no-store',
  });
  const posts = await res.json();

  return (
    <ul>
      {posts.map((p: any) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}
  • 장점: 항상 최신
  • 단점: 트래픽/응답시간 증가(매 요청 원격 호출)

해결 2: “N초마다 최신”이면 next.revalidate

// app/posts/page.tsx
export default async function PostsPage() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 30 }, // 30초마다 재검증
  });
  const posts = await res.json();

  return (
    <ul>
      {posts.map((p: any) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}
  • 장점: 캐시 이점 유지 + 주기적 갱신
  • 단점: “즉시 반영”은 아님

페이지 자체가 정적으로 굳어버리는 케이스: dynamic/revalidate 설정

데이터가 자주 바뀌는데 페이지가 정적으로 최적화되면, fetch 설정만 바꿔도 기대만큼 안 움직일 수 있습니다.

해결 3: 페이지를 강제로 동적 렌더링

// app/posts/page.tsx
export const dynamic = 'force-dynamic';

export default async function PostsPage() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();
  return <pre>{JSON.stringify(posts, null, 2)}</pre>;
}

또는 페이지 단위로 ISR을 주고 싶다면:

// app/posts/page.tsx
export const revalidate = 60; // 이 페이지는 60초 단위로 ISR

export default async function PostsPage() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();
  return <pre>{JSON.stringify(posts, null, 2)}</pre>;
}

주의할 점:

  • export const revalidate(페이지 레벨)과 fetch(..., { next: { revalidate } })(요청 레벨)을 섞으면, 의도와 다르게 더 공격적으로 캐시되거나 갱신 타이밍이 꼬였다고 느낄 수 있습니다.
  • “이 페이지는 항상 최신”이 목표면 페이지를 동적으로 + fetch no-store 조합이 가장 단순합니다.

가장 실전적인 해법: 태그 기반 캐시 무효화(revalidateTag)

운영에서 많이 원하는 건 이겁니다.

  • 평소엔 캐시로 빠르게 제공
  • 글/상품이 수정되면 그 순간 즉시 관련 페이지만 갱신

Next.js 14에서는 fetch에 태그를 붙이고, 변경 이벤트에서 태그를 무효화하는 패턴을 씁니다.

1) fetch에 tags 부여

// app/posts/page.tsx
import 'server-only';

export default async function PostsPage() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] },
  });
  const posts = await res.json();

  return (
    <ul>
      {posts.map((p: any) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

2) 서버 액션/라우트 핸들러에서 revalidateTag 호출

// app/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function updatePost(id: string, title: string) {
  // 1) DB 업데이트 또는 API 호출
  await fetch(`https://api.example.com/posts/${id}`, {
    method: 'PUT',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ title }),
    cache: 'no-store',
  });

  // 2) posts 태그 캐시 무효화
  revalidateTag('posts');
}

이 패턴의 장점:

  • 캐시를 포기하지 않으면서도 “수정 즉시 반영”이 가능
  • 어떤 페이지/컴포넌트가 tags: ['posts']를 달고 있든 함께 갱신

운영에서 캐시 무효화는 “전파/일관성” 문제가 자주 얽힙니다. 인증/키 회전처럼 캐시 때문에 장애가 나는 대표적인 사례는 Node.js JWT 검증 실패 - kid·JWKS 캐시로 401 잡기도 함께 참고하면, 캐시 설계 시 어떤 지점이 위험해지는지 감이 빨리 옵니다.

클라이언트 네비게이션에서만 안 바뀔 때: router.refresh()

서버 데이터는 갱신됐는데, 클라이언트에서 같은 라우트로 이동하거나 back/forward에서 이전 트리를 재사용하면 갱신이 늦어 보일 수 있습니다.

예: 폼 저장 후 목록으로 돌아왔는데 목록이 그대로인 경우.

'use client';

import { useRouter } from 'next/navigation';

export function SaveButton() {
  const router = useRouter();

  return (
    <button
      onClick={async () => {
        await fetch('/api/posts/123', { method: 'PUT' });
        router.refresh(); // 현재 라우트의 RSC를 다시 가져오게 함
      }}
    >
      저장
    </button>
  );
}
  • router.refresh()는 “현재 라우트에 대해 서버 컴포넌트 트리를 재요청”하는 개념입니다.
  • 다만 서버 쪽 fetch가 캐시로 고정돼 있으면 refresh를 해도 똑같은 데이터가 올 수 있으니, 앞서 설명한 no-store/revalidate/tags가 먼저입니다.

디버깅: 지금 무엇이 캐시되고 있는지 확인하는 법

1) 가장 작은 재현 코드로 분리

  • 페이지에서 DB/외부 API/내부 API를 다 섞어 쓰면 원인 파악이 느립니다.
  • fetch() 하나만 남기고 cache: 'no-store'를 붙였을 때 해결되면, 원인은 99% fetch 캐시입니다.

2) 응답에 타임스탬프를 넣어 “정말로 같은 응답인지” 확인

API 응답에 generatedAt 같은 값을 넣고 화면에 찍어보면, 브라우저 캐시인지 Next 캐시인지 감이 잡힙니다.

// app/debug/page.tsx
export default async function DebugPage() {
  const res = await fetch('https://api.example.com/debug-time', {
    next: { revalidate: 60 },
  });
  const data = await res.json();

  return (
    <div>
      <div>generatedAt: {data.generatedAt}</div>
      <div>value: {data.value}</div>
    </div>
  );
}

3) 인프라 캐시/CDN도 의심(특히 배포 후)

Vercel/CloudFront/Ingress 캐시 정책이 응답을 잡고 있을 수도 있습니다. “서버는 바뀌었는데 특정 지역/특정 POP에서만 안 바뀐다”면 애플리케이션 캐시가 아니라 엣지 캐시일 수 있습니다.

인프라 관점의 “어디가 병목/오류 지점인지”를 좁히는 접근은 쿠버네티스에서도 동일합니다. 예를 들어 502/504처럼 겉보기엔 앱 문제 같지만 LB/Ingress/타임아웃이 원인인 경우가 많습니다. 필요하면 EKS에서 ALB Ingress 502 Bad Gateway 원인 9가지처럼 계층별로 체크하는 방식이 도움이 됩니다.

추천 조합(운영 기준)

1) 관리자/대시보드처럼 “항상 최신”

  • 페이지: export const dynamic = 'force-dynamic'
  • 데이터: fetch(..., { cache: 'no-store' })

2) 사용자-facing 목록/상세처럼 “대부분 캐시 + 가끔 갱신”

  • 데이터: fetch(..., { next: { revalidate: 30 } })
  • 페이지: 필요 시 export const revalidate = 30

3) 커머스/콘텐츠처럼 “수정 즉시 반영 + 캐시 효율”

  • 데이터: fetch(..., { next: { tags: [...] } })
  • 변경 이벤트: revalidateTag('...') / 필요 시 revalidatePath('/...')

마무리: ‘안 갱신’은 버그가 아니라 설정의 결과다

Next.js 14의 RSC 캐시는 성능을 크게 올려주지만, 갱신 전략을 명시하지 않으면 “왜 안 바뀌지?”가 됩니다. 해결의 순서는 단순합니다.

  1. 문제를 RSC 렌더 캐시 vs fetch 캐시 vs 라우터 캐시로 분리
  2. 목표에 맞게 no-store / revalidate / tags + revalidateTag 중 하나를 선택
  3. 클라이언트 네비게이션 이슈는 router.refresh()로 마무리

이 3단계만 습관화하면, Next.js 14에서 데이터 갱신 문제로 시간을 태우는 일이 크게 줄어듭니다.