Published on

Next.js 14 RSC 캐시 꼬임으로 갱신이 안될 때

Authors

서버 컴포넌트(React Server Components, RSC) 기반의 Next.js 14(App Router)을 쓰다 보면 “DB는 바뀌었는데 화면은 안 바뀐다”, “새로고침해도 옛 데이터가 나온다” 같은 캐시 관련 이슈를 한 번쯤 겪습니다. 특히 개발 환경에서는 잘 되다가 프로덕션에서만 재현되거나, 특정 경로만 갱신이 안 되는 식으로 나타나서 더 골치 아픕니다.

이 글은 Next.js 14에서 흔히 말하는 ‘RSC 캐시 꼬임(=원치 않는 캐시 적중으로 stale data가 계속 노출)’이 발생하는 대표 패턴을 분해해서 설명하고, 어떤 캐시 계층이 문제인지 진단 → 올바른 무효화 전략으로 고치기까지를 실전 관점으로 정리합니다.

참고로 “캐시가 꼬여서 원인을 못 찾고 헤매는 경험”은 Next.js만의 이야기가 아닙니다. CI/CD에서도 비슷한 형태로 터집니다. 캐시 레이어를 분리해서 초기화/무효화 전략을 세우는 관점은 GitLab CI 캐시 꼬임 - 빌드 완전 초기화 가이드에서도 동일하게 유효합니다.

Next.js 14에서 ‘캐시’는 하나가 아니다

Next.js App Router + RSC 환경에서 데이터가 갱신되지 않을 때, 보통 아래 캐시들이 얽혀 있습니다.

  1. Data Cache: fetch() 결과를 Next.js가 캐싱(기본값/옵션에 따라)하는 계층
  2. Full Route Cache: 특정 라우트의 렌더 결과(HTML/RSC payload)를 캐싱
  3. Router Cache (Client-side): 클라이언트 라우팅 시 전환 최적화를 위한 캐시
  4. CDN/Reverse Proxy Cache: Vercel/CloudFront/Nginx 등 외부 레이어

문제는 “RSC 캐시”라고 뭉뚱그려 부르지만, 실제로는 어느 레이어에서 stale이 발생했는지에 따라 해결책이 완전히 달라진다는 점입니다.

증상별로 원인 레이어를 빠르게 좁히는 체크리스트

1) 브라우저 강력 새로고침(Ctrl+Shift+R)에도 안 바뀐다

  • 가능성: 서버 측 캐시(Data Cache / Full Route Cache / CDN)
  • 진단 포인트: 서버 로그에서 API 호출이 실제로 일어나는지 확인

2) 어떤 사용자만 안 바뀐다(세션/권한/로케일에 따라)

  • 가능성: 정적 캐시가 사용자별 데이터를 잘못 공유
  • 흔한 원인: cookies()/headers()를 쓰지 않는 서버 컴포넌트에서 사용자별 데이터를 fetch 캐시로 공유

3) POST/PUT으로 업데이트는 성공하는데 목록 페이지가 그대로다

  • 가능성: mutation 이후 캐시 무효화(revalidate) 누락
  • 해결 방향: revalidateTag, revalidatePath, 혹은 router.refresh()의 올바른 조합

4) 개발환경에서는 되는데 프로덕션에서만 stale

  • 가능성: 프로덕션에서만 정적 최적화/캐시가 강하게 적용
  • 해결 방향: 라우트 레벨에서 dynamic/revalidate 설정 점검

재현: “DB 업데이트 후에도 목록이 안 바뀌는” 전형적인 코드

예를 들어 /posts 페이지에서 글 목록을 RSC로 가져온다고 해보겠습니다.

// app/posts/page.tsx (Server Component)
export default async function PostsPage() {
  const res = await fetch("https://api.example.com/posts");
  const posts: { id: string; title: string }[] = await res.json();

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

이 상태에서 다른 화면에서 글을 추가(POST)한 뒤 /posts로 돌아오면, 기대와 달리 이전 데이터가 보일 수 있습니다. 이유는 간단합니다.

  • Next.js는 fetch()캐시 가능한 요청으로 판단하면 Data Cache를 사용합니다.
  • 그리고 해당 페이지 자체가 정적으로 최적화될 수 있으면 Full Route Cache까지 얹힙니다.

즉, DB가 바뀌어도 페이지가 다시 렌더되지 않거나, fetch가 캐시에서 맞아버리면 갱신이 안 됩니다.

해결 전략 1: “무조건 최신”이 필요하면 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>
  );
}
  • 장점: 가장 단순하고 확실
  • 단점: 트래픽이 많으면 서버/백엔드 부하 증가

