CSR, SSR, SSG, ISR의 차이점을 이해하고 이커머스 서비스에서 최적의 렌더링 전략을 선택하는 방법을 학습합니다.
웹 애플리케이션의 렌더링 방식을 이해하는 것은 성능 최적화의 첫걸음입니다. 각 방식의 특징과 장단점을 비교해봅니다. 렌더링 전략은 사용자 경험, SEO, 서버 비용에 직접적인 영향을 미치므로 신중하게 선택해야 합니다.
렌더링은 React 컴포넌트를 브라우저가 이해할 수 있는 HTML로 변환하는 과정입니다. 이 과정이 어디서, 언제 일어나느냐에 따라 CSR, SSR, SSG로 구분됩니다.
렌더링 위치에 따른 분류
렌더링 시점에 따른 분류
| 방식 | 렌더링 시점 | 장점 | 단점 |
|---|---|---|---|
| CSR | 브라우저 (런타임) | 인터랙션 빠름, 서버 부하 적음 | 초기 로딩 느림, SEO 불리 |
| SSR | 서버 (요청 시) | 항상 최신 데이터, SEO 유리 | 서버 부하, TTFB 증가 |
| SSG | 빌드 시 | 가장 빠름, CDN 캐싱 | 데이터 갱신 어려움 |
CSR은 전통적인 React SPA(Single Page Application) 방식입니다. 서버는 빈 HTML과 JavaScript 번들만 전송하고, 브라우저에서 모든 렌더링이 이루어집니다.
CSR 동작 순서
// 전통적인 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은 매 요청마다 서버에서 HTML을 생성합니다. 사용자는 완성된 HTML을 받아 즉시 콘텐츠를 볼 수 있고, 이후 JavaScript가 로드되면 인터랙션이 가능해집니다.
SSR 동작 순서
// 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는 빌드 시점에 HTML을 미리 생성합니다. 생성된 HTML은 CDN에 캐싱되어 전 세계 어디서든 빠르게 제공됩니다. 가장 빠른 응답 속도를 제공합니다.
SSG 동작 순서
// 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에서 즉시 제공 → 가장 빠른 응답 (수 밀리초)| 지표 | CSR | SSR | SSG |
|---|---|---|---|
| TTFB | 빠름 | 느림 | 가장 빠름 |
| FCP | 느림 | 빠름 | 가장 빠름 |
| TTI | 보통 | 보통 | 빠름 |
| 서버 부하 | 낮음 | 높음 | 없음 |
TTFB: Time to First Byte, FCP: First Contentful Paint, TTI: Time to Interactive
이커머스 적용 가이드
SSR(Server-Side Rendering)은 매 요청마다 서버에서 페이지를 렌더링합니다. 항상 최신 데이터가 필요하거나, 사용자별로 다른 콘텐츠를 제공해야 할 때 사용합니다. Next.js App Router에서 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; // 캐싱 비활성화 → SSRdynamic = 'auto'기본값. Next.js가 자동으로 판단
dynamic = 'force-dynamic'항상 SSR. 모든 요청에서 새로 렌더링
dynamic = 'force-static'항상 SSG. 동적 함수 사용 시 에러 발생
dynamic = 'error'동적 함수 사용 시 빌드 에러 발생
재고는 실시간으로 변하므로 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>
);
}🔄 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이라고 해서 모든 것을 캐싱하지 않는 것은 아닙니다. 부분적으로 캐싱을 적용하여 성능을 개선할 수 있습니다.
// 하이브리드 캐싱 전략
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 사용 시 주의사항
SSR이 적합한 경우
SSG는 빌드 시점에 HTML을 생성하여 CDN에서 제공합니다. 가장 빠른 응답 속도를 제공하며, 자주 변경되지 않는 콘텐츠에 적합합니다. 이커머스에서는 상품 목록, 카테고리 페이지, 상품 상세 페이지 등에 활용됩니다.
빌드 시작
next build 명령 실행
데이터 페칭
API, DB에서 필요한 데이터 가져오기
HTML 생성
React 컴포넌트를 HTML로 렌더링
정적 파일 저장
.next/server/app/ 디렉토리에 HTML 저장
CDN 배포
전 세계 엣지 서버에 캐싱
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에서 즉시 제공 (수 밀리초)동적 라우트([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
// ... (모든 상품 페이지가 미리 생성됨)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// 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
// ...# 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가 적합한 경우
SSG 성능 이점
ISR은 SSG의 장점(빠른 응답)과 SSR의 장점(최신 데이터)을 결합한 하이브리드 방식입니다. 정적 페이지를 주기적으로 또는 요청 시 백그라운드에서 재생성합니다. 이커머스에서 가장 많이 사용되는 렌더링 전략입니다.
Stale-While-Revalidate 패턴
가장 간단한 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 설정
// 모든 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)시간이 아닌 이벤트 기반으로 재검증합니다. 상품 정보가 수정되면 즉시 해당 페이지만 재생성할 수 있습니다.
// 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 });
}// 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');
}
}| 페이지 | revalidate | 온디맨드 | 이유 |
|---|---|---|---|
| 홈페이지 | 3600 (1시간) | 프로모션 변경 시 | 자주 변경되지 않음 |
| 상품 목록 | 1800 (30분) | 상품 추가/삭제 시 | 새 상품 반영 필요 |
| 상품 상세 | 3600 (1시간) | 상품 수정 시 | 수정 시 즉시 반영 |
| 카테고리 | 3600 (1시간) | 카테고리 변경 시 | 구조 변경 드묾 |
| 베스트셀러 | 300 (5분) | - | 순위 자주 변동 |
ISR 활용 베스트 프랙티스
ISR의 장점
Partial Prerendering(PPR)은 Next.js의 혁신적인 렌더링 방식입니다. 하나의 페이지에서 정적 부분과 동적 부분을 자동으로 분리하여, 정적 셸은 즉시 제공하고 동적 콘텐츠는 스트리밍합니다.
실험적 기능
PPR은 Next.js 14부터 도입된 실험적 기능입니다. 프로덕션 사용 전 충분한 테스트가 필요합니다.
기존 방식의 한계
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;빌드 시 정적 셸 생성
Suspense 경계 외부의 정적 콘텐츠를 HTML로 생성
요청 시 정적 셸 즉시 전송
CDN에서 캐시된 정적 HTML을 즉시 응답
동적 콘텐츠 스트리밍
Suspense 내부의 동적 컴포넌트를 서버에서 렌더링하여 스트리밍
점진적 하이드레이션
각 부분이 도착하는 대로 인터랙티브하게 변환
// 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>
);
}| 특성 | SSG | SSR | PPR |
|---|---|---|---|
| 초기 응답 | 즉시 | 느림 | 즉시 |
| 동적 데이터 | 불가 | 가능 | 가능 |
| SEO | 최적 | 좋음 | 최적 |
| 사용자 경험 | 정적 | 대기 | 점진적 |
PPR 활용 시나리오
상황에 따라 적절한 렌더링 전략을 선택하는 것이 중요합니다. 잘못된 선택은 성능 저하, 사용자 경험 악화, 서버 비용 증가로 이어집니다. 다음 가이드를 참고하여 최적의 전략을 선택하세요.
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 | 이유 |
|---|---|---|---|
| 홈페이지 | ISR | 3600 (1시간) | 빠른 로딩 + 프로모션 반영 |
| 상품 목록 | SSG + ISR | 1800 (30분) | SEO + 새 상품 반영 |
| 상품 상세 | PPR / ISR | 태그 기반 | 정적 정보 + 동적 재고 |
| 카테고리 | SSG + ISR | 3600 (1시간) | 구조 변경 드묾 |
| 검색 결과 | SSR | - | 쿼리별 다른 결과 |
| 재고 현황 | SSR | - | 실시간 정확성 필수 |
| 장바구니 | CSR | - | 사용자별 상태 |
| 결제 페이지 | SSR | - | 보안 + 실시간 가격 |
| 마이페이지 | SSR | - | 인증 + 개인 데이터 |
| 주문 내역 | SSR | - | 인증 + 최신 상태 |
| 이용약관 | SSG | false | 거의 변경 없음 |
실제 프로젝트에서는 한 페이지 내에서 여러 렌더링 전략을 조합합니다. 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>
);
}핵심 원칙
실제 이커머스 프로젝트에서 렌더링 전략을 어떻게 조합하는지 완전한 예제를 통해 알아봅니다. 상품 목록은 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// 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>
);
}// 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>
);
}// 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'
// })
// })핵심 포인트 정리