Next.js 16 cacheComponents: 91,000개 페이지를 App Router 캐싱에서 마이그레이션
대규모 전자상거래 카탈로그를 Next.js 14의 App Router에서 약 18개월간 운영 중이었을 때 Next.js 16이 출시되었습니다. 91,247개 페이지. 상품 목록, 카테고리 트리, 편집 콘텐츠, 14개 시장에 걸친 지역화된 변형들이었습니다. 기존 캐싱 모델 -- Server Components가 기본적으로 캐시되던 모델 -- 은 스테일 데이터 버그와 revalidateTag 스파게티의 지뢰밭이 되어 있었습니다. Next.js 팀이 cacheComponents를 발표하고 Next.js 15에서 기본 캐싱 없음으로의 전환을 발표했을 때(v16에서 계속 개선됨), 우리는 이제 해야 할 때라는 것을 알았습니다. 이것이 그 마이그레이션의 이야기입니다: 무엇이 작동했고, 무엇이 작동하지 않았으며, 다른 쪽의 성능 수치입니다.
목차
- 우리가 실제로 겪은 캐싱 문제
- Next.js 15와 16에서 변경된 사항
- cacheComponents 이해하기
- 91,000개 페이지를 위한 마이그레이션 전략
- 구현: 단계별
- 성능 결과 및 벤치마크
- 함정 및 주의사항
- cacheComponents를 사용해야 하는 경우와 사용하지 말아야 하는 경우
- FAQ