운영에서 “가끔 갱신이 안 된다”는 민원 자체가 더 큰 비용이라면, 특정 페이지/요청은 과감히 no-store로 두는 것도 실전적인 선택입니다.

해결 전략 2: 일정 시간 캐시 + 자동 갱신(revalidate)

“실시간까진 아니지만 너무 오래 stale이면 안 된다”면 ISR 스타일로 TTL을 주는 방식이 적합합니다.

// 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>
  );
}
  • 장점: 캐시 효율과 최신성의 균형
  • 단점: “업데이트 직후 즉시 반영”은 보장하지 않음

해결 전략 3(권장): mutation 이후 태그 기반 무효화(revalidateTag)

목록/상세/위젯 등 여러 컴포넌트가 같은 데이터를 공유하는 경우, 가장 유지보수 좋은 접근은 태그 기반 캐시 무효화입니다.

3-1) 데이터를 가져오는 fetch에 tag를 붙인다

// lib/posts.ts
export async function getPosts() {
  const res = await fetch("https://api.example.com/posts", {
    next: { tags: ["posts"] },
  });
  return res.json();
}
// app/posts/page.tsx
import { getPosts } from "@/lib/posts";

export default async function PostsPage() {
  const posts = await getPosts();
  return (
    <ul>
      {posts.map((p: any) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

3-2) 글 생성/수정 서버 액션(또는 Route Handler)에서 태그 무효화

// app/posts/actions.ts
"use server";

import { revalidateTag } 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 }),
  });

  // posts 태그가 달린 모든 fetch 캐시를 무효화
  revalidateTag("posts");
}
  • 장점: “업데이트 직후 반영”을 비교적 명확하게 달성
  • 장점: 여러 페이지/컴포넌트에 흩어진 의존성을 tag로 묶어 관리
  • 주의: 태그를 빼먹으면 다시 ‘꼬임’처럼 보일 수 있음(실은 설계 누락)

해결 전략 4: 페이지 단위로 강제 무효화(revalidatePath)는 언제 쓰나

태그 설계가 어렵거나, “이 라우트만 확실히 다시 렌더”가 목적이면 revalidatePath()가 직관적입니다.

// app/posts/actions.ts
"use server";

import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  // ... POST 수행
  revalidatePath("/posts");
}
  • 장점: 빠르게 문제를 끄고(해결하고) 싶을 때 효과적
  • 단점: /posts에 포함된 여러 데이터 소스 중 일부만 바뀌어도 전체를 무효화

실무에서는 tag(데이터 중심) 를 기본으로 두고, 특정 페이지에만 강한 결합이 있을 때 path(화면 중심) 를 보조로 섞는 편이 안정적입니다.

“사용자별 데이터가 섞여 보이는” 치명적 버그 패턴

RSC에서 사용자별 데이터를 가져오는데 캐시가 공유되면, A 사용자의 데이터가 B에게 보이는 형태로까지 번질 수 있습니다.

위험한 예

// app/me/page.tsx
export default async function MePage() {
  // 세션 쿠키 기반으로 사용자 정보를 준다고 가정
  const res = await fetch("https://api.example.com/me");
  const me = await res.json();
  return <pre>{JSON.stringify(me, null, 2)}</pre>;
}

이 코드는 fetch가 캐시되면 사용자별 응답이 섞일 수 있습니다(백엔드가 쿠키/헤더로 사용자 구분을 한다면 더 위험). 이런 경우는 원칙적으로 다음 중 하나를 택해야 합니다.

  • cache: "no-store"로 사용자 데이터는 캐시하지 않기
  • 혹은 요청을 사용자별로 분리(헤더/쿠키를 포함한 변형을 명확히)하고, Next 캐시 전략을 보수적으로 설정

RSC는 서버에서 실행되기 때문에 “브라우저에서만 가능한 API를 써서” 우회하려다 window is not defined 같은 다른 문제로 이어지기도 합니다. 클라이언트/서버 경계를 정리하는 데는 Next.js RSC에서 window is not defined 해결법도 함께 참고하면 좋습니다.

라우트 레벨 설정으로 ‘정적 최적화’를 제어하기

데이터가 자주 바뀌는데도 페이지가 정적으로 굳어버린다면, 라우트 설정을 명시해 의도를 고정하는 것이 좋습니다.

// app/posts/page.tsx
export const dynamic = "force-dynamic";
// 또는
export const revalidate = 0; // 사실상 매 요청 재생성에 가깝게

export default async function PostsPage() {
  const res = await fetch("https://api.example.com/posts");
  const posts = await res.json();
  return <div>{posts.length}</div>;
}
  • dynamic = "force-dynamic": 정적 최적화를 피하고 매 요청 동적으로 처리
  • revalidate = 0: 캐시를 사실상 쓰지 않도록 유도(상황에 따라 의미가 달라질 수 있어, 명시적으로는 force-dynamic이 더 읽기 쉬운 편)

