Published on

Next.js 14 RSC 캐시 무효화로 데이터 꼬임 해결

Authors

서버 컴포넌트 기반(App Router)으로 전환한 뒤 가장 당황스러운 문제 중 하나가 데이터가 꼬이는 현상입니다. 예를 들면

  • 글을 수정했는데 목록에서는 예전 제목이 계속 보임
  • A 사용자가 본 대시보드 숫자가 B 사용자 화면에도 잠깐 섞여 보임
  • 결제/주문처럼 즉시성이 중요한 화면에서 갱신이 늦거나 불규칙함

이런 증상은 대개 Next.js 14의 RSC 캐시(라우트 캐시) + Data Cache(fetch 캐시) + CDN/프록시 캐시가 한꺼번에 얽힐 때 발생합니다. 특히 “SSR로 매번 새로 렌더링된다”는 감각으로 코드를 작성하면, 실제 런타임에서는 조용히 캐싱되어 의도치 않은 재사용이 일어납니다.

이 글에서는 Next.js 14에서 RSC 캐시가 어떻게 동작하는지, 어떤 조합에서 데이터가 꼬이는지, 그리고 무효화(revalidate) 설계를 어떻게 잡아야 안전한지를 재현 코드와 함께 정리합니다.

1) Next.js 14에서 ‘캐시’가 여러 겹인 이유

App Router에서 페이지는 기본적으로 **서버 컴포넌트(RSC)**로 렌더링됩니다. 이때 Next.js는 성능을 위해 다양한 캐시 레이어를 둡니다.

1-1. 대표 캐시 레이어 3가지

  1. RSC/Route Cache
  • 특정 라우트 세그먼트의 RSC payload를 재사용
  • 정적 렌더링(Static)으로 판단되면 더 적극적으로 캐싱
  1. Data Cache (fetch 캐시)
  • 서버에서 실행한 fetch() 결과를 캐싱
  • fetch() 옵션에 따라 force-cache, no-store, revalidate 등이 적용
  1. 외부 캐시(CDN, Reverse Proxy, 브라우저)
  • Cache-Control 헤더, CDN 정책에 따라 별도 캐싱

문제는 “나는 fetch만 신경 썼는데” 실제로는 라우트 자체가 캐시되어 fetch가 다시 호출되지 않는 경우가 있고, 반대로 “라우트는 동적이라 생각했는데” fetch 결과가 캐시되어 데이터만 낡는 경우가 생긴다는 점입니다.

1-2. 데이터 꼬임의 전형적인 패턴

  • 사용자별 데이터를 서버 컴포넌트에서 가져오는데 fetch가 캐시되어 사용자 간 값이 섞임
  • 쓰기 작업(POST/PUT/DELETE) 이후 무효화를 하지 않아 목록/상세가 서로 다른 시점의 데이터를 보여줌

이건 DB 트랜잭션 문제처럼 보이기도 해서 원인 추적이 더 어렵습니다. (트랜잭션 자체 이슈가 의심될 때는 Spring Boot 3.2에서 @Transactional 무시되는 7가지 같은 체크리스트도 병행하면 좋습니다.)

2) 재현: 수정했는데 목록이 안 바뀌는 케이스

가장 흔한 “꼬임”은 쓰기 후 읽기 화면이 서로 다른 캐시를 보고 있는 상황입니다.

2-1. 예시 구조

  • /posts 목록
  • /posts/[id] 상세
  • /posts/[id]/edit 수정

목록과 상세는 RSC에서 데이터를 읽고, 수정은 Server Action 또는 Route Handler로 처리합니다.

app/posts/page.tsx

import Link from "next/link";

async function getPosts() {
  const res = await fetch("https://example.com/api/posts", {
    // 기본값이 캐시로 동작할 수 있음
    // next: { revalidate: 60 } 같은 설정이 들어가면 60초 동안 낡은 데이터가 유지됨
  });
  if (!res.ok) throw new Error("failed");
  return res.json() as Promise<Array<{ id: string; title: string }>>;
}

