Published on

Next.js 14 App Router 캐시 꼬임 해결법

Authors

서버 컴포넌트 기반의 Next.js 14 App Router는 기본값이 “최대한 캐시” 쪽으로 기울어 있습니다. 이 덕분에 성능은 좋아지지만, 운영에서 흔히 “캐시가 꼬였다”라고 느끼는 증상(데이터가 안 바뀜, 다른 유저 데이터가 섞임, 특정 페이지는 갱신되는데 다른 페이지는 고정됨)이 발생합니다.

이 글은 감으로 no-store를 남발하기보다, **어떤 캐시가 어디서 생기고(레이어), 왜 꼬이는지(원인), 어떻게 끊어야 하는지(해결 패턴)**를 실전 관점에서 정리합니다.


1) “캐시 꼬임”이란 무엇인가: 증상부터 분류

App Router에서 꼬임으로 관측되는 현상은 보통 아래 중 하나입니다.

1.1 데이터가 갱신되지 않는다(영원히 옛날 값)

  • CMS/DB 업데이트 후에도 페이지가 그대로
  • 배포 후에도 특정 API 응답이 이전 값

1.2 일부만 갱신된다(페이지 A는 최신, 페이지 B는 구버전)

  • 같은 데이터를 쓰는데 어떤 라우트만 갱신
  • 리스트는 최신인데 상세는 구버전(또는 반대)

1.3 유저별 데이터가 섞인다(가장 위험)

  • 로그인 A로 보고 있던 페이지가 로그인 B의 데이터처럼 보임
  • cookies()/headers()를 쓰는데도 캐시가 공유되는 느낌

이 3가지는 원인이 다릅니다. 해결도 다르게 접근해야 합니다.


2) Next.js 14 App Router 캐시 레이어 3종 세트

“캐시”라고 뭉뚱그리면 해결이 어려워집니다. App Router에서는 크게 다음 레이어가 겹칩니다.

2.1 Data Cache (fetch 캐시)

  • 서버에서 fetch() 호출 결과를 Next가 캐싱
  • next: { revalidate }, next: { tags }, cache: 'no-store'로 제어

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

  • RSC 렌더 결과(라우트 결과)를 캐시
  • 정적 렌더링(Static)으로 판단되면 강하게 캐시됨
  • 동적 요소(cookies(), headers(), searchParams 사용 등)가 있으면 동적 렌더로 전환

2.3 Router Cache (클라이언트 라우터 캐시)

  • 클라이언트 내비게이션 시, 이미 방문한 경로의 RSC payload를 재사용
  • router.refresh()로 갱신 가능

운영에서 “꼬임”으로 보이는 건 보통 2.1과 2.2가 엇갈리거나, 2.1이 의도치 않게 공유되거나, 2.3이 갱신 트리거를 못 받는 상황입니다.


3) 가장 흔한 원인 7가지 (체크리스트)

원인 1) fetch() 기본 캐시를 모르고 사용

서버 컴포넌트에서 아래처럼 쓰면, Next는 가능한 한 캐시하려고 합니다.

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

이때 API 응답이 바뀌어도 페이지가 그대로일 수 있습니다.

원인 2) “유저별 데이터”를 캐시해버림

아래는 매우 위험한 패턴입니다.

// 잘못된 예: 쿠키 기반 사용자 정보를 받아오는데 fetch가 캐시되면 섞일 수 있음
const res = await fetch('https://api.example.com/me', {
  headers: { cookie: cookies().toString() },
});

유저별 응답은 기본적으로 no-store 혹은 요청마다 다른 캐시 키가 필요합니다.

원인 3) revalidatePath/revalidateTag를 “어디서든” 호출 가능하다고 착각

  • revalidatePath, revalidateTag서버 액션 / 라우트 핸들러 같은 서버 실행 컨텍스트에서 주로 사용
  • 호출 위치가 잘못되면 갱신이 안 되거나, 기대와 다르게 동작

원인 4) 태그(tag) 설계를 안 함 (혹은 라우트/데이터 태그 불일치)

  • 리스트와 상세가 같은 데이터를 보는데 태그가 다르면 한쪽만 갱신

원인 5) CDN/프록시 캐시가 한 겹 더 있음

  • Vercel/CloudFront/NGINX 등에서 HTML이나 API 응답을 캐시
  • Next 내부 캐시를 잘 끊었는데도 외부 캐시가 남아 “안 바뀌는 것처럼” 보임

원인 6) dynamic = 'force-static' / force-dynamic의 오남용

  • 문제를 덮기 위해 force-dynamic을 남발하면 비용/지연이 증가
  • 반대로 force-static이 들어가 있으면 갱신이 막힐 수 있음

원인 7) 클라이언트 라우터 캐시 갱신 트리거 누락

  • 폼 제출/뮤테이션 후 화면이 그대로라서 “캐시 꼬임”으로 보임
  • 실제로는 서버는 최신인데 클라이언트가 refresh를 안 함

4) 해결 패턴 1: 유저별/세션별 데이터는 no-store로 고정

유저별 데이터는 캐시 이득보다 정확성이 우선입니다.

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

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

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

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

  return <div>hello, {me.name}</div>;
}

추가로, 페이지 단위로도 명시할 수 있습니다.

// app/(dashboard)/page.tsx
export const dynamic = 'force-dynamic';

다만 force-dynamic은 라우트 전체를 동적으로 만들어 비용이 커질 수 있으니, 가능하면 데이터 fetch에서만 no-store로 끊는 쪽이 안전합니다.


5) 해결 패턴 2: “자주 바뀌는 공용 데이터”는 revalidate로 TTL을 둔다

예: 상품 리스트는 60초 정도 stale 허용 가능.