다만 이건 “페이지 렌더링” 관점이고, 내부 fetch 캐시와 섞이면 기대와 다르게 보일 수 있으니, 페이지 정책과 fetch 정책을 같이 설계하는 게 안전합니다.

클라이언트 전환에서만 stale이면 router.refresh()를 점검

서버에서 갱신은 되었는데, 클라이언트 라우팅으로 돌아올 때만 안 바뀐다면 Router Cache 쪽일 수 있습니다. 이 경우 mutation 이후에 router.refresh()로 RSC payload를 다시 가져오게 만들 수 있습니다.

// app/posts/new/PostForm.tsx (Client Component)
"use client";

import { useRouter } from "next/navigation";
import { createPost } from "../actions";

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

  return (
    <form
      action={async (fd) => {
        await createPost(fd);
        router.refresh(); // 서버 컴포넌트 재요청 유도
      }}
    >
      <input name="title" placeholder="title" />
      <button type="submit">create</button>
    </form>
  );
}
  • revalidateTag/revalidatePath서버 캐시 무효화
  • router.refresh()클라이언트가 서버 결과를 다시 가져오도록 트리거

둘은 역할이 다릅니다. “무효화는 했는데 화면이 그대로”라면 refresh가 빠졌을 가능성이 있고, “refresh는 했는데 데이터가 그대로”라면 무효화/캐시정책이 잘못됐을 가능성이 큽니다.

운영에서 디버깅할 때 자주 놓치는 것들

1) CDN 캐시가 앞단에서 잡고 있는지

Vercel을 쓰든, CloudFront를 쓰든, 응답 헤더(cache-control, age, x-cache 등)를 확인해서 Next.js 이전에서 캐시가 먹는지 먼저 확인하세요. 서버에서 아무리 revalidateTag를 해도 CDN이 오래 캐시하면 “안 바뀌는 것처럼” 보입니다.

2) 동일 URL인데 쿼리/헤더가 달라 캐시 키가 갈리는지

/api/posts?page=1/api/posts?page=01 같은 사소한 차이, 또는 헤더 유무에 따라 캐시가 갈리면 “어떤 경우만 갱신 안 됨” 같은 증상이 생깁니다.

3) 캐시 초기화로만 해결하려 하지 말 것

당장은 서버 재시작/배포로 해결되는 것처럼 보여도, 설계가 그대로면 다시 재발합니다. 캐시 문제는 대부분 “무효화 경로가 없다” 혹은 “캐시 키가 의도와 다르다”로 귀결됩니다.

이 관점은 인프라 문제에서도 똑같이 중요합니다. 예를 들어 디스크 용량이 안 줄어드는 문제도 단순히 재부팅으로 덮기보다, 열린 파일 핸들을 찾아 원인을 제거해야 재발이 줄어듭니다. 비슷한 문제 해결 사고방식으로는 리눅스 디스크 100%인데 용량이 안 줄 때 - deleted-but-open(lsof)도 참고할 만합니다.

추천하는 “안 꼬이게 만드는” 기본 규칙

실무에서 Next.js 14 RSC 데이터 갱신 이슈를 줄이려면, 아래 규칙을 팀 컨벤션으로 박아두는 게 효과가 좋습니다.

  1. 사용자별 데이터는 기본 cache: "no-store"
  2. 목록/상세처럼 공유 데이터는 next: { tags: [...] } + mutation에서 revalidateTag
  3. 화면 단위로만 묶을 수 있으면 revalidatePath를 보조로 사용
  4. 클라이언트 전환에서 stale이면 router.refresh()를 함께 고려
  5. 프로덕션에서만 발생하면 라우트 레벨 dynamic/revalidate를 명시

마무리

Next.js 14의 RSC 환경에서 “데이터가 갱신되지 않는다”는 문제는 대부분 캐시가 꼬인 게 아니라, 캐시 계층이 여러 개라서 원인 레이어를 잘못 짚은 것에 가깝습니다. fetch의 Data Cache, 라우트 캐시, 클라이언트 라우터 캐시, 그리고 CDN까지—각 레이어가 무엇을 캐시하는지 분리해서 보면 해결책은 의외로 단순해집니다.

  • 정말 최신이 필요하면 no-store
  • TTL이면 revalidate
  • mutation 즉시 반영이면 tags + revalidateTag
  • 화면 단위면 revalidatePath
  • 클라이언트 전환 stale이면 router.refresh()

이 조합을 의도적으로 설계하면, “가끔만 안 바뀌는” 가장 디버깅하기 싫은 종류의 버그를 크게 줄일 수 있습니다.