export default async function PostsPage() {
  const posts = await getPosts();
  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((p) => (
          <li key={p.id}>
            <Link href={`/posts/${p.id}`}>{p.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

app/posts/[id]/page.tsx

async function getPost(id: string) {
  const res = await fetch(`https://example.com/api/posts/${id}`);
  if (!res.ok) throw new Error("failed");
  return res.json() as Promise<{ id: string; title: string; body: string }>;
}

export default async function PostDetailPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);
  return (
    <main>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </main>
  );
}

app/posts/[id]/edit/actions.ts

"use server";

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

이 상태에서 수정하면, 상세는 새로고침 시 바뀌는데 목록은 한참 뒤에야 바뀌거나(재검증 타이밍), 어떤 환경에서는 계속 안 바뀌는 것처럼 보일 수 있습니다.

3) 해결의 핵심: “무효화 단위”를 명확히 잡기

Next.js에서 캐시 무효화는 크게 두 축입니다.

  • 시간 기반: revalidate: n 또는 export const revalidate = n
  • 이벤트 기반: revalidatePath(), revalidateTag()

쓰기 작업이 있는 앱(게시판, 주문, 재고, 사용자 설정 등)은 보통 이벤트 기반 무효화가 안전합니다.

4) revalidatePath로 라우트 캐시를 확실히 깨기

쓰기 이후에 “이 변경이 반영되어야 하는 화면”을 경로 기준으로 무효화합니다.

4-1. Server Action에서 revalidatePath 적용

app/posts/[id]/edit/actions.ts

"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

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

  // 상세 페이지와 목록 페이지 모두 무효화
  revalidatePath(`/posts/${id}`);
  revalidatePath("/posts");

  redirect(`/posts/${id}`);
}

이렇게 하면 수정 직후 사용자가 /posts로 돌아가도 목록이 낡아 있을 가능성이 크게 줄어듭니다.

4-2. 주의: “무효화 범위를 과도하게 키우지 말기”

revalidatePath("/") 같은 식으로 루트 전체를 무효화하면 간단해 보이지만, 트래픽이 커질수록 서버 부하가 튈 수 있습니다. 변경 영향 범위를 기능 단위로 좁혀서 무효화하는 것이 좋습니다.

5) revalidateTag로 데이터 캐시를 정교하게 무효화

revalidatePath는 라우트 기준, revalidateTag는 데이터 기준으로 더 세밀하게 설계할 수 있습니다.

5-1. fetch에 tag를 붙이기

app/posts/data.ts

export async function getPosts() {
  const res = await fetch("https://example.com/api/posts", {
    next: { tags: ["posts"] },
  });
  if (!res.ok) throw new Error("failed");
  return res.json() as Promise<Array<{ id: string; title: string }>>;
}

export async function getPost(id: string) {
  const res = await fetch(`https://example.com/api/posts/${id}`, {
    next: { tags: [`post:${id}`, "posts"] },
  });
  if (!res.ok) throw new Error("failed");
  return res.json() as Promise<{ id: string; title: string; body: string }>;
}

5-2. 쓰기 이후 tag 무효화

app/posts/[id]/edit/actions.ts

"use server";

import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";

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

  // 해당 글 + 목록 관련 캐시를 데이터 기준으로 무효화
  revalidateTag(`post:${id}`);
  revalidateTag("posts");

  redirect(`/posts/${id}`);
}

이 방식의 장점은 /posts 외에도 같은 태그를 쓰는 다른 화면(예: 홈의 최신 글 위젯)이 있다면 함께 일관되게 갱신된다는 점입니다.

6) 사용자별 데이터가 섞이는 문제: 무조건 no-store로 고정하기

“사용자별 대시보드”나 “내 정보” 같은 화면은 캐시가 들어가면 안 됩니다. 특히 쿠키/세션 기반 인증을 쓰면서 서버에서 fetch로 백엔드를 호출할 때, 캐시가 걸리면 A의 응답이 B에게 재사용될 여지가 생깁니다.

6-1. 서버 컴포넌트에서 사용자별 데이터 fetch

app/dashboard/page.tsx

import { cookies } from "next/headers";

async function getMyDashboard() {
  const token = cookies().get("access_token")?.value;

  const res = await fetch("https://example.com/api/me/dashboard", {
    headers: {
      Authorization: `Bearer ${token}`,
    },
    cache: "no-store",
  });

  if (!res.ok) throw new Error("failed");
  return res.json() as Promise<{ revenue: number; orders: number }>;
}

