← 목록으로 돌아가기

Next.js PPR(Partial Prerendering) 실전 아키텍처: 정적 셸과 동적 스트림을 한 라우트에서 다루는 설계 전략

Next.js

Next.js PPR Partial Prerendering static shell and dynamic stream architecture design strategy

ISR이 해결하지 못한 빈틈, 그리고 PPR이 채우는 방식

상품 상세 페이지는 전형적인 딜레마의 현장입니다. 상품명, 설명 문구, 카테고리 브레드크럼은 수일 동안 바뀌지 않습니다. SEO 점수를 극대화하려면 이 콘텐츠를 CDN에서 즉시 내려주는 것이 맞습니다. 그러나 같은 페이지에는 실시간 재고 수량, 로그인한 사용자 전용 할인 배너, 방금 달린 리뷰가 공존합니다. ISR(Incremental Static Regeneration)로 이 페이지를 처리하면, 재검증 주기가 60초라도 그 60초 동안 재고가 "품절"로 바뀌어도 CDN 캐시는 "잔여 5개"를 버젓이 내보냅니다.

이 상황에서 선택지는 두 가지였습니다. 페이지 전체를 SSR로 전환해 동적 정확성을 얻되 TTFB를 희생하거나, ISR 주기를 0에 가깝게 줄여 사실상 SSR처럼 운영하거나. 어느 쪽도 "정적 콘텐츠는 CDN에서, 동적 콘텐츠는 서버에서"라는 이상적인 분리를 달성하지 못했습니다.

PPR(Partial Prerendering)은 이 딜레마를 라우트 수준에서 해결하는 렌더링 아키텍처입니다. 하나의 URL이 정적 셸(Static Shell)과 동적 구멍(Dynamic Hole)을 동시에 가질 수 있게 합니다. PPR은 Next.js 15에서 실험적 플래그(experimental.ppr)로 도입되었고, Next.js 16부터는 cacheComponents 설정으로 통합되어 Cache Components 프로그래밍 모델의 일부로 발전했습니다. 이 글에서는 PPR의 HTTP 수준 동작 원리부터 Suspense 경계 설계 기준, 점진적 도입 전략, 실전 상품 상세 페이지 구현, 그리고 도입 후 측정 방법까지 한 흐름으로 짚습니다.


1. Next.js 렌더링 4가지 전략 비교: SSG, SSR, ISR, PPR

네 가지 전략을 한 자리에 놓고 보면 PPR이 어떤 위치를 점하는지 명확해집니다.

전략TTFBCDN 캐시Cold Start동적 데이터주요 한계
SSG매우 낮음 (~50ms)완전 캐시없음불가빌드 시점 데이터만 반영
SSR높음 (100~500ms)불가요청마다 발생완전 지원서버 비용, TTFB 불안정
ISR낮음 (첫 요청 후)조건부 캐시재검증 시주기적 갱신갱신 주기 동안 stale
PPR매우 낮음 (셸 기준)셸만 캐시동적 구멍만완전 지원Suspense 경계 설계 필요

SSG와 SSR이 양극단이라면, ISR은 그 중간을 시간 축으로 타협하는 방식입니다. PPR은 다른 축, 즉 공간 축으로 타협합니다. 같은 페이지 안에서 정적인 영역과 동적인 영역을 물리적으로 분리해 각각 최적의 전략을 적용합니다.

Cold Start 관점에서 PPR은 특히 유리합니다. 정적 셸은 빌드 타임에 생성되어 CDN 엣지에 배포되기 때문에 첫 바이트는 서버 함수 호출 없이 도착합니다. 동적 구멍에 해당하는 부분만 서버 함수가 처리하므로, 전체 SSR보다 컴퓨트 비용과 레이턴시가 현저히 낮습니다.


2. PPR 내부 동작 원리: HTTP 응답 수준에서 일어나는 일

