Next.js 이론 3강

SSR, SSG, ISR - 렌더링 전략 완전 정복

CSR, SSR, SSG, ISR의 차이점을 이해하고 이커머스 서비스에서 최적의 렌더링 전략을 선택하는 방법을 학습합니다.

1. CSR vs SSR vs SSG 비교

웹 애플리케이션의 렌더링 방식을 이해하는 것은 성능 최적화의 첫걸음입니다. 각 방식의 특징과 장단점을 비교해봅니다. 렌더링 전략은 사용자 경험, SEO, 서버 비용에 직접적인 영향을 미치므로 신중하게 선택해야 합니다.

렌더링이란?

렌더링은 React 컴포넌트를 브라우저가 이해할 수 있는 HTML로 변환하는 과정입니다. 이 과정이 어디서, 언제 일어나느냐에 따라 CSR, SSR, SSG로 구분됩니다.

렌더링 위치에 따른 분류

  • 클라이언트 렌더링: 브라우저에서 JavaScript로 HTML 생성
  • 서버 렌더링: 서버에서 HTML을 생성하여 전송

렌더링 시점에 따른 분류

  • 빌드 타임: 배포 전 미리 생성 (SSG)
  • 런타임: 요청 시 생성 (SSR, CSR)

렌더링 방식 상세 비교

방식렌더링 시점장점단점
CSR브라우저 (런타임)인터랙션 빠름, 서버 부하 적음초기 로딩 느림, SEO 불리
SSR서버 (요청 시)항상 최신 데이터, SEO 유리서버 부하, TTFB 증가
SSG빌드 시가장 빠름, CDN 캐싱데이터 갱신 어려움

CSR (Client-Side Rendering)

CSR은 전통적인 React SPA(Single Page Application) 방식입니다. 서버는 빈 HTML과 JavaScript 번들만 전송하고, 브라우저에서 모든 렌더링이 이루어집니다.

CSR 동작 순서

1브라우저가 서버에 페이지 요청
2서버가 빈 HTML + JavaScript 번들 전송
3브라우저가 JavaScript 다운로드 및 실행
4React가 DOM을 생성하고 화면에 렌더링
5useEffect에서 API 호출하여 데이터 페칭
// 전통적인 React SPA 방식 (CSR)
'use client';
import { useEffect, useState } from 'react';

interface Product {
  id: string;
  name: string;
  price: number;
}

