🌐 웹개발

Partial Prerendering

부분 사전 렌더링

정적/동적 컨텐츠 혼합 렌더링. Next.js 14+ 실험 기능.

📖 상세 설명

Partial Prerendering(PPR, 부분 사전 렌더링)은 Next.js 14에서 도입된 실험적 렌더링 전략으로, 하나의 페이지에서 정적 콘텐츠와 동적 콘텐츠를 동시에 제공합니다. 기존에는 페이지 전체가 SSG(정적) 또는 SSR(동적)으로 렌더링되었지만, PPR은 이 두 가지를 하이브리드로 결합합니다.

PPR의 핵심 아이디어는 "정적 셸(Static Shell)"입니다. 페이지의 레이아웃, 네비게이션, 헤더 등 변하지 않는 부분은 빌드 시 미리 렌더링하여 CDN에 캐싱하고, 사용자 정보, 장바구니, 추천 상품 등 동적 부분은 요청 시 스트리밍으로 채워 넣습니다. 이를 통해 정적 콘텐츠의 빠른 TTFB와 동적 콘텐츠의 개인화를 모두 달성합니다.

기술적으로 PPR은 React의 Suspense와 스트리밍을 활용합니다. 동적 컴포넌트를 Suspense로 감싸면, 정적 셸이 먼저 전송되고 동적 부분은 로딩 UI를 표시한 후 준비되면 스트리밍으로 대체됩니다. 이 모든 과정이 하나의 HTTP 응답에서 일어나므로, 추가 라운드트립 없이 빠른 사용자 경험을 제공합니다.

PPR은 e-커머스 사이트에서 특히 유용합니다. 제품 상세 페이지의 이미지, 설명, 스펙은 정적으로 렌더링하고, 재고 상태, 가격, 사용자 리뷰 수 등은 동적으로 가져옵니다. 이렇게 하면 검색엔진은 제품 정보를 크롤링할 수 있고, 사용자는 최신 정보를 실시간으로 볼 수 있습니다.

💻 코드 예제

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

module.exports = nextConfig;

// === app/products/[id]/page.tsx - PPR 적용 페이지 ===
import { Suspense } from 'react';
import { ProductInfo } from './ProductInfo';
import { ProductPrice } from './ProductPrice';
import { ProductReviews } from './ProductReviews';
import { RecommendedProducts } from './RecommendedProducts';

// 정적 파라미터 생성 (빌드 시)
export async function generateStaticParams() {
    const products = await getProducts();
    return products.map((product) => ({ id: product.id }));
}

// 정적 메타데이터
export async function generateMetadata({ params }: { params: { id: string } }) {
    const product = await getProduct(params.id);
    return {
        title: product.name,
        description: product.description,
    };
}

export default async function ProductPage({ params }: { params: { id: string } }) {
    // 이 데이터 페칭은 빌드 시 실행 (정적 부분)
    const product = await getProduct(params.id);

    return (
        <div className="product-page">
            {/* 정적 부분 - 빌드 시 렌더링되어 CDN에 캐싱 */}
            <header>
                <h1>{product.name}</h1>
                <ProductInfo product={product} />
            </header>

            {/* 동적 부분 - 요청 시 스트리밍 */}
            <Suspense fallback={<PriceSkeleton />}>
                <ProductPrice productId={params.id} />
            </Suspense>

            {/* 정적 부분 */}
            <section className="description">
                {product.description}
            </section>

            {/* 동적 부분 - 사용자별 추천 */}
            <Suspense fallback={<RecommendationsSkeleton />}>
                <RecommendedProducts productId={params.id} />
            </Suspense>

            {/* 동적 부분 - 실시간 리뷰 */}
            <Suspense fallback={<ReviewsSkeleton />}>
                <ProductReviews productId={params.id} />
            </Suspense>
        </div>
    );
}

// === components/ProductPrice.tsx - 동적 컴포넌트 ===
// 이 컴포넌트는 cookies() 또는 headers()를 사용하므로 동적으로 렌더링
import { cookies } from 'next/headers';