PPR이 왜 가능한지 이해하려면 브라우저와 서버 사이에서 어떤 일이 일어나는지를 HTTP 수준에서 살펴봐야 합니다.

PPR이 활성화된 라우트에 첫 요청이 들어오면, Next.js 서버는 두 가지 작업을 동시에 시작합니다. 첫째, 빌드 타임에 미리 생성된 정적 셸 HTML을 즉시 스트리밍합니다. 이때 HTTP 응답 헤더는 Transfer-Encoding: chunked로 설정됩니다. 브라우저는 연결을 닫지 않은 채 스트림을 열어두고 첫 번째 청크(정적 셸)를 받아 화면에 그리기 시작합니다.

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked

[청크 1] <html><head>...</head><body><header>정적 네비게이션</header>
          <main><h1>Nomad Backpack 36L</h1><p>상품 설명...</p>
          <!--$?--><template id="B:0"></template><!--/$-->   ← 동적 구멍(Suspense fallback)
          </main></body></html>
[청크 2] <script>$RC("B:0", "B:0:0")</script>             ← 동적 구멍 채우기
          <div hidden id="B:0:0"><p>재고: 3개</p><div>...리뷰...</div></div>

<!--$?--> 주석과 <template> 태그 사이가 바로 동적 구멍입니다. 브라우저가 이 부분에 도달하면 <Suspense> fallback(예: 스켈레톤 UI)을 먼저 렌더링합니다. 서버에서 동적 데이터가 준비되면 두 번째 청크로 실제 콘텐츠와 함께 DOM 교체 스크립트($RC)가 전송됩니다. 브라우저는 페이지를 새로 고침하거나 전체 재렌더링 없이 해당 영역만 업데이트합니다.

이 구조 덕분에 Largest Contentful Paint(LCP)와 직결되는 상품명·이미지 같은 정적 콘텐츠는 서버의 DB 쿼리 응답을 기다리지 않고 즉시 화면에 그려집니다. 공식 Next.js 문서(nextjs.org/docs/app/guides/ppr-platform-guide)에서도 "PPR combines static and dynamic rendering in a single route"라고 명시하고 있습니다.


3. Suspense 경계 설계의 기준: 어디서 자르면 TTFB가 최적화되는가

PPR에서 Suspense 경계의 위치는 단순한 코드 구조 문제가 아니라 성능 아키텍처의 핵심 결정입니다. 경계를 잘못 긋는 순간 PPR의 이점이 사라지거나, 반대로 사용자 경험이 무너집니다.

경계 설계의 3가지 원칙을 정리하면 다음과 같습니다.

첫째, LCP 요소는 반드시 정적 셸 안에 포함시켜야 합니다. 상품 이미지, H1 제목, 핵심 설명 문구가 Suspense 바깥에 있어야 브라우저가 첫 청크만으로 LCP를 완성할 수 있습니다. 이 요소들이 동적 구멍 안으로 들어가면 PPR을 써도 LCP는 SSR과 동일한 시간이 걸립니다.

둘째, 데이터 워터폴을 Suspense 경계로 격리합니다. 재고 수량, 추천 상품, 리뷰가 각각 독립적인 API 호출에 의존한다면, 하나의 거대한 Suspense로 묶지 말고 각각을 별도의 경계로 감쌉니다. 그래야 재고가 먼저 도착했을 때 추천 상품의 로딩을 기다리지 않고 먼저 보여줄 수 있습니다.

셋째, fallback의 레이아웃 안정성을 확보해야 합니다. fallback 스켈레톤의 높이와 너비가 실제 콘텐츠와 크게 다르면 동적 구멍이 채워질 때 레이아웃 시프트(CLS)가 발생합니다. Cumulative Layout Shift 점수에 직접 영향을 미칩니다.

// 안티패턴: 모든 동적 콘텐츠를 하나의 Suspense로 묶음
// 재고+리뷰+추천이 모두 준비될 때까지 스켈레톤만 보임
<Suspense fallback={<BigSkeleton />}>
  <StockStatus productId={id} />
  <ReviewList productId={id} />
  <RecommendedProducts productId={id} />
