Next.js 이론 6강

데이터 페칭과 캐싱 전략

Next.js의 확장된 fetch API와 다양한 캐싱 전략을 학습하여 최적의 데이터 관리 방법을 익힙니다.

1. 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: NN초마다 재검증주기적 업데이트
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 캐싱 핵심

  • 기본 캐싱: Server Component에서 자동 캐싱
  • no-store: 실시간 데이터에 사용
  • revalidate: ISR 구현
  • tags: 온디맨드 재검증

2. 캐싱 메커니즘

Next.js는 4가지 캐싱 레이어를 제공합니다. 각 레이어의 역할과 동작 방식을 이해하면 성능을 최적화할 수 있습니다.

4가지 캐싱 레이어

1. Request Memoization

같은 요청 중복 제거 (렌더링 중)

범위: 단일 렌더링

2. Data Cache

fetch 결과 캐싱 (서버)

범위: 요청 간 지속

3. Full Route Cache

렌더링된 HTML/RSC 캐싱

범위: 빌드 시 ~ 재검증

4. Router Cache

클라이언트 네비게이션 캐싱

범위: 세션 동안

Request Memoization

// 같은 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

// 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 });
}

캐싱 전략 선택

  • 정적 데이터: 기본 캐싱 사용
  • 주기적 업데이트: revalidate 사용
  • 실시간 데이터: no-store 사용
  • CMS 연동: tags + 웹훅

3. 병렬 데이터 페칭

여러 데이터를 동시에 가져오면 성능이 크게 향상됩니다. Promise.all과 Suspense를 활용한 병렬 페칭 패턴을 알아봅니다.

순차 vs 병렬

// ❌ 순차 페칭 (느림) - 총 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>;
}

Suspense로 스트리밍

// 핵심 데이터 먼저, 나머지는 스트리밍
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>
  );
}

병렬 페칭 패턴

  • Promise.all: 모든 데이터 필요할 때
  • Suspense: 점진적 로딩 (UX 향상)
  • Promise.allSettled: 부분 실패 허용

4. 캐시 계층 이해

Next.js는 여러 레벨의 캐시를 사용하여 성능을 최적화합니다. 각 캐시의 역할과 무효화 방법을 이해해야 합니다.

4가지 캐시 계층

1. Request Memoization

같은 렌더링 사이클에서 동일 요청 중복 제거

범위: 단일 요청 | 자동 적용

2. Data Cache

fetch 결과를 서버에 영구 저장 (재검증까지)

범위: 요청 간 지속 | revalidate로 제어

3. Full Route Cache

렌더링된 HTML과 RSC Payload 캐싱

범위: 빌드 시 ~ 재배포 | 정적 라우트만

4. Router Cache

클라이언트 측 RSC Payload 캐싱 (네비게이션)

범위: 세션 동안 | 자동 적용

Data Cache 동작

// 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로 프로덕션 테스트
  • • 헤더에서 캐시 상태 확인 가능

5. 재검증 전략

캐시된 데이터를 최신 상태로 유지하는 다양한 재검증 방법을 학습합니다. 시간 기반, 태그 기반, 경로 기반 재검증을 상황에 맞게 선택합니다.

시간 기반 재검증

// 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시간)
데이터 수정 시태그 기반상품 정보 수정
페이지 전체 갱신경로 기반카테고리 구조 변경

이커머스 재검증 팁

  • 상품 수정: revalidateTag로 즉시 반영
  • 재고 변경: no-store로 실시간 조회
  • 프로모션: 시간 기반으로 주기적 갱신

6. 이커머스 예제: 데이터 페칭 전략

실제 이커머스에서 사용하는 데이터 페칭 패턴을 구현합니다. 데이터 특성에 따라 적절한 캐싱 전략을 선택하는 것이 핵심입니다.

데이터전략이유
상품 목록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();
});

실시간 재고 (no-store)

// 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>
  );
}

상품 수정 API

// 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 });
}

전략 요약

상품 목록: revalidate + tags
상품 상세: tags (수정 시 즉시 반영)
재고/가격: no-store (실시간)
카테고리: force-cache (정적)

핵심 포인트

  • • 데이터 특성에 맞는 캐싱 전략 선택
  • • cache 함수로 중복 요청 방지
  • • 태그로 세밀한 캐시 제어