export default function ProductsPage() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function fetchProducts() {
      try {
        const res = await fetch('/api/products');
        if (!res.ok) throw new Error('Failed to fetch');
        const data = await res.json();
        setProducts(data);
      } catch (err) {
        setError('상품을 불러오는데 실패했습니다.');
      } finally {
        setLoading(false);
      }
    }
    fetchProducts();
  }, []);

  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>{error}</div>;
  
  return (
    <div className="grid grid-cols-4 gap-4">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

⚠️ 검색 엔진 크롤러가 JavaScript를 실행하지 않으면 빈 페이지만 인덱싱됩니다.

SSR (Server-Side Rendering)

SSR은 매 요청마다 서버에서 HTML을 생성합니다. 사용자는 완성된 HTML을 받아 즉시 콘텐츠를 볼 수 있고, 이후 JavaScript가 로드되면 인터랙션이 가능해집니다.

SSR 동작 순서

1브라우저가 서버에 페이지 요청
2서버에서 데이터 페칭 (DB, API 호출)
3서버에서 React 컴포넌트를 HTML로 렌더링
4완성된 HTML을 브라우저에 전송
5브라우저가 HTML 표시 (사용자가 콘텐츠 확인 가능)
6JavaScript 로드 후 하이드레이션 (인터랙션 가능)
// Next.js SSR - 요청마다 서버에서 렌더링
// cache: 'no-store' 또는 동적 함수 사용 시 SSR

export default async function ProductsPage() {
  // 매 요청마다 새로운 데이터를 가져옴
  const res = await fetch('https://api.example.com/products', {
    cache: 'no-store'  // SSR 강제
  });
  const products = await res.json();

  // 서버에서 HTML로 렌더링되어 전송됨
  return (
    <div className="grid grid-cols-4 gap-4">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// 또는 동적 함수 사용으로 SSR 트리거
import { cookies, headers } from 'next/headers';

export default async function Page() {
  const cookieStore = cookies();  // 동적 함수 → SSR
  const userToken = cookieStore.get('token');
  // ...
}

SSG (Static Site Generation)

SSG는 빌드 시점에 HTML을 미리 생성합니다. 생성된 HTML은 CDN에 캐싱되어 전 세계 어디서든 빠르게 제공됩니다. 가장 빠른 응답 속도를 제공합니다.

SSG 동작 순서

1빌드 시 데이터 페칭 (DB, API 호출)
2React 컴포넌트를 HTML로 렌더링
3정적 HTML 파일로 저장
4CDN에 배포
5사용자 요청 시 CDN에서 즉시 제공
// Next.js SSG - 빌드 시 HTML 생성
// fetch의 기본 동작이 SSG (cache: 'force-cache')

export default async function ProductsPage() {
  // 빌드 시 한 번만 실행됨
  const res = await fetch('https://api.example.com/products');
  const products = await res.json();

  return (
    <div className="grid grid-cols-4 gap-4">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// 빌드 결과:
// .next/server/app/products.html (정적 파일)
// CDN에서 즉시 제공 → 가장 빠른 응답 (수 밀리초)

성능 지표 비교

지표CSRSSRSSG
TTFB빠름느림가장 빠름
FCP느림빠름가장 빠름
TTI보통보통빠름
서버 부하낮음높음없음

TTFB: Time to First Byte, FCP: First Contentful Paint, TTI: Time to Interactive

이커머스 적용 가이드

  • 상품 목록: SSG (빠른 로딩, SEO 중요)
  • 재고/가격: SSR (실시간 데이터 필수)
  • 장바구니: CSR (사용자별 상태, SEO 불필요)
  • 검색 결과: SSR (쿼리별 다른 결과)
  • 마이페이지: SSR (인증 필요, 개인 데이터)

2. SSR 심화

SSR(Server-Side Rendering)은 매 요청마다 서버에서 페이지를 렌더링합니다. 항상 최신 데이터가 필요하거나, 사용자별로 다른 콘텐츠를 제공해야 할 때 사용합니다. Next.js App Router에서 SSR을 트리거하는 다양한 방법을 알아봅니다.

SSR 트리거 조건

Next.js는 기본적으로 정적 렌더링(SSG)을 시도합니다. 다음 조건 중 하나라도 해당되면 자동으로 SSR로 전환됩니다.

// 1. cache: 'no-store' 옵션 사용
// 가장 명시적인 SSR 트리거 방법
const res = await fetch('https://api.example.com/data', { 
  cache: 'no-store' 
});

// 2. 동적 함수 사용
// cookies(), headers() 등을 사용하면 자동으로 SSR
import { cookies, headers } from 'next/headers';

export default async function Page() {
  const cookieStore = cookies();      // 동적 함수 → SSR
  const headersList = headers();      // 동적 함수 → SSR
  const userAgent = headersList.get('user-agent');
  // ...
}

// 3. searchParams 사용
// URL 쿼리 파라미터에 접근하면 SSR
export default async function SearchPage({
  searchParams,
}: {
  searchParams: { q?: string; page?: string }
}) {
  const query = searchParams.q;  // 동적 → SSR
  // ...
}

// 4. 동적 라우트 세그먼트 설정
// 페이지 레벨에서 강제로 동적 렌더링 설정
export const dynamic = 'force-dynamic';

// 5. revalidate = 0 설정
export const revalidate = 0;  // 캐싱 비활성화 → SSR

동적 세그먼트 옵션

dynamic = 'auto'

기본값. Next.js가 자동으로 판단

dynamic = 'force-dynamic'

항상 SSR. 모든 요청에서 새로 렌더링

dynamic = 'force-static'

항상 SSG. 동적 함수 사용 시 에러 발생

dynamic = 'error'

동적 함수 사용 시 빌드 에러 발생

이커머스 SSR 예제: 실시간 재고 현황

재고는 실시간으로 변하므로 SSR이 필수입니다. 사용자가 상품 페이지를 볼 때 항상 최신 재고 정보를 보여줘야 합니다.

// app/products/[id]/page.tsx
// 재고는 실시간으로 변하므로 SSR 필수

interface Stock {
  quantity: number;
  warehouse: string;
  lastUpdated: string;
}

async function getStock(productId: string): Promise<Stock> {
  const res = await fetch(
    `https://api.myshop.com/products/${productId}/stock`,
    { 
      cache: 'no-store',  // 항상 최신 데이터
      headers: {
        'Authorization': `Bearer ${process.env.API_KEY}`
      }
    }
  );
  
  if (!res.ok) {
    throw new Error('Failed to fetch stock');
  }
  
  return res.json();
}

async function getProduct(productId: string) {
  const res = await fetch(
    `https://api.myshop.com/products/${productId}`
    // 캐싱 가능 (상품 기본 정보는 자주 안 변함)
  );
  return res.json();
}

export default async function ProductPage({ 
  params 
}: { 
  params: { id: string } 
}) {
  // 병렬로 데이터 페칭 (성능 최적화)
  const [product, stock] = await Promise.all([
    getProduct(params.id),
    getStock(params.id)
  ]);

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold">{product.name}</h1>
      <p className="text-gray-600 mt-2">{product.description}</p>
      
      <div className="mt-6 p-4 bg-gray-100 rounded-lg">
        <h2 className="font-semibold mb-2">재고 현황</h2>
        {stock.quantity > 0 ? (
          <div>
            <p className="text-green-600 font-medium">
              재고 {stock.quantity}개 남음
            </p>
            <p className="text-sm text-gray-500">
              창고: {stock.warehouse}
            </p>
          </div>
        ) : (
          <p className="text-red-600 font-medium">품절</p>
        )}
        <p className="text-xs text-gray-400 mt-2">
          마지막 업데이트: {new Date(stock.lastUpdated).toLocaleString('ko-KR')}
        </p>
      </div>
      
      <div className="mt-6">
        <p className="text-2xl font-bold">
          {product.price.toLocaleString()}원
        </p>
        <button 
          className="mt-4 w-full bg-blue-600 text-white py-3 rounded-lg disabled:bg-gray-400"
          disabled={stock.quantity === 0}
        >
          {stock.quantity > 0 ? '장바구니에 담기' : '품절'}
        </button>
      </div>
    </div>
  );
}

SSR 성능 최적화 기법

🔄 Streaming과 Suspense 활용

느린 데이터 페칭을 Suspense로 감싸서 나머지 콘텐츠를 먼저 전송합니다. TTFB(Time to First Byte)를 크게 개선할 수 있습니다.

import { Suspense } from 'react';

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);  // 빠름

  return (
    <div>
      <h1>{product.name}</h1>
      
      {/* 느린 부분은 Suspense로 분리 */}
      <Suspense fallback={<StockSkeleton />}>
        <StockStatus productId={params.id} />
      </Suspense>
      
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={params.id} />
      </Suspense>
    </div>
  );
}

⚡ 병렬 데이터 페칭

독립적인 데이터는 Promise.all로 동시에 요청합니다. 순차 요청 대비 응답 시간을 크게 단축할 수 있습니다.

// ❌ 순차 요청 (느림)
const product = await getProduct(id);      // 200ms
const stock = await getStock(id);          // 150ms
const reviews = await getReviews(id);      // 300ms
// 총 650ms

// ✅ 병렬 요청 (빠름)
const [product, stock, reviews] = await Promise.all([
  getProduct(id),   // 200ms
  getStock(id),     // 150ms
  getReviews(id),   // 300ms
]);
// 총 300ms (가장 느린 요청 시간)

🌍 Edge Runtime 사용

사용자와 가까운 엣지 서버에서 실행하여 지연 시간을 줄입니다. 단, Node.js API 일부를 사용할 수 없습니다.

// Edge Runtime 설정
export const runtime = 'edge';

export default async function Page() {
  // 사용자와 가까운 엣지에서 실행
  const data = await fetch('https://api.example.com/data', {
    cache: 'no-store'
  });
  // ...
}

SSR 캐싱 전략

SSR이라고 해서 모든 것을 캐싱하지 않는 것은 아닙니다. 부분적으로 캐싱을 적용하여 성능을 개선할 수 있습니다.

// 하이브리드 캐싱 전략
export default async function ProductPage({ params }) {
  // 상품 기본 정보: 캐싱 (1시간)
  const product = await fetch(`/api/products/${params.id}`, {
    next: { revalidate: 3600 }
  });

  // 재고: 캐싱 안 함 (실시간)
  const stock = await fetch(`/api/products/${params.id}/stock`, {
    cache: 'no-store'
  });

  // 리뷰: 짧은 캐싱 (5분)
  const reviews = await fetch(`/api/products/${params.id}/reviews`, {
    next: { revalidate: 300 }
  });

  return (
    <div>
      <ProductInfo product={product} />
      <StockStatus stock={stock} />
      <Reviews reviews={reviews} />
    </div>
  );
}

SSR 사용 시 주의사항

  • 서버 부하 증가: 모든 요청마다 서버에서 렌더링하므로 트래픽이 많으면 서버 비용이 급증합니다.
  • TTFB 증가: 서버에서 렌더링 완료 후 응답하므로 첫 바이트까지 시간이 걸립니다.
  • Cold Start: 서버리스 환경에서 첫 요청 시 지연이 발생할 수 있습니다.
  • 에러 처리: 서버 에러 시 전체 페이지가 렌더링되지 않을 수 있습니다.

SSR이 적합한 경우

  • 재고 현황: 실시간 정확성이 중요
  • 실시간 가격: 프로모션, 환율 변동
  • 사용자별 맞춤 콘텐츠: 추천 상품, 최근 본 상품
  • 검색 결과 페이지: 쿼리별 다른 결과
  • 인증이 필요한 페이지: 마이페이지, 주문 내역
  • A/B 테스트: 사용자별 다른 버전 제공

3. SSG (Static Site Generation)

SSG는 빌드 시점에 HTML을 생성하여 CDN에서 제공합니다. 가장 빠른 응답 속도를 제공하며, 자주 변경되지 않는 콘텐츠에 적합합니다. 이커머스에서는 상품 목록, 카테고리 페이지, 상품 상세 페이지 등에 활용됩니다.

SSG의 동작 원리

1

빌드 시작

next build 명령 실행

2

데이터 페칭

API, DB에서 필요한 데이터 가져오기

3

HTML 생성

React 컴포넌트를 HTML로 렌더링

4

정적 파일 저장

.next/server/app/ 디렉토리에 HTML 저장

5

CDN 배포

전 세계 엣지 서버에 캐싱

기본 SSG 구현

Next.js App Router에서 fetch의 기본 동작은 SSG입니다. 특별한 설정 없이도 정적 페이지가 생성됩니다.

// app/products/page.tsx
// fetch의 기본 동작이 SSG (cache: 'force-cache')

interface Product {
  id: string;
  name: string;
  price: number;
  image: string;
  category: string;
}

async function getProducts(): Promise<Product[]> {
  const res = await fetch('https://api.myshop.com/products');
  // cache: 'force-cache'가 기본값 → SSG
  
  if (!res.ok) {
    throw new Error('Failed to fetch products');
  }
  
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();
  
  return (
    <div>
      <h1 className="text-3xl font-bold mb-8">전체 상품</h1>
      <div className="grid grid-cols-2 md:grid-cols-4 gap-6">
        {products.map(product => (
          <div key={product.id} className="border rounded-lg p-4">
            <img 
              src={product.image} 
              alt={product.name}
              className="w-full aspect-square object-cover rounded"
            />
            <h3 className="mt-2 font-medium">{product.name}</h3>
            <p className="text-lg font-bold">
              {product.price.toLocaleString()}원
            </p>
          </div>
        ))}
      </div>
    </div>
  );
}

// 빌드 시 실행 → 정적 HTML 생성 → CDN 배포
// 사용자 요청 시 CDN에서 즉시 제공 (수 밀리초)

generateStaticParams로 동적 경로 생성

동적 라우트([id], [slug] 등)의 경로를 빌드 시점에 미리 생성합니다. 이커머스에서 수천 개의 상품 페이지를 미리 생성할 수 있습니다.

// app/products/[id]/page.tsx

interface Product {
  id: string;
  name: string;
  description: string;
  price: number;
  images: string[];
}

// 빌드 시 생성할 경로 목록 반환
export async function generateStaticParams() {
  const res = await fetch('https://api.myshop.com/products');
  const products: Product[] = await res.json();

  // 각 상품 ID로 경로 생성
  // [{ id: '1' }, { id: '2' }, { id: '3' }, ...]
  return products.map((product) => ({
    id: product.id,
  }));
}

// 각 상품 페이지 데이터 페칭
async function getProduct(id: string): Promise<Product> {
  const res = await fetch(`https://api.myshop.com/products/${id}`);
  
  if (!res.ok) {
    throw new Error('Product not found');
  }
  
  return res.json();
}

// 메타데이터도 정적 생성
export async function generateMetadata({ 
  params 
}: { 
  params: { id: string } 
}) {
  const product = await getProduct(params.id);
  
  return {
    title: `${product.name} | MyShop`,
    description: product.description,
    openGraph: {
      images: [product.images[0]],
    },
  };
}

// 페이지 컴포넌트
export default async function ProductPage({ 
  params 
}: { 
  params: { id: string } 
}) {
  const product = await getProduct(params.id);

  return (
    <div className="max-w-6xl mx-auto p-6">
      <div className="grid md:grid-cols-2 gap-8">
        <div>
          <img 
            src={product.images[0]} 
            alt={product.name}
            className="w-full rounded-lg"
          />
        </div>
        <div>
          <h1 className="text-3xl font-bold">{product.name}</h1>
          <p className="mt-4 text-gray-600">{product.description}</p>
          <p className="mt-6 text-3xl font-bold">
            {product.price.toLocaleString()}원
          </p>
          <button className="mt-6 w-full bg-blue-600 text-white py-3 rounded-lg">
            장바구니에 담기
          </button>
        </div>
      </div>
    </div>
  );
}

// 빌드 결과:
// /products/1 → 정적 HTML
// /products/2 → 정적 HTML
// /products/3 → 정적 HTML
// ... (모든 상품 페이지가 미리 생성됨)

dynamicParams 옵션

generateStaticParams에 없는 경로로 접근했을 때의 동작을 제어합니다.

// dynamicParams 옵션 설정

// true (기본값): 없는 경로는 요청 시 동적 생성
export const dynamicParams = true;

// false: 없는 경로는 404 반환
export const dynamicParams = false;

// 실전 예제: 인기 상품 100개만 미리 생성
export async function generateStaticParams() {
  // 인기 상품 100개만 가져오기
  const res = await fetch('https://api.myshop.com/products/popular?limit=100');
  const products = await res.json();
  
  return products.map(p => ({ id: p.id }));
}

// dynamicParams = true (기본값)
// → 나머지 상품은 첫 요청 시 생성되고 캐싱됨

// dynamicParams = false
// → 미리 생성된 100개 외에는 404

카테고리별 상품 페이지 SSG

// app/categories/[category]/page.tsx

// 모든 카테고리 경로 생성
export async function generateStaticParams() {
  const categories = ['electronics', 'clothing', 'books', 'home', 'sports'];
  
  return categories.map((category) => ({
    category,
  }));
}

async function getProductsByCategory(category: string) {
  const res = await fetch(
    `https://api.myshop.com/products?category=${category}`
  );
  return res.json();
}

export default async function CategoryPage({ 
  params 
}: { 
  params: { category: string } 
}) {
  const products = await getProductsByCategory(params.category);
  
  const categoryNames: Record<string, string> = {
    electronics: '전자제품',
    clothing: '의류',
    books: '도서',
    home: '홈/리빙',
    sports: '스포츠',
  };

  return (
    <div>
      <h1 className="text-3xl font-bold mb-8">
        {categoryNames[params.category]}
      </h1>
      <div className="grid grid-cols-4 gap-4">
        {products.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

// 빌드 결과:
// /categories/electronics → 정적 HTML
// /categories/clothing → 정적 HTML
// /categories/books → 정적 HTML
// ...

SSG 빌드 출력 확인

# next build 실행 시 출력

Route (app)                    Size     First Load JS
┌ ○ /                          5.2 kB   89.2 kB
├ ○ /products                  3.1 kB   87.1 kB
├ ● /products/[id]             4.5 kB   88.5 kB
│   ├ /products/1
│   ├ /products/2
│   └ /products/3
├ ○ /categories/electronics    3.2 kB   87.2 kB
├ ○ /categories/clothing       3.2 kB   87.2 kB
└ λ /api/products              0 B      0 B

○  (Static)   정적 페이지 (SSG)
●  (SSG)      generateStaticParams로 생성
λ  (Dynamic)  동적 페이지 (SSR)

SSG가 적합한 경우

  • 상품 목록 페이지: 빠른 로딩, SEO 중요
  • 카테고리 페이지: 구조가 고정적
  • 상품 상세 페이지: 기본 정보는 자주 안 변함
  • 블로그/문서: 콘텐츠 변경 빈도 낮음
  • 마케팅 랜딩 페이지: 최대 성능 필요
  • FAQ, 이용약관: 거의 변경 없음

SSG 성능 이점

  • TTFB 최소화: CDN에서 즉시 응답 (10-50ms)
  • 서버 부하 없음: 정적 파일만 제공
  • 높은 가용성: CDN 장애에도 캐시된 콘텐츠 제공
  • 비용 절감: 서버 컴퓨팅 비용 없음

4. ISR (Incremental Static Regeneration)

ISR은 SSG의 장점(빠른 응답)과 SSR의 장점(최신 데이터)을 결합한 하이브리드 방식입니다. 정적 페이지를 주기적으로 또는 요청 시 백그라운드에서 재생성합니다. 이커머스에서 가장 많이 사용되는 렌더링 전략입니다.

ISR의 동작 원리

Stale-While-Revalidate 패턴

  1. 빌드 시 정적 HTML 생성
  2. 설정된 시간(revalidate) 동안 캐시된 HTML 제공
  3. 시간 경과 후 요청이 오면 캐시된 HTML 먼저 제공 (stale)
  4. 백그라운드에서 새 HTML 생성 (revalidate)
  5. 새 HTML 준비되면 캐시 교체
  6. 다음 요청부터 새 HTML 제공

시간 기반 재검증 (Time-based Revalidation)

가장 간단한 ISR 방식입니다. 설정된 시간이 지나면 자동으로 페이지를 재생성합니다.

// app/products/page.tsx

async function getProducts() {
  const res = await fetch('https://api.myshop.com/products', {
    next: { revalidate: 3600 }  // 1시간(3600초)마다 재검증
  });
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();
  
  return (
    <div>
      <h1>전체 상품</h1>
      <p className="text-sm text-gray-500">
        마지막 업데이트: {new Date().toLocaleString('ko-KR')}
      </p>
      <div className="grid grid-cols-4 gap-4">
        {products.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

// 동작 방식:
// 1. 빌드 시 정적 HTML 생성
// 2. 1시간 동안 캐시된 HTML 제공 (빠름)
// 3. 1시간 후 첫 요청 시:
//    - 기존 캐시 HTML 즉시 제공 (사용자는 기다리지 않음)
//    - 백그라운드에서 새 HTML 생성
// 4. 새 HTML 준비되면 캐시 교체
// 5. 다음 요청부터 새 HTML 제공

페이지 레벨 revalidate 설정

// 페이지 전체에 revalidate 설정
// 모든 fetch 요청에 동일한 revalidate 적용

export const revalidate = 3600;  // 1시간

export default async function ProductsPage() {
  // 이 페이지의 모든 fetch는 1시간 캐싱
  const products = await getProducts();
  const categories = await getCategories();
  
  return (
    <div>
      <CategoryNav categories={categories} />
      <ProductList products={products} />
    </div>
  );
}

// revalidate 값 예시:
// revalidate = 0      → SSR (캐싱 안 함)
// revalidate = 60     → 1분마다 재검증
// revalidate = 3600   → 1시간마다 재검증
// revalidate = 86400  → 24시간마다 재검증
// revalidate = false  → 무기한 캐싱 (SSG)

온디맨드 재검증 - 태그 기반 (Tag-based Revalidation)

시간이 아닌 이벤트 기반으로 재검증합니다. 상품 정보가 수정되면 즉시 해당 페이지만 재생성할 수 있습니다.

// 1. 데이터 페칭 시 태그 지정
// app/products/[id]/page.tsx

async function getProduct(id: string) {
  const res = await fetch(`https://api.myshop.com/products/${id}`, {
    next: { 
      tags: [`product-${id}`, 'products']  // 태그 지정
    }
  });
  return res.json();
}

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  return <ProductDetail product={product} />;
}

// 2. 상품 수정 시 해당 태그 재검증
// app/api/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 updateProductInDB(params.id, data);
  
  // 해당 상품 페이지만 재검증
  revalidateTag(`product-${params.id}`);
  
  return Response.json({ success: true });
}

// 3. 전체 상품 목록 재검증이 필요한 경우
export async function POST(request: Request) {
  // 새 상품 추가
  const data = await request.json();
  await createProductInDB(data);
  
  // 전체 상품 목록 재검증
  revalidateTag('products');
  
  return Response.json({ success: true });
}

온디맨드 재검증 - 경로 기반 (Path-based Revalidation)

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';

export async function POST(request: Request) {
  const { path, type } = await request.json();
  
  // 특정 경로만 재검증
  revalidatePath('/products');
  revalidatePath('/products/123');
  
  // 레이아웃 포함 전체 재검증
  revalidatePath('/products', 'layout');
  
  // 페이지만 재검증 (레이아웃 제외)
  revalidatePath('/products', 'page');
  
  return Response.json({ 
    revalidated: true,
    now: Date.now()
  });
}

// Webhook에서 호출하는 예시
// CMS에서 콘텐츠 수정 시 자동으로 재검증
export async function handleCMSWebhook(payload: any) {
  const { type, data } = payload;
  
  if (type === 'product.updated') {
    revalidatePath(`/products/${data.id}`);
  }
  
  if (type === 'product.created' || type === 'product.deleted') {
    revalidatePath('/products');
  }
}

이커머스 ISR 전략 예시

페이지revalidate온디맨드이유
홈페이지3600 (1시간)프로모션 변경 시자주 변경되지 않음
상품 목록1800 (30분)상품 추가/삭제 시새 상품 반영 필요
상품 상세3600 (1시간)상품 수정 시수정 시 즉시 반영
카테고리3600 (1시간)카테고리 변경 시구조 변경 드묾
베스트셀러300 (5분)-순위 자주 변동

ISR 활용 베스트 프랙티스

  • 시간 기반: 주기적으로 변하는 데이터 (베스트셀러, 추천)
  • 태그 기반: 특정 이벤트로 변하는 데이터 (상품 수정)
  • 경로 기반: 여러 페이지 동시 재검증 필요 시
  • 조합 사용: 시간 기반 + 온디맨드 함께 사용 권장

ISR의 장점

  • • SSG 수준의 빠른 응답 속도
  • • 데이터 변경 시 자동/수동 갱신 가능
  • • 서버 부하 최소화
  • • 빌드 시간 단축 (모든 페이지를 미리 생성할 필요 없음)

5. Partial Prerendering (PPR)

Partial Prerendering(PPR)은 Next.js의 혁신적인 렌더링 방식입니다. 하나의 페이지에서 정적 부분과 동적 부분을 자동으로 분리하여, 정적 셸은 즉시 제공하고 동적 콘텐츠는 스트리밍합니다.

실험적 기능

PPR은 Next.js 14부터 도입된 실험적 기능입니다. 프로덕션 사용 전 충분한 테스트가 필요합니다.

PPR이 해결하는 문제

기존 방식의 한계

  • • SSG: 동적 데이터를 포함할 수 없음
  • • SSR: 전체 페이지가 동적이 되어 느림
  • • 페이지 단위로만 렌더링 전략 선택 가능

PPR의 해결책

  • • 정적 부분: CDN에서 즉시 제공
  • • 동적 부분: Suspense 경계에서 스트리밍
  • • 컴포넌트 단위로 렌더링 전략 자동 결정

PPR 활성화

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    ppr: true,  // PPR 활성화
  },
};

module.exports = nextConfig;

// 또는 특정 레이아웃/페이지에서만 활성화
// app/products/layout.tsx
export const experimental_ppr = true;

PPR 동작 원리

1

빌드 시 정적 셸 생성

Suspense 경계 외부의 정적 콘텐츠를 HTML로 생성

2

요청 시 정적 셸 즉시 전송

CDN에서 캐시된 정적 HTML을 즉시 응답

3

동적 콘텐츠 스트리밍

Suspense 내부의 동적 컴포넌트를 서버에서 렌더링하여 스트리밍

4

점진적 하이드레이션

각 부분이 도착하는 대로 인터랙티브하게 변환

이커머스 PPR 예제

// app/products/[id]/page.tsx
import { Suspense } from 'react';

// 정적 데이터 (빌드 시 생성)
async function getProduct(id: string) {
  const res = await fetch(`https://api.myshop.com/products/${id}`);
  return res.json();
}

// 동적 데이터 (요청 시 페칭)
async function getStock(id: string) {
  const res = await fetch(`https://api.myshop.com/products/${id}/stock`, {
    cache: 'no-store'  // 동적 → Suspense 내부에서 스트리밍
  });
  return res.json();
}

async function getReviews(id: string) {
  const res = await fetch(`https://api.myshop.com/products/${id}/reviews`, {
    cache: 'no-store'
  });
  return res.json();
}

// 동적 컴포넌트들
async function StockStatus({ productId }: { productId: string }) {
  const stock = await getStock(productId);
  
  return (
    <div className="p-4 bg-gray-100 rounded-lg">
      {stock.quantity > 0 ? (
        <p className="text-green-600 font-medium">
          재고 {stock.quantity}개 남음
        </p>
      ) : (
        <p className="text-red-600 font-medium">품절</p>
      )}
    </div>
  );
}

async function ProductReviews({ productId }: { productId: string }) {
  const reviews = await getReviews(productId);
  
  return (
    <div className="mt-8">
      <h3 className="text-xl font-bold mb-4">
        리뷰 ({reviews.length}개)
      </h3>
      {reviews.map((review: any) => (
        <div key={review.id} className="border-b py-4">
          <p className="font-medium">{review.author}</p>
          <p className="text-gray-600">{review.content}</p>
        </div>
      ))}
    </div>
  );
}

// 스켈레톤 컴포넌트
function StockSkeleton() {
  return (
    <div className="p-4 bg-gray-100 rounded-lg animate-pulse">
      <div className="h-6 bg-gray-300 rounded w-32"></div>
    </div>
  );
}

function ReviewsSkeleton() {
  return (
    <div className="mt-8 space-y-4">
      <div className="h-8 bg-gray-200 rounded w-40"></div>
      {[1, 2, 3].map(i => (
        <div key={i} className="border-b py-4 animate-pulse">
          <div className="h-4 bg-gray-200 rounded w-24 mb-2"></div>
          <div className="h-4 bg-gray-200 rounded w-full"></div>
        </div>
      ))}
    </div>
  );
}

// 메인 페이지 컴포넌트
export default async function ProductPage({ 
  params 
}: { 
  params: { id: string } 
}) {
  // 정적 부분: 빌드 시 생성되어 CDN에서 즉시 제공
  const product = await getProduct(params.id);

  return (
    <div className="max-w-4xl mx-auto p-6">
      {/* 정적 콘텐츠: 즉시 표시 */}
      <h1 className="text-3xl font-bold">{product.name}</h1>
      <p className="mt-4 text-gray-600">{product.description}</p>
      
      <div className="mt-6">
        <img 
          src={product.images[0]} 
          alt={product.name}
          className="w-full rounded-lg"
        />
      </div>
      
      <p className="mt-6 text-3xl font-bold">
        {product.price.toLocaleString()}원
      </p>

      {/* 동적 콘텐츠: Suspense 경계에서 스트리밍 */}
      <div className="mt-6">
        <Suspense fallback={<StockSkeleton />}>
          <StockStatus productId={params.id} />
        </Suspense>
      </div>

      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={params.id} />
      </Suspense>
    </div>
  );
}

PPR vs 기존 방식 비교

특성SSGSSRPPR
초기 응답즉시느림즉시
동적 데이터불가가능가능
SEO최적좋음최적
사용자 경험정적대기점진적

PPR 활용 시나리오

  • 상품 상세: 기본 정보(정적) + 재고/리뷰(동적)
  • 대시보드: 레이아웃(정적) + 실시간 데이터(동적)
  • 검색 결과: 필터 UI(정적) + 결과 목록(동적)
  • 사용자 프로필: 공통 UI(정적) + 개인 정보(동적)

6. 렌더링 전략 선택 가이드

상황에 따라 적절한 렌더링 전략을 선택하는 것이 중요합니다. 잘못된 선택은 성능 저하, 사용자 경험 악화, 서버 비용 증가로 이어집니다. 다음 가이드를 참고하여 최적의 전략을 선택하세요.

의사결정 플로우차트

1️⃣ 데이터가 사용자별로 다른가?

Yes: 인증 필요? → SSR

Yes: 클라이언트 상태? → CSR

No: 다음 질문으로

2️⃣ 데이터가 얼마나 자주 변경되는가?

실시간 필요 (재고, 가격): SSR

분~시간 단위 (베스트셀러): ISR

일~주 단위 (상품 정보): ISR + 온디맨드

거의 안 변함 (약관, FAQ): SSG

3️⃣ SEO가 중요한가?

매우 중요 (상품, 블로그): SSG/ISR

중요 (검색 결과): SSR

불필요 (대시보드, 장바구니): CSR

4️⃣ 페이지에 정적/동적 부분이 혼재하는가?

Yes: PPR 또는 Streaming + Suspense

No: 위 기준에 따라 선택

이커머스 페이지별 렌더링 전략

페이지전략revalidate이유
홈페이지ISR3600 (1시간)빠른 로딩 + 프로모션 반영
상품 목록SSG + ISR1800 (30분)SEO + 새 상품 반영
상품 상세PPR / ISR태그 기반정적 정보 + 동적 재고
카테고리SSG + ISR3600 (1시간)구조 변경 드묾
검색 결과SSR-쿼리별 다른 결과
재고 현황SSR-실시간 정확성 필수
장바구니CSR-사용자별 상태
결제 페이지SSR-보안 + 실시간 가격
마이페이지SSR-인증 + 개인 데이터
주문 내역SSR-인증 + 최신 상태
이용약관SSGfalse거의 변경 없음

하이브리드 접근법

실제 프로젝트에서는 한 페이지 내에서 여러 렌더링 전략을 조합합니다. Next.js는 이를 자연스럽게 지원합니다.

한 페이지에서 여러 전략 조합

// 상품 상세 페이지 - 하이브리드 렌더링
import { Suspense } from 'react';

export default async function ProductPage({ params }) {
  // SSG/ISR: 상품 기본 정보 (캐싱)
  const product = await getProduct(params.id);

  return (
    <div>
      {/* 정적 부분: SSG로 빌드 시 생성 */}
      <ProductInfo product={product} />
      <ProductImages images={product.images} />
      
      {/* 동적 부분: SSR로 요청 시 렌더링 */}
      <Suspense fallback={<StockSkeleton />}>
        <StockStatus productId={params.id} />
      </Suspense>
      
      {/* 클라이언트 부분: CSR로 브라우저에서 렌더링 */}
      <AddToCartButton product={product} />
      
      {/* 동적 부분: 스트리밍 */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={params.id} />
      </Suspense>
    </div>
  );
}

성능 vs 신선도 트레이드오프

SSG
최고 성능 / 낮은 신선도
ISR
높은 성능 / 적절한 신선도
SSR
보통 성능 / 최고 신선도

핵심 원칙

  • 기본값은 SSG: 가능하면 정적으로 시작
  • 필요한 부분만 동적으로: 전체 페이지를 SSR로 만들지 말 것
  • Suspense 활용: 동적 부분을 분리하여 스트리밍
  • 측정 후 최적화: 실제 성능 데이터 기반으로 결정

7. 이커머스 예제: 하이브리드 렌더링 실전

실제 이커머스 프로젝트에서 렌더링 전략을 어떻게 조합하는지 완전한 예제를 통해 알아봅니다. 상품 목록은 ISR로, 재고는 SSR로, 장바구니는 CSR로 구현하는 하이브리드 접근법입니다.

프로젝트 구조

app/
├── page.tsx                    # 홈페이지 (ISR)
├── products/
│   ├── page.tsx                # 상품 목록 (ISR)
│   └── [id]/
│       └── page.tsx            # 상품 상세 (PPR/하이브리드)
├── categories/
│   └── [category]/
│       └── page.tsx            # 카테고리별 상품 (SSG + ISR)
├── search/
│   └── page.tsx                # 검색 결과 (SSR)
├── cart/
│   └── page.tsx                # 장바구니 (CSR)
└── api/
    ├── products/
    │   └── [id]/
    │       └── route.ts        # 상품 API
    └── revalidate/
        └── route.ts            # 재검증 API

상품 목록 페이지 (ISR)

// app/products/page.tsx
import { Suspense } from 'react';
import Link from 'next/link';
import { ProductCard } from '@/components/ProductCard';
import { StockBadge } from '@/components/StockBadge';

// 30분마다 재검증
export const revalidate = 1800;

interface Product {
  id: string;
  name: string;
  price: number;
  image: string;
  category: string;
}

async function getProducts(): Promise<Product[]> {
  const res = await fetch('https://api.myshop.com/products', {
    next: { 
      tags: ['products'],
      revalidate: 1800 
    }
  });
  
  if (!res.ok) {
    throw new Error('Failed to fetch products');
  }
  
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-3xl font-bold">전체 상품</h1>
        <p className="text-sm text-gray-500">
          총 {products.length}개 상품
        </p>
      </div>
      
      <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
        {products.map((product) => (
          <div key={product.id} className="relative group">
            <Link href={`/products/${product.id}`}>
              {/* ISR: 상품 기본 정보 */}
              <ProductCard product={product} />
            </Link>
            
            {/* SSR: 실시간 재고 (Streaming) */}
            <Suspense fallback={
              <div className="absolute top-2 right-2 w-16 h-6 bg-gray-200 animate-pulse rounded" />
            }>
              <StockBadge productId={product.id} />
            </Suspense>
          </div>
        ))}
      </div>
    </div>
  );
}

실시간 재고 컴포넌트 (SSR)

// components/StockBadge.tsx
interface Stock {
  quantity: number;
  status: 'in_stock' | 'low_stock' | 'out_of_stock';
}

async function getStock(productId: string): Promise<Stock> {
  const res = await fetch(
    `https://api.myshop.com/products/${productId}/stock`,
    { 
      cache: 'no-store'  // 항상 최신 데이터
    }
  );
  
  if (!res.ok) {
    return { quantity: 0, status: 'out_of_stock' };
  }
  
  return res.json();
}

export async function StockBadge({ productId }: { productId: string }) {
  const stock = await getStock(productId);

  if (stock.status === 'out_of_stock') {
    return (
      <span className="absolute top-2 right-2 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded">
        품절
      </span>
    );
  }

  if (stock.status === 'low_stock') {
    return (
      <span className="absolute top-2 right-2 bg-orange-500 text-white text-xs font-bold px-2 py-1 rounded">
        {stock.quantity}개 남음
      </span>
    );
  }

  // 재고 충분하면 표시 안 함
  return null;
}

상품 상세 페이지 (하이브리드)

// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { AddToCartButton } from '@/components/AddToCartButton';

// 인기 상품 100개 미리 생성
export async function generateStaticParams() {
  const res = await fetch('https://api.myshop.com/products/popular?limit=100');
  const products = await res.json();
  return products.map((p: any) => ({ id: p.id }));
}

// 나머지는 요청 시 생성
export const dynamicParams = true;

async function getProduct(id: string) {
  const res = await fetch(`https://api.myshop.com/products/${id}`, {
    next: { tags: [`product-${id}`] }
  });
  
  if (!res.ok) return null;
  return res.json();
}

// 메타데이터 생성
export async function generateMetadata({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);
  
  if (!product) {
    return { title: '상품을 찾을 수 없습니다' };
  }
  
  return {
    title: `${product.name} | MyShop`,
    description: product.description,
    openGraph: {
      title: product.name,
      description: product.description,
      images: [product.images[0]],
    },
  };
}

export default async function ProductPage({ 
  params 
}: { 
  params: { id: string } 
}) {
  const product = await getProduct(params.id);
  
  if (!product) {
    notFound();
  }

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="grid md:grid-cols-2 gap-8">
        {/* 정적: 상품 이미지 */}
        <div className="space-y-4">
          <img 
            src={product.images[0]} 
            alt={product.name}
            className="w-full rounded-lg"
          />
          <div className="grid grid-cols-4 gap-2">
            {product.images.slice(1).map((img: string, i: number) => (
              <img 
                key={i} 
                src={img} 
                alt={`${product.name} ${i + 2}`}
                className="w-full rounded cursor-pointer hover:opacity-80"
              />
            ))}
          </div>
        </div>
        
        <div>
          {/* 정적: 상품 정보 */}
          <h1 className="text-3xl font-bold">{product.name}</h1>
          <p className="mt-4 text-gray-600">{product.description}</p>
          
          <div className="mt-6">
            <p className="text-3xl font-bold text-blue-600">
              {product.price.toLocaleString()}원
            </p>
            {product.originalPrice && (
              <p className="text-lg text-gray-400 line-through">
                {product.originalPrice.toLocaleString()}원
              </p>
            )}
          </div>
          
          {/* 동적: 실시간 재고 */}
          <div className="mt-6">
            <Suspense fallback={
              <div className="h-12 bg-gray-200 animate-pulse rounded" />
            }>
              <StockInfo productId={params.id} />
            </Suspense>
          </div>
          
          {/* CSR: 장바구니 버튼 */}
          <div className="mt-6">
            <AddToCartButton product={product} />
          </div>
        </div>
      </div>
      
      {/* 동적: 리뷰 (스트리밍) */}
      <div className="mt-12">
        <Suspense fallback={<ReviewsSkeleton />}>
          <ProductReviews productId={params.id} />
        </Suspense>
      </div>
    </div>
  );
}

온디맨드 재검증 API

// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const { type, id, secret } = await request.json();
  
  // 보안: 시크릿 키 검증
  if (secret !== process.env.REVALIDATE_SECRET) {
    return Response.json({ error: 'Invalid secret' }, { status: 401 });
  }
  
  try {
    switch (type) {
      case 'product':
        // 특정 상품만 재검증
        revalidateTag(`product-${id}`);
        break;
        
      case 'products':
        // 전체 상품 목록 재검증
        revalidateTag('products');
        revalidatePath('/products');
        break;
        
      case 'category':
        // 특정 카테고리 재검증
        revalidatePath(`/categories/${id}`);
        break;
        
      default:
        return Response.json({ error: 'Invalid type' }, { status: 400 });
    }
    
    return Response.json({ 
      revalidated: true, 
      type,
      id,
      timestamp: new Date().toISOString()
    });
  } catch (error) {
    return Response.json({ error: 'Revalidation failed' }, { status: 500 });
  }
}

// CMS나 관리자 페이지에서 호출
// fetch('/api/revalidate', {
//   method: 'POST',
//   body: JSON.stringify({ 
//     type: 'product', 
//     id: '123',
//     secret: 'your-secret-key'
//   })
// })

렌더링 전략 요약

상품 목록 레이아웃: SSG (빌드 시 생성)
상품 기본 정보: ISR (30분 재검증 + 태그 기반 온디맨드)
재고 현황: SSR (cache: no-store, 실시간)
리뷰: SSR + Streaming (Suspense로 분리)
장바구니 버튼: CSR (클라이언트 상태)

핵심 포인트 정리

  • 기본은 정적: SSG/ISR로 시작하여 최대 성능 확보
  • 동적은 분리: Suspense로 동적 부분만 SSR
  • 온디맨드 재검증: 데이터 변경 시 즉시 반영
  • 클라이언트 상태: 사용자별 데이터는 CSR
  • 측정과 개선: Core Web Vitals 모니터링