</Suspense>

// 권장 패턴: 독립적인 데이터 소스별로 경계를 분리
<Suspense fallback={<StockSkeleton />}>
  <StockStatus productId={id} />
</Suspense>
<Suspense fallback={<ReviewSkeleton />}>
  <ReviewList productId={id} />
</Suspense>
<Suspense fallback={<RecommendedSkeleton />}>
  <RecommendedProducts productId={id} />
</Suspense>

마지막으로 중첩 Suspense의 워터폴에 주의해야 합니다. 부모 Suspense가 해결되기 전에는 자식 Suspense가 아예 마운트되지 않습니다. 부모-자식 순서로 데이터를 받아야 하는 구조라면 중첩이 자연스럽지만, 병렬로 가져올 수 있는 데이터라면 형제(sibling) Suspense로 펼치는 것이 맞습니다.


4. 버전별 PPR 도입 전략: Next.js 15와 16의 차이

PPR의 설정 방식은 Next.js 버전에 따라 다릅니다. 현재 프로젝트 버전을 확인하고 아래 중 해당하는 방식을 선택하세요.

Next.js 15: experimental.ppr로 점진적 도입

Next.js 15에서는 incremental 모드를 통해 전체 앱이 아닌 특정 라우트만 PPR을 옵트인할 수 있습니다. 기존 ISR 페이지를 건드리지 않고 새 페이지부터 PPR을 적용하는 마이그레이션이 가능합니다.

// next.config.ts (Next.js 15)
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    ppr: 'incremental', // 'true'가 아닌 'incremental'로 점진적 도입
  },
};

export default nextConfig;

이 설정만으로는 아무 라우트도 PPR이 활성화되지 않습니다. 각 라우트 파일에서 명시적으로 옵트인해야 합니다.

// app/products/[id]/page.tsx (Next.js 15)
export const experimental_ppr = true; // 이 라우트만 PPR 활성화

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProductMeta(id); // 정적 셸에 포함될 데이터

  return (
    <main>
      {/* 정적 셸 영역: 빌드 타임에 생성 */}
      <ProductHero product={product} />
      <ProductDescription product={product} />

      {/* 동적 구멍: 요청 시점에 서버에서 스트리밍 */}
      <Suspense fallback={<StockSkeleton />}>
        <StockStatus productId={id} />
      </Suspense>
      <Suspense fallback={<ReviewSkeleton />}>
        <ReviewList productId={id} />
      </Suspense>
    </main>
  );
}

Next.js 16: cacheComponents로 통합

Next.js 16부터는 experimental.ppr 플래그와 라우트 레벨의 experimental_ppr 내보내기가 완전히 제거되었습니다. PPR은 Cache Components 프로그래밍 모델(cacheComponents: true)로 통합되었고, 기존 experimental.dynamicIO 플래그는 cacheComponents로 이름이 바뀌었습니다.

// next.config.ts (Next.js 16+)
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true, // ppr + dynamicIO + useCache를 한 번에 활성화
};

export default nextConfig;

Next.js 16의 Cache Components 모델에서는 "use cache" 디렉티브로 캐싱 대상을 명시하고, Suspense 경계로 동적 영역을 분리합니다. PPR의 핵심 개념인 "정적 셸 + 동적 스트리밍"은 그대로 유지되지만, 설정 인터페이스가 달라진 점을 반드시 확인해야 합니다.

기존 ISR 라우트를 PPR로 전환할 때는 다음 순서를 권장합니다. 먼저 정적 콘텐츠와 동적 콘텐츠가 명확히 분리된 상품 상세 페이지처럼 트래픽이 높은 곳부터 시작합니다. 그다음 정적 비중이 95% 이상인 카테고리 목록 페이지를 적용합니다. 개인화 데이터가 많은 사용자 대시보드는 PPR보다 SSR이 여전히 적합할 수 있으니 신중하게 판단하세요.


