Next.js의 확장된 fetch API와 다양한 캐싱 전략을 학습하여 최적의 데이터 관리 방법을 익힙니다.
Next.js는 네이티브 fetch API를 확장하여 캐싱과 재검증 기능을 추가했습니다. Server Component에서 데이터를 가져올 때 자동으로 캐싱됩니다.
// Server Component에서 직접 fetch
async function getProducts() {
const res = await fetch('https://api.myshop.com/products');
if (!res.ok) {
throw new Error('Failed to fetch products');
}
return res.json();
}
// 페이지에서 사용
export default async function ProductsPage() {
const products = await getProducts();
return (
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}// 1. 기본값: 캐싱됨 (force-cache)
const res = await fetch('https://api.example.com/data');
// 2. 캐싱 안 함 (매 요청마다 새로 가져옴)
const res = await fetch('https://api.example.com/data', {
cache: 'no-store'
});
// 3. 시간 기반 재검증 (ISR)
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // 1시간마다 재검증
});
// 4. 태그 기반 재검증
const res = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }
});
// 태그로 재검증 트리거
import { revalidateTag } from 'next/cache';
revalidateTag('products');| 옵션 | 동작 | 사용 시점 |
|---|---|---|
| 기본값 | 영구 캐싱 | 정적 데이터 |
| no-store | 캐싱 안 함 | 실시간 데이터 |
| revalidate: N | N초마다 재검증 | 주기적 업데이트 |
| tags | 온디맨드 재검증 | CMS 연동 |
// 상품 목록: 30분마다 재검증
async function getProducts() {
const res = await fetch('https://api.myshop.com/products', {
next: {
revalidate: 1800,
tags: ['products']
}
});
return res.json();
}
// 재고: 캐싱 안 함 (실시간)
async function getStock(productId: string) {
const res = await fetch(
`https://api.myshop.com/products/${productId}/stock`,
{ cache: 'no-store' }
);
return res.json();
}
// 카테고리: 영구 캐싱 (거의 안 변함)
async function getCategories() {
const res = await fetch('https://api.myshop.com/categories');
return res.json();
}fetch 캐싱 핵심
Next.js는 4가지 캐싱 레이어를 제공합니다. 각 레이어의 역할과 동작 방식을 이해하면 성능을 최적화할 수 있습니다.
1. Request Memoization
같은 요청 중복 제거 (렌더링 중)
범위: 단일 렌더링
2. Data Cache
fetch 결과 캐싱 (서버)
범위: 요청 간 지속
3. Full Route Cache
렌더링된 HTML/RSC 캐싱
범위: 빌드 시 ~ 재검증
4. Router Cache
클라이언트 네비게이션 캐싱
범위: 세션 동안
// 같은 URL로 여러 번 fetch해도 실제 요청은 1번만
async function ProductPage({ params }) {
// 이 두 호출은 자동으로 중복 제거됨
const product = await getProduct(params.id);
const relatedProducts = await getRelatedProducts(params.id);
return <div>...</div>;
}
async function getProduct(id: string) {
// 첫 번째 호출: 실제 요청
const res = await fetch(`https://api.example.com/products/${id}`);
return res.json();
}
async function getRelatedProducts(id: string) {
// 같은 URL이면 캐시된 결과 사용
const product = await fetch(`https://api.example.com/products/${id}`);
// ...
}// Data Cache는 요청 간에 지속됨
// 사용자 A의 요청 결과가 사용자 B에게도 제공
// 캐싱됨 (기본값)
fetch('https://api.example.com/products');
// 캐싱 안 함
fetch('https://api.example.com/products', {
cache: 'no-store'
});
// 시간 기반 재검증
fetch('https://api.example.com/products', {
next: { revalidate: 3600 }
});
// 재검증 트리거
import { revalidatePath, revalidateTag } from 'next/cache';
// 경로 기반
revalidatePath('/products');
// 태그 기반
revalidateTag('products');// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const { type, value, secret } = await request.json();
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 });
}
if (type === 'path') {
revalidatePath(value);
} else if (type === 'tag') {
revalidateTag(value);
}
return Response.json({ revalidated: true });
}캐싱 전략 선택
여러 데이터를 동시에 가져오면 성능이 크게 향상됩니다. Promise.all과 Suspense를 활용한 병렬 페칭 패턴을 알아봅니다.
// ❌ 순차 페칭 (느림) - 총 3초
async function ProductPage({ params }) {
const product = await getProduct(params.id); // 1초
const reviews = await getReviews(params.id); // 1초
const related = await getRelatedProducts(params.id); // 1초
return <div>...</div>;
}
// ✅ 병렬 페칭 (빠름) - 총 1초
async function ProductPage({ params }) {
const [product, reviews, related] = await Promise.all([
getProduct(params.id),
getReviews(params.id),
getRelatedProducts(params.id),
]);
return <div>...</div>;
}// 핵심 데이터 먼저, 나머지는 스트리밍
import { Suspense } from 'react';
export default async function ProductPage({ params }) {
// 핵심 데이터는 즉시 로드
const product = await getProduct(params.id);
return (
<div>
{/* 즉시 표시 */}
<ProductInfo product={product} />
{/* 스트리밍: 로딩 중에도 위 내용은 보임 */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts productId={params.id} />
</Suspense>
</div>
);
}
// 비동기 컴포넌트
async function ProductReviews({ productId }: { productId: string }) {
const reviews = await getReviews(productId);
return <ReviewList reviews={reviews} />;
}
async function RelatedProducts({ productId }: { productId: string }) {
const products = await getRelatedProducts(productId);
return <ProductGrid products={products} />;
}// Promise.allSettled로 부분 실패 허용
async function ProductPage({ params }) {
const results = await Promise.allSettled([
getProduct(params.id),
getReviews(params.id),
getRelatedProducts(params.id),
]);
const product = results[0].status === 'fulfilled'
? results[0].value
: null;
const reviews = results[1].status === 'fulfilled'
? results[1].value
: [];
const related = results[2].status === 'fulfilled'
? results[2].value
: [];
if (!product) {
notFound();
}
return (
<div>
<ProductInfo product={product} />
{reviews.length > 0 && <ReviewList reviews={reviews} />}
{related.length > 0 && <RelatedProducts products={related} />}
</div>
);
}병렬 페칭 패턴
Next.js는 여러 레벨의 캐시를 사용하여 성능을 최적화합니다. 각 캐시의 역할과 무효화 방법을 이해해야 합니다.
같은 렌더링 사이클에서 동일 요청 중복 제거
범위: 단일 요청 | 자동 적용
fetch 결과를 서버에 영구 저장 (재검증까지)
범위: 요청 간 지속 | revalidate로 제어
렌더링된 HTML과 RSC Payload 캐싱
범위: 빌드 시 ~ 재배포 | 정적 라우트만
클라이언트 측 RSC Payload 캐싱 (네비게이션)
범위: 세션 동안 | 자동 적용
// Data Cache는 서버에 영구 저장됨
// 캐시됨 (기본값)
fetch(url);
fetch(url, { cache: 'force-cache' });
fetch(url, { next: { revalidate: 3600 } });
// 캐시 안 됨
fetch(url, { cache: 'no-store' });
// 캐시 무효화
import { revalidateTag, revalidatePath } from 'next/cache';
revalidateTag('products'); // 태그로 무효화
revalidatePath('/products'); // 경로로 무효화요청 → Request Memoization (렌더링 중 중복 제거)
→ Data Cache (서버 저장소 확인)
→ 원본 데이터 소스 (API/DB)
응답 → Data Cache에 저장
→ Full Route Cache에 저장 (정적 라우트)
→ Router Cache에 저장 (클라이언트)// 페이지 레벨에서 캐시 비활성화
export const dynamic = 'force-dynamic';
export const revalidate = 0;
// fetch 레벨에서 캐시 비활성화
fetch(url, { cache: 'no-store' });
// 동적 함수 사용 시 자동 옵트아웃
import { cookies, headers } from 'next/headers';
const cookieStore = cookies(); // 동적 렌더링으로 전환캐시 디버깅 팁
next build && next start로 프로덕션 테스트캐시된 데이터를 최신 상태로 유지하는 다양한 재검증 방법을 학습합니다. 시간 기반, 태그 기반, 경로 기반 재검증을 상황에 맞게 선택합니다.
// fetch 레벨
fetch(url, { next: { revalidate: 3600 } });
// 페이지/레이아웃 레벨
export const revalidate = 3600;
// 동작:
// 1. 설정된 시간 동안 캐시 사용
// 2. 시간 경과 후 요청 시 백그라운드 재검증
// 3. 새 데이터 준비되면 캐시 교체 (stale-while-revalidate)// 1. 데이터 페칭 시 태그 지정
async function getProduct(id: string) {
const res = await fetch(`https://api.myshop.com/products/${id}`, {
next: { tags: [`product-${id}`, 'products'] }
});
return res.json();
}
// 2. 데이터 변경 시 태그로 재검증
// app/api/products/[id]/route.ts
import { revalidateTag } from 'next/cache';
export async function PUT(request: Request, { params }) {
// DB 업데이트...
// 해당 상품 캐시 무효화
revalidateTag(`product-${params.id}`);
return Response.json({ success: true });
}import { revalidatePath } from 'next/cache';
// 특정 페이지 재검증
revalidatePath('/products');
revalidatePath('/products/123');
// 레이아웃 포함 재검증
revalidatePath('/products', 'layout');
// 전체 사이트 재검증
revalidatePath('/', 'layout');| 상황 | 전략 | 예시 |
|---|---|---|
| 주기적 갱신 | 시간 기반 | 베스트셀러 (1시간) |
| 데이터 수정 시 | 태그 기반 | 상품 정보 수정 |
| 페이지 전체 갱신 | 경로 기반 | 카테고리 구조 변경 |
이커머스 재검증 팁
실제 이커머스에서 사용하는 데이터 페칭 패턴을 구현합니다. 데이터 특성에 따라 적절한 캐싱 전략을 선택하는 것이 핵심입니다.
| 데이터 | 전략 | 이유 |
|---|---|---|
| 상품 목록 | revalidate + tags | 주기적 갱신 + 수정 시 즉시 반영 |
| 재고/가격 | no-store | 항상 최신 데이터 필요 |
| 카테고리 | force-cache | 거의 변경되지 않음 |
// lib/api/products.ts
import { cache } from 'react';
export const getProducts = cache(async () => {
const res = await fetch('https://api.myshop.com/products', {
next: {
revalidate: 3600, // 1시간마다 재검증
tags: ['products'] // 태그로 수동 재검증 가능
}
});
return res.json();
});
export const getProduct = cache(async (id: string) => {
const res = await fetch(`https://api.myshop.com/products/${id}`, {
next: { tags: [`product-${id}`, 'products'] }
});
return res.json();
});// lib/api/stock.ts
export async function getStock(productId: string) {
const res = await fetch(
`https://api.myshop.com/products/${productId}/stock`,
{ cache: 'no-store' } // 항상 최신 데이터
);
return res.json();
}
// 컴포넌트에서 사용
async function StockStatus({ productId }: { productId: string }) {
const stock = await getStock(productId);
return (
<div>
{stock.quantity > 0 ? (
<span className="text-green-600">재고 {stock.quantity}개</span>
) : (
<span className="text-red-600">품절</span>
)}
</div>
);
}// app/api/admin/products/[id]/route.ts
import { revalidateTag } from 'next/cache';
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
const data = await request.json();
// DB 업데이트
await updateProduct(params.id, data);
// 캐시 무효화
revalidateTag(`product-${params.id}`);
revalidateTag('products');
return Response.json({ success: true });
}핵심 포인트