우리가 실제로 겪은 캐싱 문제
상황을 그려보겠습니다. Next.js 14의 App Router에서 Server Components의 fetch 요청은 기본적으로 캐시되었습니다. Data Cache는 배포 간에 지속되었습니다. Full Route Cache는 빌드 시 렌더링된 HTML과 RSC 페이로드를 저장했습니다. 그리고 클라이언트 측의 Router Cache는 프리페치된 세그먼트를 ... 글쎄, 예상보다 더 오래 유지했습니다.
91,000개 페이지가 있는 사이트의 경우, 이 기본 캐시-모든-것 접근 방식은 두 가지 카테고리의 문제를 만들었습니다:
곳곳의 스테일 데이터. 우리의 헤드리스 CMS(우리의 경우 Sanity)에서 상품 가격이 업데이트되었지만 캐시된 fetch 결과는 그대로 남아있었습니다. 47개의 다른 서버 액션에 흩어진 revalidateTag 호출이 있었습니다. 하나의 태그를 놓치면? 고객은 어제의 가격을 봅니다. 우리는 실제로 콘텐츠 팀이 스테일 페이지를 보고하는 #cache-crimes라는 Slack 채널을 가지고 있었습니다.
지옥의 빌드 시간. 91,000개 페이지의 완전한 정적 생성에는 3시간 이상이 소요되었습니다. 대부분의 페이지에 대해 revalidate: 3600으로 ISR로 이동했지만, ISR, Data Cache, 온디맨드 재검증 간의 상호작용은 정말로 추론하기 어려웠습니다. 팀의 새로운 개발자들은 캐싱 레이어를 이해하기 위해 처음 2주를 보내곤 했습니다.
정신 모델 비용
여기서 사람들이 과소평가하는 것이 있다고 생각합니다: 암시적 캐싱의 인지 비용입니다. 캐싱이 기본값이고 선택을 해제할 때, 모든 새로운 컴포넌트는 "이것을 캐시해야 할까?"를 요청하고 답이 아니면 올바른 지시문을 추가하는 것을 기억해야 합니다. 기본값이 캐싱 없음이고 선택할 때, 당신은 적극적으로 원할 때만 캐싱에 대해 생각합니다. 그것은 근본적으로 다르고 -- 더 좋은 -- 정신 모델입니다.
Next.js 15와 16에서 변경된 사항
Next.js 15는 큰 철학적 전환이었습니다. 팀은 기본값을 뒤집었습니다:
| 동작 | Next.js 14 | Next.js 15 | Next.js 16 |
|---|---|---|---|
Server Components의 fetch() |
기본적으로 캐시됨 | 기본적으로 캐시되지 않음 | 기본적으로 캐시되지 않음 |
| Route Handlers (GET) | 기본적으로 캐시됨 | 기본적으로 캐시되지 않음 | 기본적으로 캐시되지 않음 |
| Client Router Cache | 30s (동적) / 5min (정적) | 페이지 세그먼트의 0s | 0s 기본값, 구성 가능 |
| Full Route Cache | 정적 경로에 대해 활성화됨 | 동일 | cacheLife 개선 사항과 함께 동일 |
| 컴포넌트 수준 캐싱 | unstable_cache |
use cache 지시문 (실험) |
cacheComponents API (안정) |
Next.js 15는 플래그 뒤의 실험적 기능으로 use cache 지시문을 도입했습니다. Next.js 16은 2025년 초에 출시되었으며, 이를 cacheComponents 구성 옵션 및 관련 "use cache" 지시문으로 안정화했습니다. cacheLife는 사용자 정의 캐시 프로필을 정의하기 위해, cacheTag는 타겟 무효화를 위해 사용됩니다.
핵심 통찰: 캐싱은 암시적 프레임워크 동작에서 컴포넌트 수준의 명시적 개발자 선택으로 이동했습니다. 이것은 대규모 사이트에 있어서 큰 거래입니다.
cacheComponents 이해하기
next.config.js의 cacheComponents 기능은 "use cache" 지시문을 통해 컴포넌트 수준 캐싱을 활성화합니다. 기본 설정은 다음과 같습니다:
// next.config.js (Next.js 16)
const nextConfig = {
experimental: {
cacheComponents: true,
},
};
module.exports = nextConfig;
활성화되면, 모든 비동기 Server Component, 서버 액션 또는 레이아웃 파일의 맨 위에 "use cache"를 추가할 수 있습니다:
// app/products/[slug]/page.tsx
"use cache";
import { cacheLife, cacheTag } from 'next/cache';
export default async function ProductPage({ params }: { params: { slug: string } }) {
cacheLife('products'); // 사용자 정의 캐시 프로필
cacheTag(`product-${params.slug}`);
const product = await fetchProduct(params.slug);
return (
<div>
<h1>{product.name}</h1>
<ProductDetails product={product} />
<DynamicPricing productId={product.id} /> {/* 이 컴포넌트는 캐시되지 않음 */}
</div>
);
}
cacheLife 프로필
이것이 대규모 사이트에 있어서 흥미로운 부분입니다. next.config.js에서 명명된 캐시 프로필을 정의합니다:
const nextConfig = {
experimental: {
cacheComponents: true,
cacheLife: {
products: {
stale: 300, // 5분 동안 스테일 제공
revalidate: 3600, // 1시간 후 재검증
expire: 86400, // 24시간 후 하드 만료
},
editorial: {
stale: 3600,
revalidate: 86400,
expire: 604800, // 7일
},
navigation: {
stale: 86400,
revalidate: 604800,
expire: 2592000, // 30일
},
},
},
};
3계층 모델(stale, revalidate, expire)은 스테일-while-재검증 의미론에 잘 매핑됩니다. stale 창 동안 캐시된 콘텐츠가 즉시 제공됩니다. stale 이후이지만 expire 이전에, 백그라운드 재검증이 시작됩니다. expire 이후, 캐시 항목이 없어집니다.
무효화를 위한 cacheTag
cacheTag 함수는 이전의 revalidateTag 패턴을 더 구성 가능한 것으로 대체합니다:
import { revalidateTag } from 'next/cache';
// 웹훅 핸들러 또는 서버 액션에서:
export async function handleProductUpdate(productSlug: string) {
revalidateTag(`product-${productSlug}`);
revalidateTag('product-listing'); // 목록 페이지도 무효화
}
이 부분은 Next.js 15에서 많이 변경되지 않았지만, cacheComponents와 함께 작동할 때 훨씬 더 잘 작동합니다. 왜냐하면 불명확한 프레임워크 수준 캐시를 무효화하려고 시도하는 대신 특정 캐시된 컴포넌트에 태그를 지정하기 때문입니다.