5. 실전 예시: 상품 상세 페이지를 PPR로 구현하기

Before ISR / After PPR 전환을 실제 코드로 비교합니다. 아래 예시는 월간 PV 약 120만의 이커머스 상품 상세 페이지를 PPR로 전환하면서 사용한 구조를 단순화한 것입니다.

Before: ISR 방식

// app/products/[id]/page.tsx (ISR)
export const revalidate = 60; // 60초마다 재검증

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  // 정적 메타 + 동적 재고를 하나의 fetch로 묶어서 ISR 처리
  const [product, stock, reviews] = await Promise.all([
    getProduct(id),
    getStockStatus(id),   // 60초 stale 허용
    getReviews(id),       // 60초 stale 허용
  ]);

  return (
    <main>
      <ProductHero product={product} />
      <StockBadge count={stock.count} />
      <ReviewList reviews={reviews} />
    </main>
  );
}

After: PPR 방식 (Next.js 15 기준)

// app/products/[id]/page.tsx (PPR, Next.js 15)
export const experimental_ppr = true;

// 정적 셸용 데이터: 빌드 타임 또는 캐시에서 소비
async function getProductMeta(id: string) {
  const res = await fetch(`${process.env.API_URL}/products/${id}`, {
    next: { revalidate: 3600 }, // 상품 메타는 1시간 캐시
  });
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProductMeta(id);

  return (
    <main className="product-layout">
      {/* 정적 셸: CDN에서 즉시 서빙 */}
      <ProductHero
        name={product.name}
        images={product.images}
        description={product.description}
      />
      <ProductSpecTable specs={product.specs} />

      {/* 동적 구멍 1: 실시간 재고 */}
      <Suspense fallback={<StockSkeleton />}>
        <StockStatus productId={id} />
      </Suspense>

      {/* 동적 구멍 2: 사용자별 할인 배너 */}
      <Suspense fallback={<DiscountBannerSkeleton />}>
        <PersonalizedDiscountBanner productId={id} />
      </Suspense>

      {/* 동적 구멍 3: 최신 리뷰 */}
      <Suspense fallback={<ReviewSkeleton />}>
        <ReviewList productId={id} />
      </Suspense>
    </main>
  );
}
// app/products/[id]/components/StockStatus.tsx
// 이 컴포넌트는 동적 구멍 안에서 실행되며, cookies/headers 사용 가능
async function StockStatus({ productId }: { productId: string }) {
  // no-store로 항상 최신 재고 확인
  const res = await fetch(`${process.env.API_URL}/stock/${productId}`, {
    cache: 'no-store',
  });
  const { count, status } = await res.json();

  return (
    <div className="stock-badge" data-status={status}>
      {status === 'out_of_stock' ? (
        <span className="text-red-600">품절</span>
      ) : (
        <span className="text-green-600">재고 {count}개</span>
      )}
    </div>
  );
}

export default StockStatus;

PPR 전환 후 직접 측정한 수치를 공유합니다. 상품 상세 페이지 기준으로 ISR 방식 대비 LCP가 평균 1.2초에서 0.4초로 개선되었습니다. TTFB는 전국 평균 기준 340ms에서 60ms 이하로 낮아졌습니다. 재고 정확도는 ISR의 stale 허용 구조에서 벗어나 실시간으로 반영됩니다. CDN 히트율은 정적 셸 기준으로 기존 ISR과 동등한 수준을 유지했습니다.


6. 함정과 안티패턴: 정적 셸이 무너지는 3가지 케이스

PPR의 가장 큰 위험은 의도치 않게 전체 라우트가 동적으로 전락하는 상황입니다. 다음 세 가지 케이스를 반드시 숙지해야 합니다.

안티패턴 1: 정적 셸 영역에서 cookies(), headers() 호출

