Next.js PPR — 렌더링 끝판왕이 등장했다
Partial Prerendering으로 정적과 동적의 경계가 사라진다. CSR부터 PPR까지, 렌더링의 최종 진화.
#렌더링, 이제 진짜 끝이 보인다
"렌더링 방식 고찰"에서 CSR부터 ISR까지 다뤘고, "React Server Components 딥다이브"에서 RSC까지 공부했다.
그때마다 "이게 최종 형태인가?" 싶었는데, 또 나왔다.
Partial Prerendering(PPR).
솔직히 처음 들었을 때는 "대체 렌더링 방식이 몇 개야" 싶었다. 근데 PPR을 파보고 나니까 이해했다. 이건 진짜 끝판왕이다. 기존 렌더링 방식들의 장점만 골라 합친, 자연스러운 최종 진화 형태다.
PPR이 뭔데?
Partial Prerendering은 하나의 라우트 안에서 정적인 부분과 동적인 부분을 동시에 처리하는 렌더링 방식이다.
기존에는 페이지 단위로 "이 페이지는 SSG", "이 페이지는 SSR" 이렇게 선택해야 했다. 근데 현실의 페이지는 그렇게 깔끔하게 나뉘지 않는다.
예를 들어 이커머스 상품 페이지를 생각해보자.
- 정적인 부분: 헤더, 네비게이션, 푸터, 상품 설명
- 동적인 부분: 장바구니 개수, 개인화 추천, 재고 현황, 리뷰
기존에는 동적인 부분이 하나라도 있으면 전체를 SSR로 돌리거나, 클라이언트에서 따로 fetch해야 했다.
PPR은 이 고민을 없앤다. 정적인 껍데기(shell)는 빌드 타임에 미리 만들어두고, 동적인 부분만 요청 시점에 스트리밍한다.

