Published on

Next.js 14 RSC에서 fetch 캐시 꼬임 해결법

Authors

서버 컴포넌트(RSC)로 전환한 Next.js 14 프로젝트에서 가장 당황스러운 장애 중 하나가 “fetch 결과가 꼬인다”는 현상입니다. A 사용자가 봐야 할 데이터가 B 사용자 화면에 보이거나, 방금 갱신한 값이 계속 예전 값으로 남아 있거나, 페이지마다 데이터가 랜덤하게 섞여 보이는 식입니다. 대개는 Next.js의 Data Cache(서버 fetch 캐시), Full Route Cache(라우트 HTML 캐시), 그리고 **Request Memoization(동일 요청 내 dedupe)**가 동시에 작동하면서 생기는 착시/오해에서 출발합니다.

이 글에서는 Next.js 14 RSC에서 fetch 캐시가 “꼬인 것처럼 보이는” 전형적인 원인과, 사용자별 데이터/권한이 섞이지 않게 만드는 실전 해결책을 정리합니다. 운영에서 비슷한 장애를 겪었다면, 네트워크/인증 계층에서도 캐시가 엮일 수 있으니 Nginx 뒤 OAuth 콜백 302 무한리다이렉트 원인 같은 글도 함께 참고하면 좋습니다.

Next.js 14에서 “캐시”가 3겹으로 존재한다

RSC에서 데이터가 예상과 다르게 보이면, 먼저 어떤 캐시가 개입했는지 분리해야 합니다.

1) Request Memoization (요청 단위 중복 제거)

  • 같은 서버 요청(한 번의 렌더링 트랜잭션) 안에서 동일한 fetch(url, options) 호출은 dedupe 됩니다.
  • 이는 “요청 단위”라 사용자 간 섞임을 만들지는 않지만, 같은 렌더링에서 여러 컴포넌트가 호출할 때 의도와 다르게 한 번만 호출되어 값이 공유되는 것처럼 보일 수 있습니다.

2) Data Cache (서버 fetch 캐시)

  • Next.js는 서버에서 실행된 fetch 결과를 캐싱할 수 있습니다.
  • 특히 revalidatetags를 사용하면 “서버 데이터 캐시”가 적극적으로 동작합니다.
  • 핵심: 캐시 키는 URL + 옵션(일부) 기반이며, 여러분이 기대하는 “사용자 컨텍스트(쿠키/세션)”가 자동으로 키에 포함된다고 가정하면 사고가 납니다.

3) Full Route Cache (라우트 결과 캐시)

  • 페이지(라우트) 자체가 정적으로 판단되면 HTML/RSC payload가 캐시되어 다음 요청에 재사용될 수 있습니다.
  • 여기서 사용자별 데이터가 섞여 보이면, 사실상 페이지가 정적 처리된 것이 원인인 경우가 많습니다.

정리하면, “fetch 캐시 꼬임”이라고 부르는 현상은 실제로는 다음 중 하나입니다.

  • 사용자별 데이터인데 Data Cache에 올라가서 다른 사용자에게 재사용됨
  • 사용자별 데이터인데 라우트가 정적으로 캐시되어 재사용됨
  • 헤더/쿠키/인증이 다른데 캐시 키에 반영되지 않아 같은 응답으로 취급됨

재현되는 전형적인 문제 코드

아래는 흔한 실수 패턴입니다. 서버 컴포넌트에서 쿠키 기반 인증 API를 호출하지만, fetch 캐시가 개입해 사용자별로 분리되지 않는 상황을 만들 수 있습니다.

// app/dashboard/page.tsx (Server Component)
import { cookies } from "next/headers";

export default async function DashboardPage() {
  // 쿠키는 사용자별
  const cookie = cookies().toString();

  // 문제 포인트: fetch가 캐시되면, 사용자별 응답이 섞일 수 있음
  const res = await fetch("https://api.example.com/me", {
    headers: { cookie },
    // next: { revalidate: 60 } 같은 옵션이 있거나
    // 혹은 상위에서 정적 최적화가 일어나면 사고가 커짐
  });

  const me = await res.json();

  return (
    <main>
      <h1>{me.name}님의 대시보드</h1>
    </main>
  );
}