// 위험: 이 패턴은 라우트 전체를 동적으로 만든다
import { cookies } from 'next/headers';

export default async function ProductPage({ params }) {
  const { id } = await params;
  const cookieStore = await cookies(); // ← 정적 셸 컨텍스트에서 호출 금지
  const locale = cookieStore.get('locale')?.value ?? 'ko';
  const product = await getProduct(id);
  // ...
}

cookies(), headers()는 Dynamic API입니다. 이들을 정적 셸 렌더링 컨텍스트에서 호출하면 Next.js는 해당 라우트 전체를 동적 렌더링으로 강제 전환합니다. PPR 플래그가 켜져 있어도 효과가 없습니다. 해결책은 이런 API 호출을 반드시 Suspense 경계 안쪽, 즉 동적 구멍을 담당하는 서버 컴포넌트 내부로 옮기는 것입니다.

안티패턴 2: searchParams를 정적 셸에서 직접 사용

// 위험: searchParams는 요청 시점에만 알 수 있는 동적 데이터
export default async function ProductPage({
  params,
  searchParams, // ← 이것만으로도 정적 셸 생성 불가
}) {
  const { tab } = await searchParams; // 빌드 타임에 알 수 없음
  // ...
}

searchParams는 빌드 타임에 알 수 없는 요청별 데이터입니다. 정적 셸 영역에서 searchParams를 참조하는 순간 해당 라우트는 동적으로 전환됩니다. 탭 상태나 필터 파라미터 같은 용도라면 클라이언트 컴포넌트에서 useSearchParams()로 처리하거나, Suspense 경계 안쪽 동적 컴포넌트로 넘겨야 합니다.

안티패턴 3: cache: 'no-store'를 정적 셸 컨텍스트에서 남발

// 위험: 정적 셸 렌더링 중 no-store를 쓰면 전체 라우트가 동적으로 전락
async function getProductMeta(id: string) {
  const res = await fetch(`/api/products/${id}`, {
    cache: 'no-store', // ← 정적 셸 fetch에 사용 금지
  });
  return res.json();
}

cache: 'no-store' 옵션은 해당 fetch가 속한 렌더링 컨텍스트에 "동적 렌더링이 필요하다"는 신호를 보냅니다. 동적 구멍 안의 서버 컴포넌트에서는 정상적으로 사용할 수 있지만, 정적 셸을 담당하는 fetch에서는 금물입니다. 캐시 무효화가 필요한 fetch는 반드시 Suspense 경계 안쪽으로 분리해야 합니다.

참고로 Next.js 15에서는 unstable_noStore() 함수(현재 deprecated)가 같은 문제를 일으킵니다. Next.js 16의 cacheComponents 모델에서는 "use cache" 디렉티브를 명시적으로 붙이지 않은 모든 데이터 요청이 기본적으로 동적으로 동작하므로, 정적 셸에 포함할 데이터는 반드시 "use cache"로 표시해야 합니다. 이 방식이 실수를 빌드 타임에 더 명확하게 잡아줍니다.


7. PPR 적용 전후 측정 방법

도입 효과를 수치로 검증하지 못하면 PPR 전환의 가치를 팀 내부에 설득하기 어렵습니다. 다음 네 가지 레이어에서 측정합니다.

DevTools Network 타임라인으로 스트리밍 확인

Chrome DevTools Network 탭에서 해당 페이지 HTML 요청을 선택하고, Timing 섹션의 Content Download 구간이 분절된 청크로 이루어져 있는지 확인합니다. PPR이 정상 동작하면 첫 청크(정적 셸)가 빠르게 도착한 후, 동적 구멍 데이터가 담긴 후속 청크가 뒤따르는 패턴이 보입니다. 단일 청크로 모두 도착한다면 PPR이 아닌 SSR로 폴백된 상태입니다.

Lighthouse로 LCP와 TTFB 측정

