Published on

Next.js 14 RSC 캐시로 데이터가 안 바뀔 때

Authors

서버 데이터는 분명 바뀌었는데, Next.js 14(App Router)에서 화면이 계속 예전 값으로 보이는 경우가 있습니다. 대부분은 RSC(React Server Components) 렌더링 결과fetch 캐시가 결합되면서 생기는 현상입니다. 특히 개발 환경에서는 새로고침으로 잘 바뀌다가, 프로덕션에 올리면 갑자기 “데이터가 안 바뀐다”는 제보가 나오기 쉽습니다.

이 글에서는 Next.js 14 기준으로 왜 stale(오래된) UI가 뜨는지, 그리고 상황별로 가장 안전한 캐시 전략(no-store, revalidate, 태그 기반 revalidateTag, path 기반 revalidatePath)을 정리합니다. Next.js 15에서도 유사한 문제가 반복되므로, 추가로 참고할 만한 글로 Next.js 15 RSC 캐시로 stale UI 뜰 때 해결법도 함께 보면 좋습니다.

1) 증상 패턴: “DB는 바뀌었는데 UI는 그대로”

대표적인 증상은 아래와 같습니다.

  • 목록 페이지에서 새 글을 등록했는데, 목록이 갱신되지 않음
  • 상세 페이지에서 수정했는데, 다시 들어와도 수정 전 내용이 보임
  • 서버 컴포넌트에서 fetch()로 가져온 값이 계속 동일하게 유지됨
  • 배포 후에만 재현(로컬 dev에서는 덜 재현)

이때 흔히 클라이언트 상태관리(React Query 등) 문제로 오해하지만, App Router + RSC에서는 서버 렌더 결과 자체가 캐시될 수 있어 원인이 서버 쪽에 있는 경우가 많습니다.

2) Next.js 14에서 “캐시”가 걸리는 지점 3가지

Next.js 14의 App Router에서 데이터가 안 바뀌는 원인은 대체로 아래 3개 중 하나(혹은 조합)입니다.

2.1 fetch()의 기본 캐시 동작

서버 컴포넌트에서 실행되는 fetch()는 Next.js가 요청을 메모이즈/캐시할 수 있습니다. 특히 정적 최적화가 가능한 경로에서는 “빌드 시점/요청 시점”에 가져온 결과가 계속 재사용되며, 개발자가 의도한 실시간성이 깨집니다.

2.2 Route Segment(페이지) 단위의 정적화

page.tsx가 “정적으로 만들 수 있다”고 판단되면, Next.js는 그 세그먼트를 정적으로 캐시하거나 ISR처럼 다룹니다. 개발자가 아무 설정을 안 했는데도 결과적으로 정적 페이지처럼 고정되는 느낌이 날 수 있습니다.

2.3 Server Action 이후에도 캐시 무효화를 안 한 경우

use server로 DB를 업데이트해도, 그 업데이트를 반영해야 하는 페이지/태그를 revalidate하지 않으면 기존 RSC 결과가 그대로 남아 UI가 갱신되지 않습니다.

3) 가장 빠른 해결: “무조건 최신”이 필요하면 no-store

관리자 화면, 결제 상태, 재고, 실시간 대시보드처럼 항상 최신 데이터가 필요하면 캐시를 끄는 게 정답인 경우가 많습니다.

3.1 서버 컴포넌트에서 fetch(..., { cache: 'no-store' })

// app/products/page.tsx

type Product = { id: string; name: string; updatedAt: string };

async function getProducts(): Promise<Product[]> {
  const res = await fetch(`${process.env.API_BASE_URL}/products`, {
    cache: 'no-store',
  });
  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <main>
      <h1>Products</h1>
      <ul>
        {products.map((p) => (
          <li key={p.id}>{p.name} ({p.updatedAt})</li>
        ))}
      </ul>
    </main>
  );
}
  • 장점: 가장 직관적이고 “데이터가 안 바뀐다” 류의 이슈를 즉시 제거
  • 단점: 매 요청마다 백엔드 호출 → 트래픽/비용 증가

3.2 페이지 자체를 동적으로 강제: export const dynamic = 'force-dynamic'

fetch만 바꿨는데도 정적화가 남아 의심될 때, 페이지 세그먼트를 동적으로 강제할 수 있습니다.

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

export default async function ProductsPage() {
  // ...
}

> 실무 팁: “관리자/내부 도구”는 force-dynamic + no-store 조합이 가장 예측 가능합니다.

4) 성능이 중요하면 revalidate(ISR 스타일)로 타협

모든 요청에서 최신이 필요하지 않고, “최대 10초 정도 늦어도 된다” 같은 요구라면 revalidate가 적절합니다.

4.1 fetch 옵션으로 revalidate 지정

async function getProducts() {
  const res = await fetch(`${process.env.API_BASE_URL}/products`, {
    next: { revalidate: 10 }, // 10초마다 갱신
  });
  if (!res.ok) throw new Error('Failed');
  return res.json();
}
  • 장점: 캐시를 활용하면서도 주기적으로 자동 갱신
  • 단점: 업데이트 직후 즉시 반영이 보장되지 않음(최대 revalidate 초만큼 stale 가능)

5) “업데이트 직후 즉시 반영”이 필요하면 태그/경로 무효화

가장 흔한 요구는 이겁니다.

  • 평소에는 캐시로 빠르게
  • 하지만 쓰기 작업(POST/PUT/DELETE) 후에는 즉시 갱신