export async function ProductPrice({ productId }: { productId: string }) {
    // cookies()를 사용하면 자동으로 동적 렌더링으로 전환
    const userRegion = cookies().get('region')?.value || 'KR';

    // 지역별 가격 조회 (실시간)
    const pricing = await fetch(
        `https://api.example.com/pricing/${productId}?region=${userRegion}`,
        { cache: 'no-store' } // 동적 데이터
    ).then(res => res.json());

    return (
        <div className="price-section">
            <span className="currency">{pricing.currency}</span>
            <span className="amount">{pricing.amount.toLocaleString()}</span>
            {pricing.discount > 0 && (
                <span className="discount">{pricing.discount}% 할인</span>
            )}
            <StockStatus available={pricing.inStock} />
        </div>
    );
}

// === components/RecommendedProducts.tsx - 개인화된 추천 ===
import { headers } from 'next/headers';

export async function RecommendedProducts({ productId }: { productId: string }) {
    // headers()를 사용하면 자동으로 동적 렌더링
    const headersList = headers();
    const userId = headersList.get('x-user-id');

    const recommendations = await fetch(
        `https://api.example.com/recommendations`,
        {
            method: 'POST',
            body: JSON.stringify({ productId, userId }),
            cache: 'no-store'
        }
    ).then(res => res.json());

    return (
        <section className="recommendations">
            <h2>추천 상품</h2>
            <div className="grid">
                {recommendations.map((item: Product) => (
                    <ProductCard key={item.id} product={item} />
                ))}
            </div>
        </section>
    );
}

// === 스켈레톤 컴포넌트 (로딩 상태) ===
function PriceSkeleton() {
    return (
        <div className="price-skeleton animate-pulse">
            <div className="h-8 w-32 bg-gray-700 rounded" />
        </div>
    );
}

function RecommendationsSkeleton() {
    return (
        <section className="recommendations-skeleton">
            <h2>추천 상품</h2>
            <div className="grid grid-cols-4 gap-4">
                {[1, 2, 3, 4].map(i => (
                    <div key={i} className="h-48 bg-gray-700 rounded animate-pulse" />
                ))}
            </div>
        </section>
    );
}

🗣️ 실무 대화 예시

PM

"상품 페이지가 SSG로 빌드되는데, 가격이나 재고는 실시간으로 보여줘야 해요. 전체 페이지를 SSR로 바꿔야 할까요?"

개발자

"PPR(Partial Prerendering)을 쓰면 됩니다. 상품 정보, 이미지, 설명 같은 정적 부분은 그대로 SSG로 캐싱하고, 가격과 재고만 Suspense로 감싸서 동적으로 스트리밍하면 돼요. TTFB는 빠르게 유지하면서 실시간 데이터도 보여줄 수 있어요."

동료

"PPR이랑 기존 ISR의 차이가 뭐예요?"

개발자

"ISR은 페이지 전체를 주기적으로 재생성하지만, PPR은 한 페이지 안에서 정적/동적 부분을 분리해요. ISR은 모든 사용자에게 같은 캐시된 페이지를 보여주지만, PPR은 정적 셸은 공유하면서 개인화된 동적 콘텐츠를 사용자별로 다르게 보여줄 수 있어요."

면접관

"어떤 컴포넌트가 동적으로 렌더링될지 Next.js는 어떻게 판단하나요?"

지원자

"cookies(), headers() 같은 동적 함수를 사용하거나, fetch에 cache: 'no-store' 옵션을 주면 해당 컴포넌트는 자동으로 동적 렌더링됩니다. Suspense 바운더리 안에 있으면 그 부분만 스트리밍으로 나중에 채워지고, 바깥 부분은 정적으로 사전 렌더링돼요."

⚠️ 주의사항

실험적 기능: PPR은 Next.js 14에서 도입된 실험적(experimental) 기능입니다. 프로덕션에서 사용하기 전에 안정성을 확인하고, 버전 업그레이드 시 변경 사항을 주의 깊게 살펴보세요.

Suspense 바운더리: 동적 컴포넌트는 반드시 Suspense로 감싸야 PPR이 제대로 작동합니다. Suspense 없이 동적 함수를 사용하면 페이지 전체가 동적 렌더링으로 전환되어 PPR의 이점을 잃습니다.

캐싱 전략: 정적 부분은 CDN에 캐싱되므로 빌드 후 변경이 필요하면 재배포가 필요합니다. 자주 변경되는 콘텐츠는 동적 컴포넌트로 분리하거나 ISR과 조합하여 사용하세요.

🔗 관련 용어

📚 더 배우기