[Next.js] fetch 캐싱 전략과 revalidate 옵션 활용




웹 애플리케이션에서 가장 중요한 두 가지 목표는 ‘빠른 응답 속도’와 ‘최신 데이터 제공’입니다. 하지만 이 둘은 서로 상충하는 관계에 있습니다. 캐싱을 통해 속도를 높이면 데이터가 오래될 수 있고, 항상 최신 데이터를 가져오려면 속도가 느려집니다.

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 (권장 사항)

  1. 데이터 특성에 맞는 캐싱 전략 선택
    • 정적 콘텐츠: 긴 캐싱 시간 (24시간 이상)
    • 자주 변경되는 콘텐츠: 짧은 캐싱 시간 (5-30분)
    • 실시간 데이터: 캐싱 비활성화
  2. 태그를 활용한 세밀한 캐시 제어
    • 관련 데이터를 그룹화하여 효율적 무효화
    • 계층적 태그 구조로 유연한 관리
  3. On-demand revalidation 활용
    • CMS 웹훅으로 즉시 업데이트
    • 관리자 패널에서 수동 제어 옵션 제공
  4. 모니터링과 최적화
    • 캐시 히트율 측정
    • 응답 시간 추적
    • 지속적 개선

DON’Ts (주의 사항)

  1. 과도한 캐싱 피하기
    • 사용자별 개인화 데이터는 캐싱 주의
    • 보안이 중요한 데이터는 캐싱 금지
  2. 캐시 무효화 타이밍 고려
    • 동시에 너무 많은 캐시 무효화 피하기
    • 점진적 무효화 전략 사용
  3. 캐시 의존성 주의
    • 순환 의존성 방지
    • 명확한 캐시 계층 구조 유지

결론

Next.js의 fetch 캐싱과 revalidate 옵션은 현대 웹 애플리케이션에서 성능과 데이터 최신성의 균형을 맞추는 강력한 도구입니다.

핵심 요점:

  • 시간 기반 재검증으로 예측 가능한 업데이트 주기 설정
  • 태그 기반 재검증으로 관련 데이터 그룹 관리
  • On-demand 재검증으로 즉각적인 업데이트 보장
  • 적응형 캐싱으로 상황에 맞는 최적화

성공적인 캐싱 전략의 핵심은 각 데이터의 특성을 이해하고, 적절한 재검증 메커니즘을 선택하며, 지속적으로 모니터링하고 개선하는 것입니다. 이를 통해 빠른 응답 속도와 최신 데이터를 동시에 제공하는 최적의 사용자 경험을 만들 수 있습니다.




댓글 남기기