export default async function DashboardPage() {
  const data = await getMyDashboard();
  return (
    <main>
      <h1>Dashboard</h1>
      <p>revenue: {data.revenue}</p>
      <p>orders: {data.orders}</p>
    </main>
  );
}

핵심은 cache: "no-store" 입니다. 사용자별 응답은 “성능보다 정합성”이 우선인 경우가 대부분이고, 캐시를 쓰더라도 별도 키 전략이 필요합니다.

6-2. 페이지 단에서 강제로 동적 처리하기

여러 데이터 소스가 섞여 “어떤 fetch는 no-store인데도 뭔가가 캐시되는 것 같다”는 느낌이 들면, 라우트 자체를 동적으로 고정하는 것도 방법입니다.

export const dynamic = "force-dynamic";

다만 이 설정은 라우트 전체의 캐싱 이점을 포기하므로, 정말 사용자별 데이터가 중심인 페이지에서만 쓰는 편이 좋습니다.

7) 쓰기 직후 화면이 순간적으로 옛값을 보여주는 이유

Next.js의 무효화는 “지금 보고 있는 화면의 즉시 DOM을 바꿔준다”가 아니라, 다음 요청부터 새로 만들어지도록 캐시를 깨는 작업에 가깝습니다.

그래서 UX 관점에서 쓰기 직후에는 아래를 함께 고려해야 합니다.

  • 수정 완료 후 redirect()로 상세로 보내기
  • 클라이언트 컴포넌트에서 optimistic update를 하되, 서버 무효화와 함께 사용
  • 목록 페이지에서 router.refresh()를 적절히 사용

router.refresh()는 현재 라우트의 RSC를 다시 가져오도록 유도합니다. 다만 근본 해결은 무효화 설계이며, refresh()는 보조 수단으로 보는 게 안전합니다.

8) 운영에서 자주 하는 실수 체크리스트

8-1. 사용자별 fetch에 cache: "no-store"를 빼먹음

  • Authorization 헤더가 있으면 더더욱 캐시 금지

8-2. 목록은 tag, 상세는 path로 따로 무효화해서 정합성이 깨짐

  • 가능하면 목록/상세가 같은 데이터 도메인이라면 tag 중심으로 통일

8-3. API 뒤에 CDN 캐시가 남아 있음

  • Next.js 쪽 무효화는 Next.js 캐시만 다룹니다
  • API 응답에 Cache-Control이 어떻게 나가고 있는지 확인해야 합니다

인프라 단 캐시/라우팅 문제는 증상이 애매하게 나타나서 앱 캐시 문제로 착각하기 쉽습니다. 예를 들어 인그레스/ALB 레벨에서 비정상 상태가 섞이면 “어떤 요청만 옛날 데이터”처럼 보일 수 있습니다. 이런 경우엔 EKS Ingress 503인데 Pod 정상일 때 점검 가이드 같은 관점으로 네트워크 레이어도 함께 점검하는 게 좋습니다.

9) 권장 설계: 데이터 도메인별 tag + 쓰기 시 tag 무효화

실무에서 가장 관리하기 쉬운 패턴은 아래입니다.

  • 읽기 fetchnext: { tags: [...] }를 붙인다
  • 쓰기(Server Action/Route Handler)에서 영향을 받는 tag를 revalidateTag()로 무효화한다
  • 사용자별 데이터는 cache: "no-store"로 고정한다

9-1. 도메인 tag 예시

  • 게시글 목록: posts
  • 게시글 단건: post:${id}
  • 댓글 목록: comments:post:${id}
  • 사용자 프로필: user:${userId} (단, 사용자별이면 no-store 고려)

10) 정리

Next.js 14에서 데이터 꼬임을 잡는 요령은 “캐시를 끄자”가 아니라, 어떤 데이터가 어떤 단위로 캐시되고, 어떤 이벤트에서 무효화되어야 하는지를 명확히 정의하는 것입니다.

  • 사용자별 데이터는 cache: "no-store"
  • 쓰기 이후에는 revalidatePath() 또는 revalidateTag()로 이벤트 기반 무효화
  • 가능하면 tag 전략으로 도메인 단위 정합성을 맞추기

이 3가지만 일관되게 적용해도 “가끔 옛날 데이터가 보인다” 같은 불신을 크게 줄일 수 있고, RSC의 성능 이점도 안전하게 가져갈 수 있습니다.