"쿠키를 헤더로 넣었으니 사용자별로 다르겠지"라고 생각하기 쉽지만, 캐시 키에 쿠키가 포함된다고 보장할 수 없습니다. (또한 프록시/CDN 계층에서 Vary: Cookie가 없으면 더 위험해집니다.)

해결 전략 1: 사용자별/권한별 데이터는 기본적으로 no-store

가장 안전하고 단순한 원칙:

  • 개인화 데이터, 권한이 개입하는 데이터, 결제/주문/프로필 등은 cache: 'no-store'
  • 캐시가 필요하면 “사용자 키가 포함된 별도 캐시 레이어(예: Redis)”로 설계
// app/dashboard/page.tsx
import { cookies } from "next/headers";

export default async function DashboardPage() {
  const cookie = cookies().toString();

  const res = await fetch("https://api.example.com/me", {
    headers: { cookie },
    cache: "no-store",
  });

  if (!res.ok) throw new Error("Failed to load me");
  const me = await res.json();

  return <h1>{me.name}님의 대시보드</h1>;
}

이 한 줄(cache: 'no-store')로 “다른 사용자에게 내 데이터가 보이는” 급의 사고를 상당수 차단할 수 있습니다.

no-store를 권장하는 이유

  • Next.js의 Data Cache는 앱 레벨 공유 캐시 성격이 강합니다.
  • 사용자별로 캐시를 안전하게 분리하려면, 캐시 키에 사용자 식별자/권한/스코프가 반드시 포함되어야 하는데, 이를 fetch 옵션만으로 완벽히 통제하기 어렵습니다.

해결 전략 2: 페이지 자체가 정적 처리되지 않게 막기

개인화 데이터가 섞이는 큰 원인 중 하나는 라우트가 정적으로 최적화되어 Full Route Cache가 켜지는 것입니다. 다음 중 하나로 라우트를 동적으로 고정하세요.

2-1) export const dynamic = 'force-dynamic'

// app/dashboard/page.tsx
export const dynamic = "force-dynamic";

import { cookies } from "next/headers";

export default async function DashboardPage() {
  const cookie = cookies().toString();
  const res = await fetch("https://api.example.com/me", {
    headers: { cookie },
    cache: "no-store",
  });
  const me = await res.json();
  return <h1>{me.name}</h1>;
}

2-2) export const revalidate = 0

  • 사실상 동적에 가깝게 동작하도록 유도합니다.
export const revalidate = 0;

개인화 페이지는 **라우트 캐시(HTML/RSC payload)**까지 꺼야 안전합니다.

해결 전략 3: 공용 데이터는 revalidate + tags로 “의도적으로” 캐시

반대로 공용 데이터(예: 공지사항, 상품 목록, 블로그 글 목록)는 캐시를 적극 활용하는 게 맞습니다. 중요한 건 사용자 컨텍스트가 없는 데이터만 캐시한다는 점입니다.

// app/(public)/products/page.tsx
export default async function ProductsPage() {
  const res = await fetch("https://api.example.com/products", {
    next: { revalidate: 300, tags: ["products"] },
  });

  if (!res.ok) throw new Error("Failed to load products");
  const products = await res.json();

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

그리고 관리자에서 상품을 수정했을 때는 태그 기반으로 무효화합니다.

// app/api/admin/products/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";

export async function POST() {
  revalidateTag("products");
  return NextResponse.json({ ok: true });
}

이 패턴은 “캐시가 꼬였다”가 아니라, 캐시를 설계대로 통제하는 방식입니다.

해결 전략 4: 인증 헤더/쿠키를 fetch에 섞을 때의 체크리스트

개인화 API를 호출해야 한다면 아래를 지키는 것이 안전합니다.

4-1) cache: 'no-store'를 기본값으로

  • 사용자별 응답이 섞이면 보안 사고입니다.

4-2) 중간 프록시/CDN의 캐시도 점검

  • API 응답에 Cache-Control: private, no-store 또는 최소한 private가 설정되어야 합니다.
  • 쿠키 기반이면 Vary: Cookie가 필요한 경우가 많습니다.
  • Nginx/CloudFront/ALB 등에서 Authorization/Cookie 헤더를 캐시 키에 포함하지 않으면 사고가 납니다.