이때는 revalidateTag / revalidatePath를 Server Action 또는 Route Handler에서 호출해 캐시를 정확히 무효화하는 방식이 정석입니다.

5.1 태그 기반: fetchtags를 달고 revalidateTag() 호출

(1) 읽기: 태그 달기

// app/lib/products.ts

type Product = { id: string; name: string };

export async function getProducts(): Promise<Product[]> {
  const res = await fetch(`${process.env.API_BASE_URL}/products`, {
    next: {
      tags: ['products'],
      revalidate: 3600, // 기본은 길게
    },
  });
  if (!res.ok) throw new Error('Failed');
  return res.json();
}

(2) 쓰기: Server Action에서 태그 무효화

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

import { revalidateTag } from 'next/cache';

export async function createProduct(formData: FormData) {
  const name = String(formData.get('name') || '');

  const res = await fetch(`${process.env.API_BASE_URL}/products`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ name }),
  });

  if (!res.ok) throw new Error('Create failed');

  // products 태그로 캐시된 fetch/RSC 결과를 무효화
  revalidateTag('products');
}

이 패턴의 핵심은 읽기(fetch)와 쓰기(action)가 같은 태그로 연결돼야 한다는 점입니다.

5.2 경로 기반: revalidatePath('/products')

목록/상세 등 특정 라우트 세그먼트가 확실하다면 path 기반도 단순합니다.

'use server';

import { revalidatePath } from 'next/cache';

export async function updateProduct(id: string, formData: FormData) {
  const name = String(formData.get('name') || '');

  const res = await fetch(`${process.env.API_BASE_URL}/products/${id}`, {
    method: 'PUT',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ name }),
  });
  if (!res.ok) throw new Error('Update failed');

  // 목록과 상세 둘 다 무효화
  revalidatePath('/products');
  revalidatePath(`/products/${id}`);
}
  • 장점: 설정이 직관적
  • 단점: 경로가 많아질수록 무효화 호출이 늘어남(태그가 더 잘 확장됨)

6) “나는 no-store인데도 안 바뀐다”면 체크할 것

여기부터는 실제 트러블슈팅에서 자주 걸리는 함정들입니다.

6.1 브라우저/프록시/CDN 캐시

Next.js 쪽 캐시를 껐는데도 안 바뀌면, 그 다음은 인프라입니다.

  • API 응답 헤더에 Cache-Control이 강하게 잡혀 있는지
  • CDN(CloudFront 등)에서 API를 캐시하는지
  • 리버스 프록시(Nginx) 설정이 응답을 캐시하는지

특히 Kubernetes/EKS 환경에서 인그레스/ALB/NGINX 조합을 쓰면 “앱은 정상인데 경계에서 캐시/리셋/타임아웃”이 문제인 경우가 많습니다. 인프라 레벨 이슈가 의심되면 성격은 다르지만 진단 접근 방식 참고용으로 EKS ALB Ingress 500 Target reset 원인·해결 같은 글도 도움이 됩니다.

6.2 동일 요청 dedupe(요청 중복 제거)로 “같은 렌더에서만” 고정

RSC 렌더링 중 같은 fetch(url)이 여러 번 호출되면 Next.js가 dedupe해 한 번만 호출하는 경우가 있습니다. 이건 같은 요청 사이클에서의 최적화라 보통 문제는 아니지만,

  • URL이 항상 동일
  • 캐시 키가 동일

이면 “내가 다른 파라미터로 호출한다고 생각했는데 실제론 같은 요청”인 실수를 잡는 데 도움이 됩니다. 예: 쿼리스트링이 누락됐거나, 헤더/쿠키 기반 변형을 기대했는데 반영이 안 됨.

6.3 쿠키/헤더에 따른 개인화인데 정적화가 되어버린 경우

로그인 사용자별로 다른 데이터를 보여줘야 하는데 정적화되면 큰 사고가 납니다. 이런 페이지는 다음 중 하나로 명시적으로 처리하는 습관이 안전합니다.

  • export const dynamic = 'force-dynamic'
  • 개인화 데이터 fetch에 cache: 'no-store'

7) 권장 의사결정 표(실무용)

  • 항상 최신이 필수: cache: 'no-store' (+ 필요시 dynamic='force-dynamic')
  • 대부분 캐시, 최대 N초 stale 허용: next: { revalidate: N }
  • 읽기는 캐시, 쓰기 직후만 즉시 갱신: next: { tags: [...] } + revalidateTag() 또는 revalidatePath()

이 3가지만 명확히 구분해도 “Next.js 14에서 데이터가 안 바뀐다” 문제의 80%는 정리됩니다.

8) 마무리: 해결의 핵심은 “캐시 키”와 “무효화 트리거”

Next.js 14 RSC 환경에서 stale UI는 버그라기보다, 프레임워크가 성능을 위해 제공하는 캐싱 메커니즘을 어떤 정책으로 쓰고 있는지가 불명확할 때 생깁니다.

  • 읽기(fetch)는 어떤 캐시 정책을 갖는가?
  • 쓰기(Server Action/Route Handler) 후 무엇을 무효화하는가?
  • 개인화/관리자처럼 실시간성이 중요한 경로는 강제로 동적 처리했는가?

위 질문에 답이 코드로 드러나게 만들면, 배포 후에도 “데이터가 안 바뀐다” 류의 이슈를 재현 가능하게 줄일 수 있습니다.