어떻게 동작하는 건데?
PPR의 동작 원리는 생각보다 우아하다.
1단계: 빌드 타임 — 정적 Shell 생성
빌드 시점에 Next.js가 라우트를 분석한다. <Suspense> 경계를 기준으로 정적인 부분과 동적인 부분을 나눈다.
정적인 부분(레이아웃, 헤더, 네비게이션 등)은 HTML로 프리렌더링해서 CDN에 캐싱한다.
2단계: 요청 시점 — 동적 부분 스트리밍
사용자가 페이지를 요청하면:
- CDN에서 정적 shell을 즉시 전송 (TTFB 최소화)
<Suspense>fallback이 동적 컴포넌트 자리를 잡아둠- 서버에서 동적 부분 렌더링 완료되면 스트리밍으로 교체
[정적 Shell - 즉시 전송]
┌─────────────────────────────┐
│ Header (static) │
│ Nav (static) │
├─────────────────────────────┤
│ Product Info (static) │
│ │
│ ┌─ Loading... ──────────┐ │ ← Suspense fallback
│ │ (추천 상품 로딩중) │ │
│ └───────────────────────┘ │
│ │
│ ┌─ Loading... ──────────┐ │ ← Suspense fallback
│ │ (리뷰 로딩중) │ │
│ └───────────────────────┘ │
│ │
│ Footer (static) │
└─────────────────────────────┘
[동적 부분 - 스트리밍으로 교체]
추천 상품 ━━━━━━━━▶ 완료! fallback 교체
리뷰 ━━━━━━━━━━━━━▶ 완료! fallback 교체
핵심은 사용자가 정적 shell을 거의 즉시 볼 수 있다는 것이다. 동적 콘텐츠는 준비되는 대로 하나씩 채워진다. 페이지가 점진적으로 완성되는 느낌이다.
코드로 보면 바로 이해된다
next.config.ts 설정
먼저 PPR을 활성화한다. 아직 실험적 기능이므로 incremental 모드로 시작하는 게 안전하다.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
ppr: "incremental", // 라우트별로 PPR 점진적 적용
},
};
export default nextConfig;라우트에서 PPR 활성화
// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { ProductInfo } from '@/components/product-info';
import { Recommendations } from '@/components/recommendations';
import { Reviews } from '@/components/reviews';
import { CartCount } from '@/components/cart-count';
import { Skeleton } from '@/components/skeleton';
// 이 라우트에서 PPR 활성화
export const experimental_ppr = true;
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<main>
{/* 정적 영역 — 빌드 타임에 프리렌더링 */}
<header>
<nav>...</nav>
{/* 동적 영역 — 요청 시점에 스트리밍 */}
<Suspense fallback={<span>🛒</span>}>
<CartCount />
</Suspense>
</header>
{/* 정적 영역 */}
<ProductInfo id={params.id} />
{/* 동적 영역들 — 각각 독립적으로 스트리밍 */}
<Suspense fallback={<Skeleton type="recommendations" />}>
<Recommendations productId={params.id} />
</Suspense>
<Suspense fallback={<Skeleton type="reviews" />}>
<Reviews productId={params.id} />
</Suspense>
{/* 정적 영역 */}
<footer>...</footer>
</main>
);
}동적 컴포넌트 예시
동적 컴포넌트는 특별한 게 없다. 그냥 서버 컴포넌트에서 데이터를 fetch하면 된다. Next.js가 알아서 동적으로 판별한다.
// components/recommendations.tsx
// Server Component (기본값)
async function Recommendations({ productId }: { productId: string }) {
// cookies(), headers() 같은 동적 함수 사용 → 자동으로 동적 처리
const res = await fetch(`https://api.example.com/recommendations/${productId}`, {
cache: 'no-store', // 매 요청마다 fresh 데이터
});
const products = await res.json();
return (
<section>
<h2>추천 상품</h2>
<div className="grid grid-cols-4 gap-4">
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</section>
);
}
<Suspense>로 감싸기만 하면 끝이다. Next.js가 빌드 타임에 정적/동적 경계를 자동으로 분석한다.
성능 비교: Before vs After
PPR의 효과를 체감하려면 숫자로 봐야 한다. 같은 이커머스 상품 페이지 기준이다.
SSR (Before)
요청 → 서버에서 전체 렌더링 (DB 쿼리 3개 + API 호출 2개)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (800ms)
→ HTML 전송 → 화면 표시
TTFB: ~800ms (모든 데이터 준비될 때까지 대기)
FCP: ~900ms
LCP: ~1200ms
PPR (After)
요청 → 정적 shell 즉시 전송 (CDN)
━━━ (50ms)
→ 화면 표시 (shell + fallback)
동적 부분 병렬 스트리밍:
추천 상품 ━━━━━━━━━━━ (400ms) → 교체
리뷰 ━━━━━━━━━━━━━━━━ (600ms) → 교체
TTFB: ~50ms (CDN에서 정적 shell 즉시 응답)
FCP: ~100ms
LCP: ~650ms
TTFB가 800ms에서 50ms로. 16배 개선이다. 사용자는 거의 즉시 페이지 구조를 볼 수 있다.
렌더링 방식 진화 총정리
CSR부터 PPR까지, 렌더링의 진화를 한 눈에 정리한다.
| 방식 | 정적 콘텐츠 | 동적 콘텐츠 | TTFB | SEO | 단위 |
|---|---|---|---|---|---|
| CSR | X | O (클라이언트) | 빠름 | 나쁨 | 페이지 |
| SSR | O | O (서버) | 느림 | 좋음 | 페이지 |
| SSG | O | X | 매우 빠름 | 좋음 | 페이지 |
| ISR | O | 부분적 (revalidate) | 매우 빠름 | 좋음 | 페이지 |
| RSC | O | O (컴포넌트) | 보통 | 좋음 | 컴포넌트 |
| PPR | O (빌드) | O (스트리밍) | 매우 빠름 | 좋음 | 컴포넌트 |
진화 흐름을 보면 명확한 패턴이 있다.
CSR → SSR → SSG → ISR → RSC → PPR
매 단계마다 "정적과 동적의 경계를 더 세밀하게 제어"하는 방향으로 발전했다. PPR은 그 경계를 컴포넌트 단위로, 하나의 라우트 안에서 자유롭게 넘나드는 최종 형태다.
주의: 보안 이슈 (CVE-2025-59472)
PPR을 도입한다면 반드시 알아야 할 보안 이슈가 있다.
2025년에 CVE-2025-59472가 보고되었다. Next.js의 minimal 모드(standalone output)에서 PPR을 활성화했을 때, 특정 요청 패턴으로 DoS(서비스 거부) 공격이 가능한 취약점이었다.
공격자가 PPR의 스트리밍 메커니즘을 악용해서 서버 리소스를 고갈시킬 수 있었다.
이 취약점은 이미 패치되었다. Next.js 15.2 이상 버전을 사용하면 안전하다. 하지만 PPR을 프로덕션에 적용할 때는 항상 최신 버전을 유지하는 습관이 중요하다.
# 현재 Next.js 버전 확인
npx next --version
# 최신 버전으로 업데이트
npm install next@latestPPR이 빛나는 경우 vs 과한 경우
PPR이 진가를 발휘하는 경우
- 이커머스: 상품 정보(정적) + 재고/가격/추천(동적)
- 대시보드: 레이아웃/네비(정적) + 실시간 데이터 위젯(동적)
- 블로그 + 커뮤니티: 글 본문(정적) + 댓글/좋아요(동적)
- 랜딩 페이지: 마케팅 콘텐츠(정적) + 개인화 CTA(동적)
PPR이 과한 경우
- 완전 정적 페이지: 마케팅 랜딩, 문서 사이트 → SSG로 충분하다
- 전부 동적인 페이지: 실시간 채팅, 주식 차트 → 정적 shell의 의미가 없다
- 작은 프로젝트: 복잡성 대비 이득이 크지 않다. 오버엔지니어링이 될 수 있다
개인적인 의견: 정적/동적 비율이 7:3 ~ 5:5 정도 되는 페이지에서 PPR의 효과가 극대화된다. 한쪽으로 너무 치우치면 굳이 PPR을 쓸 이유가 줄어든다.
마무리: 렌더링의 끝은 어디인가
렌더링 방식 고찰에서 CSR부터 ISR까지 다뤘고, RSC까지 공부했는데, PPR이 진정한 끝판왕이라고 느꼈다.
왜냐하면 PPR은 "어떤 렌더링 방식을 선택할까?"라는 질문 자체를 없애버리기 때문이다. 하나의 라우트 안에서 정적과 동적이 자연스럽게 공존한다. 더 이상 페이지 단위로 고민할 필요가 없다.
물론 아직 실험적 기능이고, 프로덕션 안정성이 완전히 검증된 건 아니다. 하지만 방향성은 확실하다. 웹 렌더링은 더 세밀한 단위로, 더 자동으로, 더 최적으로 진화하고 있다.
CSR → SSR → SSG → ISR → RSC → PPR.
이 여정이 끝일까? 솔직히 모르겠다. 또 새로운 게 나올 수도 있다. 하지만 적어도 지금, PPR이 우리가 가진 가장 완성된 형태의 렌더링 방식이라는 건 확실하다.