// app/products/page.tsx
export default async function ProductsPage() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 },
  });

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

이 패턴은 “갱신은 느리지만 안정적”입니다. 다만 운영에서 즉시 반영이 필요하면 다음의 태그 기반 무효화가 더 적합합니다.


6) 해결 패턴 3: 태그 기반 캐시 무효화로 “즉시 반영” 만들기

핵심은 읽기(fetch)에 tags를 붙이고, 쓰기(뮤테이션) 후 revalidateTag를 호출하는 것입니다.

6.1 읽기: fetch에 tags 부여

// lib/api.ts
export async function fetchProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: { tags: ['products'] },
  });
  if (!res.ok) throw new Error('Failed');
  return res.json();
}

export async function fetchProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { tags: [`product:${id}`, 'products'] },
  });
  if (!res.ok) throw new Error('Failed');
  return res.json();
}
  • 리스트는 products
  • 상세는 product:${id}products 둘 다
  • 이렇게 하면 상세 수정 시 상세/리스트 동시 갱신이 쉬워집니다.

6.2 쓰기: 서버 액션에서 revalidateTag 호출

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

import { revalidateTag } from 'next/cache';

export async function updateProduct(id: string, payload: any) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    method: 'PUT',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(payload),
  });

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

  // 상세/리스트를 모두 무효화
  revalidateTag(`product:${id}`);
  revalidateTag('products');

  return res.json();
}

이 방식은 “TTL 기다리기”가 아니라 변경 이벤트 기반으로 즉시 반영됩니다.


7) 해결 패턴 4: revalidatePath는 “페이지 단위”로만 정확히 쓰기

태그 설계가 어렵거나, 특정 라우트 결과만 확실히 날리고 싶으면 revalidatePath가 직관적입니다.

'use server';

import { revalidatePath } from 'next/cache';

export async function createProduct(payload: any) {
  const res = await fetch('https://api.example.com/products', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error('Create failed');

  // 리스트 페이지 결과를 무효화
  revalidatePath('/products');
  return res.json();
}

주의할 점:

  • revalidatePath('/products')해당 경로의 라우트 캐시(Full Route Cache) 관점에서 이해해야 합니다.
  • 같은 데이터라도 다른 경로에서 보여주면 그 경로도 함께 무효화해야 합니다.

8) 해결 패턴 5: “클라이언트에서 갱신이 안 되는 것처럼 보일 때”

서버는 이미 최신인데 화면만 안 바뀌는 경우가 있습니다. 뮤테이션 후 router.refresh()를 호출하면 Router Cache가 갱신됩니다.

'use client';

import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
import { updateProduct } from './actions';

export function SaveButton({ id }: { id: string }) {
  const router = useRouter();
  const [pending, startTransition] = useTransition();

  return (
    <button
      disabled={pending}
      onClick={() =>
        startTransition(async () => {
          await updateProduct(id, { name: 'new name' });
          router.refresh();
        })
      }
    >
      Save
    </button>
  );
}

이걸 안 하면 “서버 액션은 성공했는데 UI가 그대로”라서 캐시 꼬임처럼 느껴집니다.


9) 운영에서의 진단법: “어느 레이어 캐시가 남았나”를 확인

9.1 헤더/응답을 먼저 본다

  • API 응답이 CDN에서 캐시되는지 확인: cache-control, age, x-cache
  • Next 내부 캐시만 끊었는데도 안 바뀌면 프록시/CDN 캐시일 가능성이 큼

9.2 재현 시나리오를 최소화한다

  • 같은 요청을 3번 반복: 새로고침/시크릿/다른 계정
  • “유저 섞임”은 대부분 no-store 미적용이거나 캐시 키가 공유되는 구조

9.3 백엔드 호출 지연/타임아웃이 캐시 문제로 오인되는 경우

캐시가 꼬인 게 아니라, 백엔드가 느려져서 이전 화면(클라 캐시)만 보이는 경우도 있습니다. 특히 Redis/네트워크 타임아웃이 끼면 증상이 복잡해집니다. 인프라 레벨에서 타임아웃을 먼저 제거해야 정확한 캐시 진단이 됩니다.


10) “꼬임”을 예방하는 권장 설계 (실전 규칙)

10.1 데이터 성격별로 캐시 정책을 고정

  • 유저별/권한별: cache: 'no-store' (필요 시 페이지 dynamic)
  • 공용이지만 자주 변함: next: { revalidate: n } 또는 tags + revalidateTag
  • 거의 안 변함: 정적(강캐시) + 배포 시점에만 갱신

10.2 태그 네이밍 규칙을 팀 표준으로

예시:

  • 컬렉션: products
  • 엔티티: product:${id}
  • 조인/뷰: products:featured, products:category:${slug}

이렇게 해두면 “어디를 무효화해야 하지?”가 줄어듭니다.

10.3 no-store 남발을 피하되, 위험한 곳에는 과감히 적용

no-store는 비용이 있지만, 유저 데이터 섞임 리스크는 더 비쌉니다.


11) 마무리: 가장 빠른 처방전

운영에서 당장 적용할 “응급 처방”을 정리하면 아래 순서가 실패 확률이 낮습니다.

  1. 유저별 데이터 fetch에 cache: 'no-store' 적용 (섞임 방지)
  2. 공용 데이터는 tags를 붙이고 revalidateTag로 즉시 무효화
  3. 뮤테이션 후 화면이 안 바뀌면 router.refresh() 추가
  4. 그래도 안 바뀌면 CDN/프록시 캐시 헤더 확인

이 4단계를 통과하면 “캐시 꼬임”의 대부분은 구조적으로 해결됩니다.