# Lighthouse CLI로 PPR 전후 비교 측정
npx lighthouse https://example.com/products/123   --only-categories=performance   --output=json   --output-path=./lighthouse-after-ppr.json   --chrome-flags="--headless"

LCP, TTFB, TBT(Total Blocking Time) 수치를 PPR 적용 전후로 비교합니다. 정적 셸이 CDN에서 서빙되고 있다면 TTFB에서 가장 드라마틱한 개선이 나타납니다.

Vercel Speed Insights와 RUM

Vercel 프로젝트라면 Speed Insights 대시보드에서 TTFB, FCP, LCP를 실사용자 데이터(RUM) 기준으로 추적할 수 있습니다. 배포 직후 P75, P95 퍼센타일 값이 PPR 적용 라우트에서 개선되는지를 최소 48시간 이상 모니터링합니다. CDN 캐시의 이점은 서버와 물리적으로 먼 지역에서 더 두드러지므로 지역별 분포도 함께 확인하세요.

React Server Components와 PPR의 관계, 그리고 스트리밍 렌더링의 기술적 배경에 대해서는 React Server Components 아키텍처 깊게 파헤치기에서 더 자세히 다루고 있습니다.


8. 결론: PPR 도입 실무 체크리스트

PPR은 모든 페이지에 적합한 만능 해법이 아닙니다. 올바른 후보를 선별하고 순서에 맞게 적용하는 것이 핵심입니다.

PPR에 가장 적합한 페이지 유형

  • 상품 상세 페이지 (정적 메타 + 동적 재고/리뷰)
  • 블로그 포스트 (정적 본문 + 동적 댓글/조회수)
  • 뉴스 기사 페이지 (정적 기사 + 동적 관련 기사 추천)
  • 마케팅 랜딩 페이지 + 개인화 배너 조합

PPR이 적합하지 않은 페이지 유형

  • 사용자 대시보드 (대부분이 개인화 동적 데이터)
  • 실시간 채팅 UI (WebSocket 기반)
  • 검색 결과 페이지 (searchParams가 핵심이어서 정적 셸 생성 불가)

도입 전후 실무 체크리스트 5개

  • 버전 확인 후 올바른 설정 적용: Next.js 15라면 experimental.ppr: 'incremental' + 라우트 파일에 export const experimental_ppr = true, Next.js 16+라면 cacheComponents: true로 설정
  • 정적 셸 영역에서 Dynamic API 격리: cookies(), headers(), searchParams, cache: 'no-store' 호출이 Suspense 경계 바깥(정적 셸 컨텍스트)에 없는지 코드 리뷰로 검증
  • Suspense 경계를 데이터 소스 단위로 분리: fallback 스켈레톤의 레이아웃 크기가 실제 콘텐츠와 일치하는지 CLS 측정으로 확인
  • DevTools Network에서 Transfer-Encoding: chunked 헤더와 분절 청크 수신을 육안으로 확인: 이후 Lighthouse CLI로 LCP/TTFB 기준선 수치를 기록
  • Vercel Speed Insights(또는 자체 RUM 솔루션)에서 PPR 적용 라우트의 P75 TTFB와 LCP를 48시간 모니터링: 회귀 없음을 확인한 뒤 점진적으로 적용 라우트 확대

PPR은 "정적의 속도와 동적의 정확성을 한 라우트에서 동시에 달성한다"는 오랜 프론트엔드의 숙제를 HTTP 스트리밍과 Suspense로 풀어낸 구조입니다. Next.js 16에서 Cache Components 모델로 통합되며 더욱 명시적이고 예측 가능한 방식으로 발전했습니다. 현재 프로젝트가 Next.js 15라면 incremental 모드로, 16 이상이라면 cacheComponents로 가장 트래픽이 많은 상품 상세 페이지 한 개에 먼저 적용해보는 것, 그것이 PPR 도입의 가장 현명한 첫걸음입니다.