웹 애플리케이션에서 가장 중요한 두 가지 목표는 ‘빠른 응답 속도’와 ‘최신 데이터 제공’입니다. 하지만 이 둘은 서로 상충하는 관계에 있습니다. 캐싱을 통해 속도를 높이면 데이터가 오래될 수 있고, 항상 최신 데이터를 가져오려면 속도가 느려집니다.
Next.js는 이 딜레마를 해결하기 위해 정교한 fetch 캐싱 시스템과 다양한 revalidate(재검증) 옵션을 제공합니다. 이 가이드에서는 이러한 기능들을 어떻게 활용하여 성능과 데이터 최신성의 균형을 맞출 수 있는지 자세히 알아보겠습니다.
Next.js의 캐싱 시스템 이해하기
4단계 캐싱 계층 구조
Next.js는 여러 층의 캐싱 시스템을 통해 데이터를 관리합니다. 각 계층을 이해하는 것이 효과적인 캐싱 전략의 첫걸음입니다.
// Next.js의 4단계 캐싱 계층
// 1️⃣ Request Memoization (React)
// - 범위: 단일 렌더링 패스 동안만 유효
// - 목적: 같은 렌더링 중 중복 요청 제거
// - 예시: 여러 컴포넌트에서 같은 사용자 정보 요청
// 2️⃣ Data Cache (Next.js)
// - 범위: 서버 재시작 간에도 지속
// - 목적: fetch 결과를 서버에 캐싱
// - 예시: API 응답을 일정 시간 동안 저장
// 3️⃣ Full Route Cache
// - 범위: 빌드 시 생성, 배포 간 지속
// - 목적: 전체 페이지 HTML과 RSC 페이로드 캐싱
// - 예시: 정적 페이지 사전 렌더링
// 4️⃣ Router Cache (Client)
// - 범위: 브라우저 세션 동안
// - 목적: 클라이언트 측 네비게이션 캐싱
// - 예시: 방문했던 페이지 빠르게 재표시
각 계층이 어떻게 작동하는지 실제 예시로 살펴보겠습니다:
// app/products/page.tsx
export default async function ProductsPage() {
// 이 fetch는 어떻게 캐싱될까요?
const response = await fetch('https://api.example.com/products');
const products = await response.json();
// 1. Request Memoization: 이 페이지 렌더링 중 다른 컴포넌트에서
// 같은 URL을 fetch하면 캐시된 결과 사용
// 2. Data Cache: 다음 사용자가 이 페이지를 방문해도
// 캐시된 데이터 사용 (기본 설정)
// 3. Full Route Cache: 이 페이지가 정적이면
// 빌드 시 전체 HTML이 캐싱됨
// 4. Router Cache: 사용자가 다른 페이지 갔다가 돌아와도
// 클라이언트에 캐시된 버전 표시
return <ProductList products={products} />;
}
fetch 캐싱 옵션 상세 설명
Next.js의 fetch는 브라우저 표준 fetch API를 확장하여 추가 옵션을 제공합니다:
// 1. force-cache (기본값) - 최대한 캐싱
export async function getStaticData() {
const response = await fetch('https://api.example.com/static-content', {
cache: 'force-cache' // 또는 생략 (기본값)
});
// 이 데이터는 한 번 가져온 후 계속 재사용됩니다
// 서버를 재시작해도 캐시가 유지됩니다
// 언제 사용? 거의 변하지 않는 데이터 (회사 소개, 약관 등)
return response.json();
}
// 2. no-store - 캐싱 완전 비활성화
export async function getRealTimeData() {
const response = await fetch('https://api.example.com/live-data', {
cache: 'no-store' // 매번 새로운 데이터 요청
});
// 모든 요청마다 서버에서 최신 데이터를 가져옵니다
// 캐싱이 전혀 되지 않으므로 항상 최신 상태
// 언제 사용? 실시간 데이터 (주가, 재고, 온라인 사용자 수 등)
return response.json();
}
// 3. Next.js 전용: revalidate 옵션
export async function getPeriodicData() {
const response = await fetch('https://api.example.com/news', {
next: {
revalidate: 3600 // 1시간(3600초)마다 재검증
}
});
// 1시간 동안은 캐시된 데이터 사용
// 1시간 후 첫 요청 시 백그라운드에서 새 데이터 가져옴
// 언제 사용? 주기적으로 업데이트되는 데이터 (뉴스, 블로그 등)
return response.json();
}
시간 기반 재검증 (Time-based Revalidation)
ISR (Incremental Static Regeneration) 이해하기
ISR은 정적 사이트의 빠른 속도와 동적 사이트의 최신성을 결합한 Next.js의 핵심 기능입니다.
// app/blog/[slug]/page.tsx
// 이 페이지는 빌드 시 생성되지만, 지정된 시간마다 재생성됩니다
export default async function BlogPost({ params }: { params: { slug: string } }) {
// 10분마다 재검증
const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
next: { revalidate: 600 } // 600초 = 10분
});
return <Article post={await post.json()} />;
}
// ISR 동작 과정:
// 1. 첫 방문자: 캐시 미스 → 데이터 fetch → 페이지 생성 → 캐시 저장
// 2. 10분 내 방문자: 캐시된 페이지 즉시 제공 (빠름!)
// 3. 10분 후 첫 방문자:
// - 즉시: 기존 캐시된 페이지 제공 (stale)
// - 백그라운드: 새 데이터로 페이지 재생성
// 4. 다음 방문자: 새로 생성된 페이지 제공
데이터 특성에 따른 재검증 시간 설정
다양한 데이터 타입에 맞는 재검증 전략:
// app/_lib/fetch-strategies.ts
// 데이터 타입별 재검증 시간 상수
const REVALIDATE_TIMES = {
STATIC: 86400, // 24시간 - 거의 안 변하는 데이터
SEMI_DYNAMIC: 3600, // 1시간 - 가끔 변하는 데이터
FREQUENT: 300, // 5분 - 자주 변하는 데이터
REAL_TIME: 0, // 캐싱 안 함 - 실시간 데이터
} as const;
// 실제 사용 예시
export async function getCompanyInfo() {
// 회사 정보는 거의 안 변함 - 24시간 캐싱
const response = await fetch('/api/company', {
next: { revalidate: REVALIDATE_TIMES.STATIC }
});
return response.json();
}
export async function getProductList() {
// 상품 목록은 하루에 몇 번 정도 변경 - 1시간 캐싱
const response = await fetch('/api/products', {
next: { revalidate: REVALIDATE_TIMES.SEMI_DYNAMIC }
});
return response.json();
}
export async function getTrendingPosts() {
// 트렌딩 포스트는 자주 변함 - 5분 캐싱
const response = await fetch('/api/trending', {
next: { revalidate: REVALIDATE_TIMES.FREQUENT }
});
return response.json();
}
export async function getStockPrice(symbol: string) {
// 주가는 실시간 - 캐싱 안 함
const response = await fetch(`/api/stocks/${symbol}`, {
cache: 'no-store' // revalidate: 0과 같은 효과
});
return response.json();
}
조건부 재검증 시간
상황에 따라 다른 재검증 시간을 적용할 수 있습니다:
// app/products/page.tsx
export default async function ProductsPage({
searchParams
}: {
searchParams: { category?: string }
}) {
// 카테고리별로 다른 재검증 시간 적용
const getRevalidateTime = (category?: string) => {
switch (category) {
case 'electronics':
return 1800; // 전자제품은 30분 (가격 변동 빈번)
case 'books':
return 7200; // 도서는 2시간 (변동 적음)
case 'fashion':
return 900; // 패션은 15분 (재고 변동 많음)
default:
return 3600; // 기본 1시간
}
};
const products = await fetch(
`https://api.example.com/products?category=${searchParams.category || 'all'}`,
{
next: {
revalidate: getRevalidateTime(searchParams.category)
}
}
);
return <ProductGrid products={await products.json()} />;
}
태그 기반 재검증 (Tag-based Revalidation)
태그 시스템의 이해
태그 기반 재검증은 관련된 모든 캐시를 한 번에 무효화할 수 있는 강력한 기능입니다.
// 태그를 사용한 캐싱
export async function getProductData() {
// 여러 태그를 동시에 적용 가능
const response = await fetch('https://api.example.com/products', {
next: {
tags: ['products', 'inventory', 'pricing'],
revalidate: 3600
}
});
// 이 캐시는 다음 중 하나로 무효화 가능:
// - revalidateTag('products')
// - revalidateTag('inventory')
// - revalidateTag('pricing')
// - 1시간 경과
return response.json();
}
계층적 태그 전략
복잡한 애플리케이션에서는 계층적 태그 구조가 유용합니다:
// app/_lib/tag-strategy.ts
import { revalidateTag } from 'next/cache';
// 태그 생성 헬퍼 함수
export function createTags(resource: string, id?: string, subcategory?: string) {
const tags = [resource]; // 기본 리소스 태그
if (id) {
tags.push(`${resource}:${id}`); // 특정 아이템 태그
}
if (subcategory) {
tags.push(`${resource}:${subcategory}`); // 서브카테고리 태그
}
return tags;
}
// 사용 예시
export async function getProduct(productId: string) {
const response = await fetch(`/api/products/${productId}`, {
next: {
tags: createTags('products', productId, 'electronics'),
revalidate: 3600
}
});
return response.json();
}
// 캐시 무효화 예시
export async function updateProduct(productId: string, data: any) {
// 제품 업데이트
await updateProductInDB(productId, data);
// 관련 캐시 무효화 - 세밀한 제어
revalidateTag(`products:${productId}`); // 특정 제품만
revalidateTag('products:electronics'); // 전자제품 카테고리
revalidateTag('products'); // 모든 제품 (필요시)
}
실전 예시: 블로그 시스템
// app/_actions/blog-actions.ts
'use server';
import { revalidateTag } from 'next/cache';
// 블로그 포스트 가져오기
export async function getBlogPost(slug: string) {
const response = await fetch(`https://api.blog.com/posts/${slug}`, {
next: {
tags: [
'posts',
`post:${slug}`,
'comments', // 댓글도 함께 관리
'authors'
],
revalidate: 1800 // 30분
}
});
return response.json();
}
// 새 댓글 추가 시
export async function addComment(postSlug: string, comment: string) {
// 댓글 저장
await saveComment(postSlug, comment);
// 해당 포스트와 댓글 관련 캐시만 무효화
revalidateTag(`post:${postSlug}`);
revalidateTag('comments');
// 'posts' 태그는 건드리지 않음 - 다른 포스트는 캐시 유지
}
// 포스트 수정 시
export async function updatePost(slug: string, content: string) {
// 포스트 업데이트
await updatePostInDB(slug, content);
// 특정 포스트만 재검증
revalidateTag(`post:${slug}`);
}
// 작성자 정보 변경 시
export async function updateAuthor(authorId: string) {
// 작성자 정보 업데이트
await updateAuthorInDB(authorId);
// 작성자 관련 모든 캐시 무효화
revalidateTag('authors');
revalidateTag('posts'); // 포스트에도 작성자 정보가 있으므로
}
On-Demand Revalidation (주문형 재검증)
웹훅을 통한 자동 재검증
CMS나 외부 서비스에서 데이터가 변경될 때 자동으로 캐시를 무효화:
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
// CMS 웹훅 엔드포인트
export async function POST(request: Request) {
try {
// 1. 보안 검증 - 웹훅이 진짜인지 확인
const headersList = headers();
const secret = headersList.get('x-webhook-secret');
if (secret !== process.env.WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. 웹훅 페이로드 파싱
const body = await request.json();
console.log('웹훅 수신:', body.event);
// 3. 이벤트 타입에 따라 처리
switch (body.event) {
case 'post.published':
// 새 포스트 발행 - 포스트 목록 재검증
revalidateTag('posts');
revalidatePath('/blog');
console.log('✅ 포스트 목록 재검증 완료');
break;
case 'post.updated':
// 포스트 수정 - 특정 포스트만 재검증
revalidateTag(`post:${body.data.slug}`);
revalidatePath(`/blog/${body.data.slug}`);
console.log(`✅ 포스트 ${body.data.slug} 재검증 완료`);
break;
case 'product.price_changed':
// 가격 변경 - 제품과 가격 관련 캐시 재검증
revalidateTag('products');
revalidateTag('pricing');
console.log('✅ 제품 가격 재검증 완료');
break;
default:
console.log('⚠️ 알 수 없는 이벤트:', body.event);
}
return Response.json({
revalidated: true,
timestamp: Date.now()
});
} catch (error) {
console.error('❌ 웹훅 처리 실패:', error);
return Response.json(
{ error: 'Failed to revalidate' },
{ status: 500 }
);
}
}
관리자 패널에서 수동 재검증
관리자가 직접 캐시를 제어할 수 있는 UI 제공:
// app/admin/cache-control/page.tsx
'use client';
import { useState } from 'react';
import { revalidateCache } from '@/app/_actions/cache-actions';
export default function CacheControl() {
const [status, setStatus] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const handleRevalidate = async (type: string, target: string) => {
setIsLoading(true);
setStatus(`${target} 재검증 중...`);
try {
const result = await revalidateCache(type, target);
if (result.success) {
setStatus(`✅ ${target} 재검증 완료!`);
} else {
setStatus(`❌ 재검증 실패: ${result.error}`);
}
} catch (error) {
setStatus(`❌ 오류 발생: ${error.message}`);
} finally {
setIsLoading(false);
}
};
return (
<div className="p-6">
<h2 className="text-2xl font-bold mb-6">캐시 관리</h2>
{/* 상태 표시 */}
{status && (
<div className="mb-4 p-3 bg-blue-100 rounded">
{status}
</div>
)}
{/* 빠른 작업 버튼들 */}
<div className="space-y-4">
<section>
<h3 className="font-semibold mb-2">태그별 재검증</h3>
<div className="flex gap-2">
<button
onClick={() => handleRevalidate('tag', 'products')}
disabled={isLoading}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
제품 캐시 새로고침
</button>
<button
onClick={() => handleRevalidate('tag', 'posts')}
disabled={isLoading}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
블로그 캐시 새로고침
</button>
<button
onClick={() => handleRevalidate('tag', 'users')}
disabled={isLoading}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
사용자 캐시 새로고침
</button>
</div>
</section>
<section>
<h3 className="font-semibold mb-2">페이지별 재검증</h3>
<div className="flex gap-2">
<button
onClick={() => handleRevalidate('path', '/')}
disabled={isLoading}
className="px-4 py-2 bg-green-500 text-white rounded disabled:opacity-50"
>
홈페이지 새로고침
</button>
<button
onClick={() => handleRevalidate('path', '/products')}
disabled={isLoading}
className="px-4 py-2 bg-green-500 text-white rounded disabled:opacity-50"
>
제품 페이지 새로고침
</button>
</div>
</section>
</div>
</div>
);
}
// app/_actions/cache-actions.ts
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
export async function revalidateCache(
type: 'tag' | 'path',
target: string
) {
try {
console.log(`🔄 ${type} 재검증 시작:`, target);
if (type === 'tag') {
revalidateTag(target);
} else {
revalidatePath(target);
}
console.log(`✅ ${type} 재검증 완료:`, target);
// 재검증 이력 저장 (옵션)
await logRevalidation(type, target);
return { success: true };
} catch (error) {
console.error(`❌ ${type} 재검증 실패:`, error);
return { success: false, error: error.message };
}
}
async function logRevalidation(type: string, target: string) {
// 데이터베이스에 재검증 이력 저장
console.log(`📝 재검증 로그: ${type} - ${target} at ${new Date().toISOString()}`);
}
고급 캐싱 전략
적응형 캐싱: 트래픽에 따른 동적 조절
트래픽이나 시간대에 따라 캐싱 전략을 자동으로 조절:
// app/_lib/adaptive-cache.ts
// 시간대별 트래픽 패턴 분석
function getAdaptiveRevalidateTime(
baseTime: number,
resourceType: string
): number {
const hour = new Date().getHours();
const isWeekend = [0, 6].includes(new Date().getDay());
// 피크 시간대 정의 (오전 9-11시, 오후 7-10시)
const isPeakHour = (hour >= 9 && hour <= 11) || (hour >= 19 && hour <= 22);
// 주말이면 캐시 시간 늘림 (트래픽 적음)
if (isWeekend) {
return baseTime * 2;
}
// 피크 시간에는 캐시 시간 줄임 (더 자주 업데이트)
if (isPeakHour) {
return Math.max(baseTime / 2, 60); // 최소 1분
}
// 새벽 시간 (0-6시)에는 캐시 시간 크게 늘림
if (hour >= 0 && hour < 6) {
return baseTime * 3;
}
return baseTime;
}
// 실제 사용 예시
export async function getAdaptiveProducts() {
const baseRevalidateTime = 600; // 기본 10분
const revalidateTime = getAdaptiveRevalidateTime(
baseRevalidateTime,
'products'
);
console.log(`🕐 현재 시각: ${new Date().toLocaleTimeString()}`);
console.log(`⏱️ 적응형 캐시 시간: ${revalidateTime}초`);
const response = await fetch('https://api.example.com/products', {
next: {
revalidate: revalidateTime,
tags: ['products']
}
});
return response.json();
}
계층적 캐싱 전략
데이터의 중요도와 변경 빈도에 따른 계층적 관리:
// app/_lib/hierarchical-cache.ts
interface CacheLevel {
pattern: RegExp;
revalidate: number;
tags: string[];
description: string;
}
// 캐시 레벨 정의
const CACHE_LEVELS: CacheLevel[] = [
{
pattern: /^\/api\/static\//,
revalidate: 86400, // 24시간
tags: ['static'],
description: '정적 콘텐츠 (회사 정보, 약관 등)'
},
{
pattern: /^\/api\/content\//,
revalidate: 3600, // 1시간
tags: ['content'],
description: '콘텐츠 (블로그, 뉴스 등)'
},
{
pattern: /^\/api\/products\//,
revalidate: 300, // 5분
tags: ['products', 'inventory'],
description: '제품 정보 (재고 포함)'
},
{
pattern: /^\/api\/realtime\//,
revalidate: 0, // 캐싱 안 함
tags: [],
description: '실시간 데이터'
}
];
// URL에 따라 적절한 캐시 레벨 자동 선택
export async function fetchWithHierarchy(url: string) {
// URL에 맞는 캐시 레벨 찾기
const cacheLevel = CACHE_LEVELS.find(level =>
level.pattern.test(url)
);
if (!cacheLevel) {
console.log('⚠️ 캐시 레벨 미정의, 기본값 사용');
return fetch(url); // 기본 캐싱
}
console.log(`📊 캐시 레벨: ${cacheLevel.description}`);
console.log(`⏱️ 재검증 시간: ${cacheLevel.revalidate}초`);
// 실시간 데이터는 캐싱 안 함
if (cacheLevel.revalidate === 0) {
return fetch(url, { cache: 'no-store' });
}
// 그 외는 설정된 레벨에 따라 캐싱
return fetch(url, {
next: {
revalidate: cacheLevel.revalidate,
tags: cacheLevel.tags
}
});
}
캐싱 디버깅과 모니터링
캐시 상태 확인하기
개발 중 캐시가 제대로 작동하는지 확인하는 방법:
// app/_lib/cache-debugger.ts
export class CacheDebugger {
private enabled = process.env.NODE_ENV === 'development';
async debugFetch(url: string, options?: any) {
if (!this.enabled) {
return fetch(url, options);
}
console.group(`🔍 캐시 디버깅: ${url}`);
// 요청 전 시간 기록
const startTime = performance.now();
// 캐시 옵션 로깅
if (options?.next?.revalidate !== undefined) {
console.log(`⏱️ Revalidate: ${options.next.revalidate}초`);
}
if (options?.next?.tags) {
console.log(`🏷️ Tags:`, options.next.tags);
}
if (options?.cache) {
console.log(`📦 Cache 모드:`, options.cache);
}
// 실제 fetch 수행
const response = await fetch(url, options);
// 응답 시간 계산
const duration = performance.now() - startTime;
// 캐시 상태 추론 (응답 시간 기반)
if (duration < 10) {
console.log('✅ 캐시 히트 (메모리 캐시)');
} else if (duration < 50) {
console.log('✅ 캐시 히트 (디스크 캐시)');
} else {
console.log('❌ 캐시 미스 (네트워크 요청)');
}
console.log(`⏱️ 응답 시간: ${duration.toFixed(2)}ms`);
console.groupEnd();
return response;
}
}
// 사용 예시
const debugger = new CacheDebugger();
export async function getDebuggedData() {
const data = await debugger.debugFetch(
'https://api.example.com/data',
{
next: {
revalidate: 60,
tags: ['data']
}
}
);
return data.json();
}
캐시 히트율 모니터링
실제 운영 환경에서 캐시 효율성 측정:
// app/_lib/cache-monitor.ts
class CacheMonitor {
private stats = {
hits: 0,
misses: 0,
errors: 0,
totalResponseTime: 0,
requestCount: 0
};
async monitoredFetch(url: string, options?: any) {
const startTime = performance.now();
try {
const response = await fetch(url, options);
const duration = performance.now() - startTime;
// 통계 업데이트
this.stats.requestCount++;
this.stats.totalResponseTime += duration;
// 캐시 히트/미스 판단 (응답 시간 기반)
if (duration < 50) {
this.stats.hits++;
} else {
this.stats.misses++;
}
// 주기적 리포트 (100번째 요청마다)
if (this.stats.requestCount % 100 === 0) {
this.printReport();
}
return response;
} catch (error) {
this.stats.errors++;
throw error;
}
}
printReport() {
const hitRate = (this.stats.hits / this.stats.requestCount * 100).toFixed(2);
const avgResponseTime = (this.stats.totalResponseTime / this.stats.requestCount).toFixed(2);
console.log('📊 캐시 성능 리포트');
console.log(`├─ 총 요청: ${this.stats.requestCount}`);
console.log(`├─ 캐시 히트: ${this.stats.hits}`);
console.log(`├─ 캐시 미스: ${this.stats.misses}`);
console.log(`├─ 히트율: ${hitRate}%`);
console.log(`├─ 평균 응답시간: ${avgResponseTime}ms`);
console.log(`└─ 에러: ${this.stats.errors}`);
}
}
export const cacheMonitor = new CacheMonitor();
실전 예시: 전자상거래 플랫폼
모든 캐싱 전략을 종합한 실제 전자상거래 사이트 구현:
// app/shop/page.tsx
import { Suspense } from 'react';
// 🏷️ 카테고리 데이터 - 거의 안 변함 (24시간 캐싱)
async function getCategories() {
console.log('📁 카테고리 데이터 페칭...');
const response = await fetch('https://api.shop.com/categories', {
next: {
revalidate: 86400, // 24시간
tags: ['categories']
}
});
return response.json();
}
// 🛍️ 주요 상품 - 정기적 업데이트 (1시간 캐싱)
async function getFeaturedProducts() {
console.log('⭐ 주요 상품 데이터 페칭...');
const response = await fetch('https://api.shop.com/products/featured', {
next: {
revalidate: 3600, // 1시간
tags: ['products', 'featured']
}
});
return response.json();
}
// ⚡ 플래시 세일 - 자주 변경 (5분 캐싱)
async function getFlashDeals() {
console.log('⚡ 플래시 딜 데이터 페칭...');
const response = await fetch('https://api.shop.com/deals/flash', {
next: {
revalidate: 300, // 5분
tags: ['deals', 'flash']
}
});
return response.json();
}
// 📊 재고 상태 - 실시간 (캐싱 안 함)
async function getInventoryStatus(productIds: string[]) {
console.log('📦 재고 상태 확인 중...');
const response = await fetch('https://api.shop.com/inventory/status', {
method: 'POST',
body: JSON.stringify({ productIds }),
cache: 'no-store' // 실시간 데이터
});
return response.json();
}
// 컴포넌트: 주요 상품 섹션
async function FeaturedSection() {
const products = await getFeaturedProducts();
// 제품 ID 추출
const productIds = products.map(p => p.id);
// 실시간 재고 확인
const inventory = await getInventoryStatus(productIds);
return (
<section className="featured-products">
<h2>주요 상품</h2>
<div className="product-grid">
{products.map(product => (
<ProductCard
key={product.id}
product={product}
inStock={inventory[product.id]?.available > 0}
/>
))}
</div>
</section>
);
}
// 메인 쇼핑 페이지
export default async function ShopPage() {
// 카테고리는 즉시 로드 (거의 변하지 않음)
const categories = await getCategories();
return (
<div className="shop-page">
{/* 카테고리 네비게이션 - 캐시된 데이터 */}
<nav className="category-nav">
{categories.map(cat => (
<CategoryLink key={cat.id} category={cat} />
))}
</nav>
{/* 주요 상품 - Suspense로 스트리밍 */}
<Suspense fallback={<ProductsSkeleton />}>
<FeaturedSection />
</Suspense>
{/* 플래시 딜 - 독립적 로딩 */}
<Suspense fallback={<DealsSkeleton />}>
<FlashDeals />
</Suspense>
</div>
);
}
// 관리자가 상품을 업데이트했을 때
// app/api/admin/products/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
export async function POST(request: Request) {
const { action, productId } = await request.json();
switch (action) {
case 'update':
// 제품 업데이트 로직
await updateProduct(productId);
// 관련 캐시 무효화
revalidateTag('products');
revalidateTag('featured'); // 주요 상품일 수도 있음
revalidatePath('/shop');
console.log('✅ 제품 캐시 재검증 완료');
break;
case 'price_change':
// 가격만 변경된 경우
revalidateTag('products');
revalidateTag('deals'); // 딜에도 영향
console.log('✅ 가격 관련 캐시 재검증 완료');
break;
}
return Response.json({ success: true });
}
모범 사례와 주의사항
DO’s (권장 사항)
- 데이터 특성에 맞는 캐싱 전략 선택
- 정적 콘텐츠: 긴 캐싱 시간 (24시간 이상)
- 자주 변경되는 콘텐츠: 짧은 캐싱 시간 (5-30분)
- 실시간 데이터: 캐싱 비활성화
- 태그를 활용한 세밀한 캐시 제어
- 관련 데이터를 그룹화하여 효율적 무효화
- 계층적 태그 구조로 유연한 관리
- On-demand revalidation 활용
- CMS 웹훅으로 즉시 업데이트
- 관리자 패널에서 수동 제어 옵션 제공
- 모니터링과 최적화
- 캐시 히트율 측정
- 응답 시간 추적
- 지속적 개선
DON’Ts (주의 사항)
- 과도한 캐싱 피하기
- 사용자별 개인화 데이터는 캐싱 주의
- 보안이 중요한 데이터는 캐싱 금지
- 캐시 무효화 타이밍 고려
- 동시에 너무 많은 캐시 무효화 피하기
- 점진적 무효화 전략 사용
- 캐시 의존성 주의
- 순환 의존성 방지
- 명확한 캐시 계층 구조 유지
결론
Next.js의 fetch 캐싱과 revalidate 옵션은 현대 웹 애플리케이션에서 성능과 데이터 최신성의 균형을 맞추는 강력한 도구입니다.
핵심 요점:
- 시간 기반 재검증으로 예측 가능한 업데이트 주기 설정
- 태그 기반 재검증으로 관련 데이터 그룹 관리
- On-demand 재검증으로 즉각적인 업데이트 보장
- 적응형 캐싱으로 상황에 맞는 최적화
성공적인 캐싱 전략의 핵심은 각 데이터의 특성을 이해하고, 적절한 재검증 메커니즘을 선택하며, 지속적으로 모니터링하고 개선하는 것입니다. 이를 통해 빠른 응답 속도와 최신 데이터를 동시에 제공하는 최적의 사용자 경험을 만들 수 있습니다.