인증/리다이렉트가 엮이면 증상이 더 난해해질 수 있는데, 이런 케이스는 OAuth redirect_uri mismatch 즉시 해결 체크리스트처럼 “겉증상은 캐시 같지만 실제는 인증 흐름 문제”인 경우도 함께 의심해야 합니다.

4-3) 서버 컴포넌트에서 쿠키를 읽는 순간 정적 최적화가 깨질 수도, 안 깨질 수도

  • cookies()/headers() 사용 여부, dynamic 설정, fetch 옵션에 따라 라우트 캐싱 여부가 달라집니다.
  • “나는 동적이라고 생각했는데 정적이었다”가 가장 흔한 함정입니다.

해결 전략 5: 서버 액션/뮤테이션 이후 즉시 반영이 안 되는 문제

RSC에서 글 작성/수정 후 목록이 바로 갱신되지 않아 “캐시가 꼬였다”고 느끼는 경우가 많습니다. 이때는 태그 무효화 또는 경로 무효화를 의도적으로 호출해야 합니다.

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

import { revalidatePath, revalidateTag } from "next/cache";

export async function updateProfile(formData: FormData) {
  // 1) DB 업데이트 수행 (예: await db.user.update...)

  // 2) 캐시 무효화
  revalidateTag("me");
  revalidatePath("/dashboard");
}

그리고 me를 가져오는 fetch는 태그를 붙여 둡니다.

// lib/api.ts
import { cookies } from "next/headers";

export async function fetchMe() {
  const cookie = cookies().toString();

  // 개인화 데이터라면 no-store가 가장 안전
  return fetch("https://api.example.com/me", {
    headers: { cookie },
    cache: "no-store",
    // 만약 사용자별 캐시를 정말 하고 싶다면 별도 캐시 계층 권장
    // next: { tags: ["me"] },
  });
}

“캐시 꼬임”을 줄이는 설계 원칙

1) 공용 데이터와 개인화 데이터를 컴포넌트 레벨에서 분리

  • 공용 데이터: revalidate/tags로 캐시
  • 개인화 데이터: no-store + 동적 라우트

예를 들어 상품 상세 페이지에서,

  • 상품 정보(공용)는 캐시
  • 내 장바구니/내 가격(개인화)은 no-store

처럼 분리하면 성능과 안전성을 동시에 잡습니다.

2) 캐시 키에 “사용자 컨텍스트”를 억지로 넣지 말고, 캐시를 끄는 게 낫다

  • ?userId= 같은 쿼리로 캐시를 분리하는 방식은 보안/로그/공유 링크 등에서 부작용이 큽니다.
  • 정말 필요하면 애플리케이션 캐시(예: Redis)에 userId:resource 형태로 명시적 키를 두세요.

3) 운영에서 관측 가능성을 올리기

  • 응답에 x-cache: HIT/MISS 같은 헤더를 붙이거나
  • API 서버에서 Cache-Control/Vary를 로깅
  • Next.js 서버 로그에 fetch 호출 파라미터(민감정보 제거 후)를 남기기

분산 환경에서 캐시/인증/네트워크가 동시에 얽히면 원인 규명이 길어집니다. EKS 같은 환경이라면 네트워크 계층 문제도 데이터 이상처럼 보일 수 있으니, 필요 시 EKS iptables-legacy/nft 충돌로 네트워크 먹통 해결처럼 인프라 이슈 가능성도 분리해서 접근하는 게 좋습니다.

실전 처방전(요약)

  1. 사용자별 데이터: fetch(..., { cache: 'no-store' }) + dynamic = 'force-dynamic'
  2. 공용 데이터: next: { revalidate, tags }로 캐시하고, 변경 시 revalidateTag
  3. 변경 후 즉시 반영: 서버 액션/route handler에서 revalidatePath/revalidateTag
  4. 프록시/CDN 점검: Cache-Control, Vary, 인증 헤더 캐시 키 포함 여부 확인

RSC의 fetch 캐시는 성능을 크게 올려주지만, “무엇을 캐시해도 되는가” 경계가 흐려지는 순간 데이터가 섞여 보이는 장애로 이어집니다. 개인화 데이터는 과감히 no-store로 고정하고, 공용 데이터만 의도적으로 캐시하는 쪽이 Next.js 14에서 가장 안전한 운영 패턴입니다.