Next.js 16 cacheComponents: 91,000 페이지 무중단 마이그레이션
2시 14분에 배포가 진행됩니다. 91,000개의 상품 페이지—카테고리 트리, 14개 시장의 현지화된 변형, 에디토리얼 SEO 콘텐츠—가 Next.js 14의 App Router에서 v16의 cacheComponents API로 전환됩니다. Vercel 로그에서 첫 요청 워터폴을 확인합니다. TTFB가 1.8초까지 급증합니다. Slack이 울립니다. 18개월 동안 기존 default-cache 모델과 싸워왔습니다. 오래된 가격, 너무 늦게 실행되는 revalidateTag 호출, "웹사이트 때문"이라고 불평하는 고객 지원 티켓들. Next.js 15는 기본적으로 캐싱을 끄고, v16은 cacheComponents를 사용해 선택적으로 다시 켤 수 있게 했습니다. 천 개의 캐시 버그로 인한 천천한 죽음보다는 마이그레이션을 선택했습니다. 이제 실제 트래픽, 실제 에러, 그리고 일출 2시간 전의 프로덕션 인시던트를 보고 있습니다. 무엇이 깨졌는지, 무엇이 유지되었는지, 그리고 어떤 벤치마크 스위트도 예측하지 못한 성능 델타를 알아봅시다.
목차
- 실제로 겪었던 캐싱 문제
- 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시간 이상이 걸렸습니다. ISR과 revalidate: 3600으로 대부분의 페이지로 이동했지만, 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 | 30초(동적) / 5분(정적) | 페이지 세그먼트용 0초 | 기본값 0초, 구성 가능 |
| Full Route Cache | 정적 라우트에서 활성화됨 | 동일 | cacheLife 개선과 함께 동일 |
| 컴포넌트 수준 캐싱 | unstable_cache |
use cache 지시문(실험) |
cacheComponents API(안정) |
Next.js 15는 use cache 지시문을 플래그 뒤의 실험적 기능으로 도입했습니다. 2025년 초 출시된 Next.js 16은 이를 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'); // custom cache profile
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)은 stale-while-revalidate 의미론에 깔끔하게 매핑됩니다. 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주)
cacheComponents를 활성화하지 않고 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 | 캐시 없음 | N/A |
단계 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일 동안 측정한 전체 마이그레이션 후의 실제 숫자는 다음과 같습니다:
| 메트릭 | 이전 (Next.js 14) | 단계 1 후 (v16, 캐시 없음) | 전체 마이그레이션 후 |
|---|---|---|---|
| 평균 TTFB (상품 페이지) | 180ms | 340ms | 95ms |
| 평균 TTFB (카테고리 페이지) | 220ms | 410ms | 72ms |
| 평균 TTFB (에디토리얼 페이지) | 150ms | 280ms | 45ms |
| P99 TTFB (모든 페이지) | 1,200ms | 2,100ms | 380ms |
| 빌드 시간 (완전) | 3시간 12분 | 2시간 48분 | 48분 |
| Vercel 함수 호출/일 | 2.4M | 3.8M | 1.1M |
| 월간 Vercel 청구액 | ~$840 | ~$1,200 | ~$520 |
| 캐시 히트율 | 알 수 없음 (암묵적) | N/A | 78% |
| 오래된 콘텐츠 인시던트 (#cache-crimes) | 8-12/주 | 0 | 1-2/월 |
빌드 시간 개선은 설명할 가치가 있습니다. cacheComponents와 함께, 우리는 빌드 시점에 모든 91,000개 페이지를 생성하는 것에서 벗어났습니다. 대신, 상위 5,000개 페이지(트래픽별)만 정적으로 생성하고 나머지는 캐싱과 함께 온디맨드 생성하도록 했습니다. cacheComponents 지시문은 첫 방문 후 온디맨드 페이지가 캐시되었으며, cacheLife가 신선도를 제어했습니다.
Vercel 청구액 감소는 중요했습니다. 더 적은 함수 호출(명시적 컴포넌트 캐싱으로 인해) 더하기 더 짧은 빌드 시간은 실제 비용 절감을 의미했습니다. ~$320/월 감소는 자체로 비용을 정당화합니다.
함정과 주의할 점
직렬화 경계
"use cache" 지시문은 직렬화 경계를 만듭니다. 캐시된 컴포넌트로 props로 전달되는 모든 것은 직렬화 가능해야 합니다. 우리는 콜백 함수 또는 React 엘리먼트를 props로 받는 여러 컴포넌트를 가지고 있었습니다—그것들은 즉시 깨졌습니다. 수정은 대신 composition 패턴을 사용하도록 재구조화하는 것이었습니다:
// ❌ 이것은 "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개의 페이지로, 각각 고유한 params로, 캐시 키 공간은 엄청납니다. 첫 주에 Vercel의 엣지 캐시 한계에 도달했고, 어떤 페이지가 긴 expire 값을 얻을지에 대해 더 전략적이어야 했습니다. 낮은 트래픽 롱테일 페이지는 더 짧은 캐시 지속 시간을 얻었습니다.
`Date.now()` 함정
"use cache"를 사용하는 캐시된 함수 내에서 Date.now() 또는 new Date()를 호출하는 모든 컴포넌트는 그 타임스탬프를 캐시합니다. 우리는 몇 시간 동안 동일한 시간을 표시하는 "마지막 업데이트" 디스플레이에서 이것을 발견했습니다. 수정: 시간에 민감한 로직을 Client Component 또는 캐시되지 않은 Server Component로 이동합니다.
중첩된 캐시 경계
다른 캐시된 컴포넌트 내에 캐시된 컴포넌트를 중첩할 때, 내부 캐시는 자신의 생명주기를 가집니다. 이것은 강력하지만 혼동되기 쉽습니다. 우리는 팀 규칙을 확립했습니다: 페이지 수준에서 OR 컴포넌트 수준에서 캐시하되, 명확한 이유가 없으면 둘 다 하지 마세요.
cacheComponents를 사용해야 할 때와 하지 말아야 할 때
다음의 경우 사용하세요:
- 몇 백 개 이상의 페이지가 있고 ISR 빌드 시간이 고통스러울 때
- 콘텐츠가 섹션별로 다른 신선도 요구사항이 명확할 때
- 무엇이 캐시되는지 대 항상 새로움에 대해 세밀한 제어가 필요할 때
- Vercel 또는 Next.js 캐시 레이어를 지원하는 플랫폼에서 실행 중일 때
- 인프라 비용을 높은 트래픽 사이트에서 줄이고 싶을 때
다음의 경우 사용하지 마세요:
- 사이트가 작아서 완전한 SSG가 잘 작동할 때
- 모든 페이지가 완전히 동적(어디나 사용자 특정 콘텐츠)일 때
- Next.js 캐싱 인프라를 지원하지 않는 호스팅 플랫폼에서 실행 중일 때
- 팀이 Next.js에 새로워서—먼저 기본을 편하게 해야 할 때
프로젝트가 이 수준의 캐싱 제어를 필요로 하는지, 또는 Astro 같은 다른 프레임워크가 콘텐츠 많은 사이트에 더 적합한지 평가하는 것이 마이그레이션에 커밋하기 전에 생각해 볼 가치가 있습니다.
여러 헤드리스 CMS 소스에서 콘텐츠가 오는 프로젝트의 경우, Next.js 16의 cacheTag 시스템은 headless CMS 아키텍처와 아름답게 작동합니다—각 콘텐츠 유형은 자신의 무효화 채널을 얻습니다.
FAQ
Next.js 16의 cacheComponents는 무엇입니까?
cacheComponents는 Server Components를 위해 "use cache" 지시문을 활성화하는 Next.js 16의 실험적 구성 옵션입니다. 이것은 어떤 컴포넌트가 캐시되어야 하는지를 명시적으로 표시하고 cacheLife를 사용하여 맞춤형 캐시 프로필을 정의할 수 있게 합니다. 이것은 Next.js 15에서 실험적이었던 use cache 지시문의 안정적인 진화입니다.
cacheComponents는 ISR (Incremental Static Regeneration)과 어떻게 다릅니까?
ISR은 전체 페이지를 캐시하고 시간 기반 일정에 따라 재검증합니다. cacheComponents는 페이지 내 개별 컴포넌트를 캐시할 수 있게 하며, 각각은 다른 캐시 수명을 가집니다. 단일 페이지는 24시간 동안 캐시된 헤더, 1시간 동안 캐시된 상품 정보, 그리고 절대 캐시되지 않는 가격을 가질 수 있습니다. ISR은 그것을 할 수 없습니다—그것은 페이지 수준에서 전부 또는 무입니다.
cacheComponents를 사용하려면 Vercel에 있어야 합니까?
아니오, 하지만 경험은 Vercel에서 가장 좋습니다. 캐싱 인프라가 단단하게 통합되어 있기 때문입니다. 자체 호스팅 Next.js 배포는 파일 시스템 캐시 어댑터와 함께 cacheComponents를 사용할 수 있지만, 엣지 배포 이점을 얻지 못할 것입니다. Netlify와 Cloudflare 같은 플랫폼이 지원을 추가하고 있지만, 2026년 중반 현재, Vercel은 가장 완전한 구현으로 남아있습니다.
Next.js 16에서 캐시된 컴포넌트를 무효화하려면 어떻게 합니까?
캐시된 컴포넌트 내에서 cacheTag()를 사용하여 태그를 할당한 다음, 서버 액션, 경로 핸들러, 또는 웹훅 엔드포인트에서 revalidateTag('tag-name')을 호출합니다. 이것은 그 태그를 가진 모든 캐시된 컴포넌트를 무효화합니다. 이것은 Next.js 15와 동일한 API이지만, 불투명한 프레임워크 수준 캐시를 무효화하려고 하기보다는 특정 캐시된 컴포넌트를 태깅하고 있기 때문에 더 유용합니다.
cacheComponents는 내 Vercel 청구액을 줄입니까?
크게 줄일 수 있습니다. 우리의 경우, 캐시된 컴포넌트 응답이 캐시 레이어에서 제공되었기 때문에 함수 호출이 54% 감소했습니다. 빌드 시간 감소도 빌드 분 수를 절약합니다. 귀하의 마일리지는 트래픽 패턴과 캐시 히트율에 따라 다릅니다—Vercel의 가격 책정 계산기로 현재 사용량을 확인하세요.
직렬화 불가능한 props를 받는 컴포넌트에 "use cache"를 추가하면 어떻게 됩니까?
빌드 오류가 발생합니다. "use cache" 지시문은 직렬화 경계를 만들므로, 모든 props는 직렬화 가능해야 합니다(문자열, 숫자, 일반 객체, 배열). 함수, React 엘리먼트, 클래스 인스턴스, 기타 직렬화 불가능한 값은 빌드가 실패하게 합니다. 컴포넌트를 재구조화하여 데이터 props만 받고, 자식 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주 안에 할 수 있을 것입니다. 실제 코드 변경은 간단합니다—시간은 캐싱 요구사항을 감시하고, 무효화 흐름을 테스트하고, 배포 후 캐시 히트율을 모니터링하는 데 들어갑니다. 혼자 하고 싶지 않으시다면, 우리에게 연락하세요—우리는 이것을 몇 번 했습니다.