91,000개 페이지를 위한 마이그레이션 전략
우리는 이것을 한 번에 하지 않았습니다. 14개의 로캘에 걸쳐 91,000개의 페이지가 있으면, 전격 마이그레이션은 무모했을 것입니다. 우리가 이것을 어떻게 나누었는지 여기 있습니다:
단계 1: Next.js 16으로 업그레이드, 캐시 변경 없음 (1-2주)
캐시 구성 요소를 활성화하지 않고 Next.js 14.2에서 16.0으로 업그레이드했습니다. 이것만으로 동작을 변경했습니다. 왜냐하면 fetch 요청이 더 이상 기본적으로 캐시되지 않기 때문입니다. 우리는 TTFB 회귀를 예상했고 우리는 그것을 얻었습니다:
- 평균 TTFB는 180ms에서 340ms로 증가 (상품 페이지)
- 원점 서버 부하 약 60% 증가 (우리의 Sanity CDN은 잘 견뎠지만, 우리의 사용자 정의 API 엔드포인트는 그렇지 않았습니다)
- ISR 재검증 실제로 더 빨라짐 (캐시 상태를 관리할 것이 적었기 때문에)
이것은 우리가 의심했던 것을 확인했습니다: 우리는 암시적 캐싱에 무겁게 의존하고 있었으며, 많은 페이지가 실제로 캐싱이 필요했습니다 -- 단지 명시적이고 의도적인 캐싱입니다.
단계 2: 감사 및 페이지 분류 (3주)
우리는 우리 앱의 모든 경로를 분류했습니다:
| 페이지 유형 | 수 | 캐시 전략 | cacheLife 프로필 |
|---|---|---|---|
| 상품 상세 페이지 | 42,000 | 상품 태그로 캐시 | products (5분 스테일 / 1시간 재검증) |
| 카테고리 목록 페이지 | 3,200 | 카테고리 태그로 캐시 | products (5분 스테일 / 1시간 재검증) |
| 편집/블로그 페이지 | 8,400 | 공격적으로 캐시 | editorial (1시간 스테일 / 24시간 재검증) |
| 지역화된 변형 | 31,647 | 기본 페이지와 동일 | 기본에서 상속됨 |
| 계정/동적 페이지 | 6,000 | 캐시 없음 | 해당 없음 |
단계 3: cacheComponents 활성화, 지시문 추가 (4-6주)
플래그를 활성화하고 "use cache" 지시문을 추가하기 시작했습니다. 핵심 결정: 대부분의 경로에 대해 페이지 수준에서 캐시했지만, 정적/동적 콘텐츠가 혼합된 페이지의 경우 컴포넌트 수준에서 캐시했습니다.
상품 페이지의 경우, 상품 정보와 이미지는 캐시되었지만 가격 지정 컴포넌트와 재고 상태는 캐시되지 않았습니다:
// components/ProductInfo.tsx
"use cache";
import { cacheLife, cacheTag } from 'next/cache';
export async function ProductInfo({ slug }: { slug: string }) {
cacheLife('products');
cacheTag(`product-${slug}`, 'product-info');
const product = await getProduct(slug);
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductImages images={product.images} />
</section>
);
}
// components/DynamicPricing.tsx
// "use cache" 지시문 없음 -- 항상 최신
export async function DynamicPricing({ productId }: { productId: string }) {
const pricing = await getPricing(productId); // 매 요청마다 가격 API 히트
return (
<div className="pricing">
<span className="price">${pricing.current}</span>
{pricing.onSale && <span className="was-price">${pricing.original}</span>}
</div>
);
}
단계 4: 웹훅 통합 (7주)
우리는 우리의 Sanity 웹훅을 올바른 태그로 revalidateTag를 호출하도록 다시 연결했습니다. 이것은 실제로 우리의 이전 설정보다 더 간단했습니다. 왜냐하면 태그가 이제 코드에서 명시적이었기 때문에, fetch 옵션에 흩어져 있지 않았습니다.
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
const secret = request.headers.get('x-webhook-secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
switch (body._type) {
case 'product':
revalidateTag(`product-${body.slug.current}`);
revalidateTag('product-listing');
break;
case 'category':
revalidateTag(`category-${body.slug.current}`);
revalidateTag('navigation');
break;
case 'article':
revalidateTag(`article-${body.slug.current}`);
break;
}
return new Response('OK', { status: 200 });
}
구현: 단계별
유사한 마이그레이션을 수행하고 있다면, 다음은 우리가 권장하는 실용적인 교과서이며 (그리고 현재 우리가 Social Animal에서 Next.js 개발 프로젝트에 사용하는 것):
단계 1: 플래그 활성화
// next.config.js
module.exports = {
experimental: {
cacheComponents: true,
cacheLife: {
// 합리적인 기본값으로 시작
default: {
stale: 60,
revalidate: 900,
expire: 86400,
},
},
},
};
단계 2: 핫 경로 찾기
분석을 사용하여 가장 많은 트래픽을 받는 페이지와 TTFB가 가장 중요한 경우를 식별합니다. 우리의 경우, 카테고리 페이지(높은 트래픽, 상대적으로 안정적인 콘텐츠)와 상품 페이지(높은 트래픽, 중간 정도의 동적 콘텐츠)였습니다.
단계 3: 하향식으로 `"use cache"` 추가
레이아웃으로 시작합니다. 루트 레이아웃이 네비게이션 데이터를 가져오면, 먼저 그것을 캐시합니다 -- 가장 높은 영향, 가장 낮은 위험 변경입니다:
// app/layout.tsx
// 주의: 레이아웃의 "use cache"는 레이아웃 쉘을 캐시합니다
// 자식 페이지는 여전히 독립적으로 렌더링됩니다
import { Navigation } from '@/components/Navigation';
export default async function RootLayout({ children }) {
return (
<html>
<body>
<Navigation /> {/* 이 컴포넌트는 자체 "use cache"를 가집니다 */}
{children}
</body>
</html>
);
}
단계 4: 모니터링 설정
우리는 Vercel의 내장 분석과 사용자 정의 로깅을 사용하여 캐시 히트율을 추적했습니다. cacheComponents를 활성화한 후 첫 주에 우리의 캐시 히트율은 34%였습니다. stale 지속 시간을 조정한 후 78%로 올랐습니다.
성능 결과 및 벤치마크
다음은 Vercel의 Pro 플랜에서 측정한 30일 기간 동안 완전한 마이그레이션 후의 실제 수치입니다:
| 메트릭 | Before (Next.js 14) | After Phase 1 (v16, 캐시 없음) | 완전한 마이그레이션 후 |
|---|---|---|---|
| 평균 TTFB (상품 페이지) | 180ms | 340ms | 95ms |
| 평균 TTFB (카테고리 페이지) | 220ms | 410ms | 72ms |
| 평균 TTFB (편집 페이지) | 150ms | 280ms | 45ms |
| P99 TTFB (모든 페이지) | 1,200ms | 2,100ms | 380ms |
| 빌드 시간 (완전) | 3h 12min | 2h 48min | 48min |
| Vercel 함수 호출/일 | 2.4M | 3.8M | 1.1M |
| 월간 Vercel 청구서 | ~$840 | ~$1,200 | ~$520 |
| 캐시 히트율 | 알 수 없음 (암시적) | 해당 없음 | 78% |
| 스테일 콘텐츠 발생 (#cache-crimes) | 8-12/week | 0 | 1-2/month |
빌드 시간 개선은 설명이 필요합니다. cacheComponents를 사용하여 빌드 시 모든 91,000개의 페이지를 생성하는 것에서 벗어났습니다. 대신, 상위 5,000개 페이지만(트래픽 기준으로) 정적으로 생성하고 나머지는 캐싱을 통해 온디맨드 생성을 허용했습니다. cacheComponents 지시문은 첫 방문 후 온디맨드 페이지가 캐시되었다는 것을 의미했으며, cacheLife는 신선함을 제어했습니다.
Vercel 청구서 감소는 중요했습니다. 더 적은 함수 호출(명시적 컴포넌트 캐싱 때문에)과 더 짧은 빌드 시간은 실제 비용 절감을 의미했습니다. 그 ~$320/달 감소는 자신을 보상합니다.
함정 및 주의사항
직렬화 경계
"use cache" 지시문은 직렬화 경계를 생성합니다. 캐시된 컴포넌트로 소품으로 전달된 모든 것은 직렬화 가능해야 합니다. 우리는 소품으로 콜백 함수나 React 요소를 받는 여러 컴포넌트를 가지고 있었습니다 -- 그것들은 즉시 깨졌습니다. 수정은 대신 구성 패턴을 사용하도록 재구성하는 것이었습니다:
// ❌ 이것은 "use cache"로 깨집니다
"use cache";
export async function ProductCard({ product, onAddToCart }) {
// onAddToCart는 함수 -- 직렬화 불가능합니다!
}
// ✅ 이것은 작동합니다
"use cache";
export async function ProductCard({ product }) {
return (
<div>
<h2>{product.name}</h2>
{/* AddToCart는 Client Component, 캐시되지 않음 */}
<AddToCartButton productId={product.id} />
</div>
);
}
동적 매개변수 및 캐시 키 폭발
91,000개의 페이지로, 각각 고유한 매개변수로, 캐시 키 공간은 엄청났습니다. 첫 주에 Vercel의 에지 캐시 제한에 도달했고 어떤 페이지가 긴 expire 값을 얻을지 더 전략적이어야 했습니다. 낮은 트래픽 롱테일 페이지는 더 짧은 캐시 기간을 얻었습니다.
`Date.now()` 함정
"use cache"를 사용하는 모든 컴포넌트가 캐시된 함수 내에서 Date.now() 또는 new Date()를 호출하면 그 타임스탐프를 캐시합니다. 우리는 이것을 몇 시간 동안 같은 시간을 표시하는 "마지막 업데이트" 디스플레이에서 발견했습니다. 수정: 시간 민감한 로직을 Client Component 또는 캐시되지 않은 Server Component로 이동합니다.
중첩된 캐시 경계
중첩된 캐시된 컴포넌트를 다른 캐시된 컴포넌트 내에 배치할 때, 내부 캐시는 자체 수명 주기를 가집니다. 이것은 강력하지만 혼란스럽습니다. 우리는 팀 관례를 수립했습니다: 페이지 수준 또는 컴포넌트 수준에서 캐시하거나, 명확한 이유가 없으면 둘 다 하지 마십시오.
cacheComponents를 사용해야 하는 경우와 사용하지 말아야 하는 경우
다음의 경우 사용하십시오:
- 몇 백 개 이상의 페이지가 있고 ISR 빌드 시간이 괴로울 때
- 콘텐츠가 섹션별로 다양한 신선함 요구사항을 가지고 있을 때
- 캐시된 것과 항상 최신의 것을 세밀하게 제어해야 할 때
- Vercel 또는 Next.js 캐시 레이어를 지원하는 플랫폼에서 실행 중일 때
- 고트래픽 사이트의 인프라 비용을 줄이려고 할 때
다음의 경우 사용하지 마십시오:
- 사이트가 완전한 SSG가 잘 작동할 정도로 충분히 작을 때
- 모든 페이지가 완전 동적일 때 (곳곳의 사용자 특정 콘텐츠)
- Next.js 캐싱 인프라를 지원하지 않는 호스팅 플랫폼에 있을 때
- 팀이 Next.js에 새로울 때 -- 먼저 기본 사항에 익숙해지십시오
당신의 프로젝트가 이 수준의 캐싱 제어가 필요한지 평가 중이거나, Astro와 같은 다른 프레임워크가 콘텐츠 집약적인 사이트에 더 나은 맞춤형일 수도 있는지 고민하고 있다면, 마이그레이션에 커밋하기 전에 생각할 가치가 있습니다.
여러 헤드리스 CMS 소스에서 콘텐츠가 오는 프로젝트의 경우, Next.js 16의 cacheTag 시스템은 헤드리스 CMS 아키텍처와 아름답게 작동합니다 -- 각 콘텐츠 유형은 자체 무효화 채널을 얻습니다.
FAQ
Next.js 16의 cacheComponents는 무엇입니까?
cacheComponents는 Server Components에 대해 "use cache" 지시문을 활성화하는 Next.js 16의 실험적 구성 옵션입니다. 어떤 컴포넌트를 캐시해야 하는지 명시적으로 표시하고 cacheLife를 사용하여 사용자 정의 캐시 프로필을 정의할 수 있습니다. 이것은 Next.js 15에서 실험적이었던 use cache 지시문의 안정적인 진화입니다.
cacheComponents는 ISR(증분 정적 재생성)과 어떻게 다릅니까?
ISR은 전체 페이지를 캐시하고 시간 기반 일정에 따라 재검증합니다. cacheComponents는 개별 컴포넌트가 페이지 내에서 다른 캐시 수명으로 각각 캐시되도록 합니다. 단일 페이지는 24시간 동안 캐시된 헤더, 1시간 동안 캐시된 상품 정보, 절대 캐시되지 않은 가격을 가질 수 있습니다. ISR은 그것을 할 수 없습니다 -- 페이지 수준에서 모두이거나 없습니다.
cacheComponents를 사용하려면 Vercel에 있어야 합니까?
아니오, 하지만 경험은 캐싱 인프라가 긴밀하게 통합되기 때문에 Vercel에서 가장 좋습니다. 자체 호스팅된 Next.js 배포는 파일 시스템 캐시 어댑터와 함께 cacheComponents를 사용할 수 있지만, 에지 배포 이점을 얻지 못합니다. Netlify 및 Cloudflare와 같은 플랫폼이 지원을 추가하고 있지만, 2025년 중반 기준으로 Vercel이 가장 완전한 구현을 유지합니다.
Next.js 16에서 캐시된 컴포넌트를 무효화하려면 어떻게 합니까?
캐시된 컴포넌트 내에서 cacheTag()를 사용하여 태그를 할당한 다음 서버 액션, 경로 핸들러 또는 웹훅 엔드포인트에서 revalidateTag('tag-name')을 호출합니다. 이것은 그 태그가 있는 모든 캐시된 컴포넌트를 무효화합니다. 이것은 Next.js 15와 동일한 API이지만, 불명확한 프레임워크 캐시를 무효화하려고 시도하는 대신 명시적 캐시된 컴포넌트에 태그를 지정하고 있기 때문에 이제 더 유용합니다.
cacheComponents는 내 Vercel 청구서를 줄일까요?
상당히 줄일 수 있습니다. 우리의 경우, 캐시된 컴포넌트 응답이 캐시 레이어에서 제공되었기 때문에 함수 호출이 54% 감소했습니다. 빌드 시간 감소는 빌드 분에 대해서도 절약됩니다. 당신의 마일리지는 트래픽 패턴과 캐시 히트율에 따라 다를 것입니다 -- Vercel의 가격 계산기에서 현재 사용량으로 확인하십시오.
직렬화 불가능한 소품을 받는 컴포넌트에 "use cache"를 추가하면 어떻게 됩니까?
빌드 오류가 발생합니다. "use cache" 지시문은 직렬화 경계를 생성하므로 모든 소품은 직렬화 가능해야 합니다 (문자열, 숫자, 일반 객체, 배열). 함수, React 요소, 클래스 인스턴스 및 기타 직렬화 불가능한 값은 빌드 실패를 야기합니다. 컴포넌트를 재구성하여 데이터 소품만 허용하고 자식 Client Components에서 상호작용을 처리합니다.
다른 프레임워크에서 React Server Components와 함께 cacheComponents를 사용할 수 있습니까?
아니오. cacheComponents는 React의 Server Components를 기반으로 구축된 Next.js 특정 기능입니다. "use cache" 지시문 구문은 결국 React 표준이 될 수도 있지만, cacheLife 프로필과 cacheTag 시스템은 Next.js API입니다. Remix를 사용하거나 사용자 정의 RSC 설정을 사용하는 경우, 다른 캐싱 전략이 필요할 것입니다.
대규모 Next.js 사이트를 cacheComponents로 마이그레이션하는 데 얼마나 오래 걸립니까?
우리의 91,000페이지 사이트에서 4명의 개발자 팀의 경우, 테스트 및 모니터링을 포함하여 전체 마이그레이션은 7주가 소요되었습니다. 더 작은 사이트(10,000페이지 미만)에서 더 간단한 데이터 모델이 있는 경우는 아마도 1-2주 내에 완료할 수 있습니다. 실제 코드 변경은 직설적입니다 -- 시간은 캐싱 요구사항을 감사하고, 무효화 흐름을 테스트하고, 배포 후 캐시 히트율을 모니터링하는 것으로 갑니다. 혼자가 아니라면, 문의하십시오 -- 우리는 이제 몇 번 이것을 했습니다.