Back

Next.js Partial Prerendering (PPR) 완전 분석: 작동 원리부터 실전 적용까지

Next.js 개발하면서 이 선택 앞에 안 서본 사람 없을 거예요: 정적이냐 동적이냐? 빌드 타임에 미리 렌더링해서 빠르게 보여줄 건지(SSG), 아니면 매 요청마다 렌더링해서 최신 데이터를 보여줄 건지(SSR). 하나만 골라야 했죠. 둘 다는 안 됐으니까요.

근데 이제 돼요. Partial Prerendering(PPR)은 App Router 이후 Next.js에서 가장 큰 아키텍처 변화예요. 정적 셸(헤더, 네비게이션, 레이아웃, 퍼스트 뷰 콘텐츠)은 즉시 서빙하고, 동적 파트(사용자별 데이터, 실시간 가격, 맞춤 추천)는 같은 HTTP 응답 안에서 스트리밍해요. 클라이언트 측 fetch도 없고, 레이아웃 시프트도 없어요. 요청 하나, 응답 하나. 정적의 속도와 동적의 신선함을 동시에.

이건 점진적 개선이 아니에요. SSG vs SSR 이분법의 끝이에요. 어떻게 동작하는지, 어떻게 구현하는지, 어디서 안 되는지까지 다 까볼게요.

PPR이 해결하는 문제

PPR 이전에는 Next.js에 네 가지 렌더링 전략이 있었고, 각각 뼈아픈 트레이드오프가 있었거든요:

전략속도최신성개인화
SSG⚡ 즉시❌ 리빌드 전까지 낡음❌ 모두에게 동일
ISR⚡ 빠름⚠️ revalidation 주기 내 낡음❌ 모두에게 동일
SSR🐌 느린 TTFB✅ 항상 최신✅ 사용자별
CSR🐌 느린 FCP✅ 항상 최신✅ 사용자별

실제 상황으로 볼게요. 이커머스 상품 페이지를 생각해보세요:

  • 상품 제목, 설명, 이미지는 거의 안 바뀜 → 정적이어야 함
  • 가격, 재고, 리뷰는 수시로 변함 → 동적이어야 함
  • "당신을 위한 추천" 섹션은 사용자별로 다름 → 개인화 필수

SSG로 가면 가격이 낡아요. SSR로 가면 가격, 재고, 추천 데이터를 전부 가져올 때까지 한 바이트도 못 보내니까 TTFB가 끔찍하죠. CSR로 가면 사용자는 API 세 개가 끝날 때까지 스켈레톤만 보고 있어야 해요.

PPR은 이 전체 매트릭스를 없애 버려요. 한 페이지, 한 요청: 정적 파트는 즉시 도착하고, 동적 파트는 준비되는 대로 스트리밍.

PPR이 실제로 어떻게 동작하는지

PPR은 마법은 아니지만, 아키텍처가 꽤 영리해요. 요청이 PPR 적용 페이지에 도착했을 때 무슨 일이 벌어지는지 살펴볼게요.

1단계: 빌드 타임 — 정적 셸 생성

next build 할 때, Next.js는 페이지를 SSG처럼 렌더링해요. 근데 동적 컴포넌트를 감싸고 있는 <Suspense> 바운더리를 만나면 멈춰요. 그 컴포넌트를 resolve하려고 시도하지 않아요. 대신:

  1. <Suspense> 바운더리 바깥의 모든 것을 정적 HTML 셸로 렌더링
  2. <Suspense> 바운더리 위치에 폴백 플레이스홀더 삽입
  3. 이 셸을 CDN/Edge에 저장해서 즉시 서빙 가능하게 만듦
// app/product/[id]/page.tsx import { Suspense } from "react"; import { ProductHeader } from "./ProductHeader"; import { PricingSection } from "./PricingSection"; import { Recommendations } from "./Recommendations"; export default async function ProductPage({ params, }: { params: { id: string }; }) { const product = await getProduct(params.id); // 정적 — 빌드 타임에 fetched return ( <main> {/* 정적: 빌드 타임에 렌더, CDN에서 서빙 */} <ProductHeader product={product} /> {/* 동적: 요청 시 스트리밍 */} <Suspense fallback={<PricingSkeleton />}> <PricingSection productId={params.id} /> </Suspense> {/* 동적: 요청 시 스트리밍 */} <Suspense fallback={<RecommendationsSkeleton />}> <Recommendations productId={params.id} /> </Suspense> </main> ); }

2단계: 요청 시 — 셸 서빙 + 동적 파트 스트리밍

사용자가 /product/123을 요청하면:

  1. CDN이 미리 빌드된 정적 셸을 즉시 서빙 (상품 제목, 이미지, 설명 등 <Suspense> 바운더리 바깥의 모든 것)
  2. 동시에, 서버가 <Suspense> 바운더리 안의 동적 컴포넌트 실행 시작
  3. 각 동적 컴포넌트가 resolve되면, 그 HTML이 React 스트리밍 프로토콜로 응답에 스트리밍되어 폴백을 대체

이게 단일 HTTP 요청 안에서 벌어져요. 브라우저는 동적 파트가 서버에서 아직 처리 중인 동안에도 정적 셸을 그려요. 워터폴도 없고, 두 번째 라운드트립도 없어요.

3단계: 브라우저가 점진적 응답 수신

시간 (ms)    사용자가 보는 것
──────────────────────────────────────────
0            요청 전송
50           정적 셸 도착 → 페이지가 보인다!
             (헤더, 네비, 상품 제목, 이미지, 스켈레톤 로더)
120          가격 데이터 스트리밍 → 스켈레톤이 실제 가격으로 교체
200          추천 데이터 스트리밍 → 스켈레톤이 카드로 교체

기존 SSR이랑 비교해보면:

시간 (ms)    사용자가 보는 것
──────────────────────────────────────────
0            요청 전송
350          아무것도 안 보임 — 서버가 모든 데이터 대기 중
350          전체 페이지 한 번에 도착

TTFB 차이가 극적이죠. PPR이면 정적 셸이 이미 캐시되어 있으니까 LCP가 Edge에서 100ms 미만이 될 수 있어요.

PPR 활성화: 단계별 가이드

PPR은 Next.js 14에서 실험 기능으로 처음 등장했고, Next.js 15에서 개선되었고, Next.js 16 (2025년 10월)에서 Cache Components와 함께 정식 출시됐어요. Next.js 16에서는 experimental.ppr 플래그가 제거되고 cacheComponents 설정으로 대체됐어요.

1. next.config.ts 업데이트

// next.config.ts — Next.js 16+ const nextConfig = { cacheComponents: true, }; export default nextConfig;

참고: Next.js 14/15를 쓰고 있다면 기존 실험 플래그를 사용하세요:

const nextConfig = { experimental: { ppr: true } };

Next.js 16에서는 모든 코드가 기본적으로 동적이에요. "use cache" 디렉티브나 Suspense 바운더리를 통해 정적 캐싱에 옵트인하는 구조죠. 이전 버전에서 페이지가 기본 정적이었던 것과 정반대예요.

2. Suspense 바운더리로 페이지 구조화

핵심은 이거예요: Suspense 바운더리가 정적과 동적의 경계선이에요. <Suspense> 바운더리 바깥은 빌드 타임에 프리렌더, 안쪽은 요청 시 실행.

// app/dashboard/page.tsx import { Suspense } from "react"; import { DashboardLayout } from "@/components/DashboardLayout"; import { UserProfile } from "@/components/UserProfile"; import { RecentActivity } from "@/components/RecentActivity"; import { Analytics } from "@/components/Analytics"; export default function DashboardPage() { return ( <DashboardLayout> {/* 정적 셸: 네비게이션, 사이드바, 레이아웃 구조 */} <h1>Dashboard</h1> <div className="grid grid-cols-3 gap-6"> {/* 동적: 인증된 사용자에 의존 */} <Suspense fallback={<ProfileSkeleton />}> <UserProfile /> </Suspense> {/* 동적: 실시간 데이터 */} <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> </Suspense> {/* 동적: 집계 분석 */} <Suspense fallback={<AnalyticsSkeleton />}> <Analytics /> </Suspense> </div> </DashboardLayout> ); }

3. 동적 컴포넌트를 실제로 동적으로 만들기

컴포넌트가 요청 시 데이터에 접근하면 동적이 돼요. 트리거가 되는 것들:

// 이 컴포넌트는 쿠키를 읽으니까 동적 import { cookies } from "next/headers"; export async function UserProfile() { const session = (await cookies()).get("session"); const user = await getUser(session?.value); return ( <div className="profile-card"> <img src={user.avatar} alt={user.name} /> <h2>{user.name}</h2> <p>{user.email}</p> </div> ); }

컴포넌트가 동적이 되는 조건 (요청 시 렌더링 발동):

  • cookies() — 요청 쿠키 읽기
  • headers() — 요청 헤더 읽기
  • searchParams — URL 쿼리 파라미터 접근
  • connection() — 동적 렌더링 옵트인
  • 캐시 안 된 fetch()fetch(url, { cache: "no-store" })

정적으로 남는 것들 (빌드 타임에 렌더):

  • 동적 데이터 의존성 없는 컴포넌트
  • 캐시/정적 fetch()만 사용하는 컴포넌트
  • generateStaticParams() 사용 컴포넌트

아키텍처 깊이 파고들기: PPR이 근본적으로 다른 이유

PPR은 그냥 "스트리밍 SSR"이 아니에요 (그건 loading.tsx로 이미 있었거든요). 결정적 차이는 정적 셸:

PPR 없이 (기존 스트리밍 SSR)

요청 → 서버가 모든 것을 계산 → 점진적 스트리밍
       └── 최상위 레이아웃이 resolve될 때까지 여전히 대기
       └── TTFB는 가장 느린 부모 컴포넌트에 종속

PPR 사용

요청 → CDN이 미리 빌드된 정적 셸을 즉시 서빙
       └── 서버는 Suspense로 감싼 컴포넌트만 처리
       └── TTFB = CDN 레이턴시 (~20-50ms from edge)

정적 셸은 오리진 서버가 아니라 CDN Edge에서 서빙돼요. TTFB가 DB 쿼리 속도가 아니라 가장 가까운 Edge 노드까지의 거리에 의해 결정되는 거죠.

실전 성능: PPR 적용 전후

이커머스 상품 페이지의 구체적인 메트릭을 볼게요.

PPR 적용 전 (순수 SSR)

TTFB:                    380ms (오리진 서버 렌더링)
FCP:                     420ms
LCP:                     680ms
CLS:                     0.02

PPR 적용 후 (정적 셸 + 동적 스트리밍)

TTFB:                    32ms  (CDN Edge, 정적 셸)
FCP:                     65ms  (정적 콘텐츠 즉시 페인팅)
LCP:                     65ms  (상품 이미지가 정적 셸에 포함)
CLS:                     0.00  (스켈레톤이 공간 확보)
메트릭SSR OnlyPPR개선 폭
TTFB380ms32ms11.9배
FCP420ms65ms6.5배
LCP680ms65ms10.5배
CLS0.020.00제거

흔한 패턴과 안티패턴

✅ 패턴: 세분화된 Suspense 바운더리

독립된 데이터 의존성마다 각각의 Suspense 바운더리로 감싸세요:

// ✅ 좋음: 독립적 스트리밍 <Suspense fallback={<PriceSkeleton />}> <Price productId={id} /> {/* 가격 resolve 즉시 스트리밍 */} </Suspense> <Suspense fallback={<ReviewsSkeleton />}> <Reviews productId={id} /> {/* 독립적으로 스트리밍 */} </Suspense>

❌ 안티패턴: 거대한 단일 Suspense 바운더리

전부를 하나의 Suspense 바운더리로 감싸지 마세요. 페이지 전체가 동적이 돼서 PPR의 의미가 없어져요:

// ❌ 나쁨: 가장 느린 컴포넌트 때문에 전부 대기 <Suspense fallback={<PageSkeleton />}> <Price productId={id} /> <Reviews productId={id} /> <Recommendations userId={userId} /> </Suspense>

❌ 안티패턴: Layout에서 동적 API 사용

루트 레이아웃이 쿠키나 헤더를 읽으면 전체 페이지가 동적이 돼요:

// ❌ 전체 페이지가 동적이 됨 // app/layout.tsx import { cookies } from "next/headers"; export default async function RootLayout({ children }) { const theme = (await cookies()).get("theme"); return <html data-theme={theme?.value}>{children}</html>; }

동적 데이터 접근은 Suspense로 감싼 컴포넌트 안으로 옮겨야 해요:

// ✅ 레이아웃은 정적, 동적 파트는 분리 // app/layout.tsx import { Suspense } from "react"; import { ThemeProvider } from "./ThemeProvider"; export default function RootLayout({ children }) { return ( <html> <body> <Suspense fallback={<NavSkeleton />}> <ThemeProvider /> </Suspense> {children} </body> </html> ); }

PPR vs 다른 렌더링 전략: 결정 프레임워크

PPR 쓸 때:

  • 정적+동적 콘텐츠가 섞인 페이지 — 상품 페이지, 대시보드, 댓글 달린 뉴스 기사
  • TTFB가 중요 — 이커머스, 콘텐츠 사이트, SEO 중요 페이지
  • 속도 희생 없이 개인화 — 맞춤 추천, 가격 티어, A/B 테스트
  • 레이아웃 시프트가 문제 — PPR의 스켈레톤 접근법이 CLS를 자연스럽게 방지

순수 SSG 유지:

  • 페이지 전체가 정적 — 블로그, 문서, 마케팅 페이지
  • 개인화 불필요 — 모든 방문자에게 동일한 콘텐츠

SSR 유지:

  • 모든 바이트가 요청에 의존 — 인증 플로우, 어드민 패널
  • 데이터 일관성이 최우선 — 정적 셸이 오히려 혼선을 줄 수 있는 금융 대시보드

SSR에서 PPR로 마이그레이션

1단계: 정적/동적 경계 파악

grep -rn "cookies()\|headers()\|searchParams\|connection()\|no-store\|noStore" \ --include="*.tsx" --include="*.ts" ./app

2단계: 동적 컴포넌트를 Suspense로 감싸기

// 변경 전: 전부 동적 export default async function Page() { const user = await getUser(); const posts = await getPosts(); const notifications = await getNotifications(user.id); return ( <div> <ProfileCard user={user} /> <PostList posts={posts} /> <NotificationBell count={notifications.length} /> </div> ); } // 변경 후: 정적 셸 + 동적 스트리밍 export default function Page() { return ( <div> <Suspense fallback={<ProfileSkeleton />}> <ProfileCard /> </Suspense> <PostList /> {/* 정적 — 동적 의존성 없음 */} <Suspense fallback={<NotificationSkeleton />}> <NotificationBell /> </Suspense> </div> ); }

3단계: PPR 활성화 및 빌드

// next.config.ts — Next.js 16+ const nextConfig = { cacheComponents: true, }; // Next.js 14/15 (레거시): // const nextConfig = { experimental: { ppr: true } };
next build

빌드 결과에서 PPR 페이지는 특별한 표시(◐)로 정적+동적 파트가 있음을 보여줘요:

Route (app)                    Size     First Load
─────────────────────────────────────────────────
◐ /product/[id]               4.2 kB    89 kB
○ /about                      1.1 kB    82 kB
● /blog/[slug]                2.3 kB    84 kB

○  Static
●  SSG
◐  Partial Prerendering

디버깅: 흔한 문제들

문제 1: 전체 페이지가 동적이 됨

증상: 빌드 결과에서 페이지가 partial(◐) 대신 fully dynamic(λ)으로 표시.

원인: Suspense 바운더리 바깥에서 동적 API가 호출됨. 보통 레이아웃이나 페이지 레벨에서.

해결: cookies(), headers(), connection() 호출을 찾아서 Suspense로 감싼 컴포넌트 안으로 옮기면 돼요.

문제 2: 스켈레톤에도 레이아웃 시프트 발생

해결: 스켈레톤 컴포넌트에 최종 콘텐츠와 크기가 맞는 min-heightaspect-ratio를 명시하면 돼요.

<div style={{ contain: "layout", minHeight: "200px" }}> <Suspense fallback={<Skeleton />}> <DynamicComponent /> </Suspense> </div>

PPR이 중요한 이유: 퍼포먼스를 넘어서

PPR은 단순한 성능 최적화가 아니에요. 웹 아키텍처를 바라보는 방식 자체가 바뀌는 거죠:

  1. 풀페이지 렌더링 결정의 종말: SSG 또는 SSR을 고르는 게 아니라, 같은 페이지 안에서 컴포넌트별로 SSG 그리고 SSR을 선택해요.

  2. 기본이 Edge-First: 정적 셸은 CDN Edge에 살아요. 오리진 서버는 동적 파트만 처리하면 돼요.

  3. 빌트인 프로그레시브 인핸스먼트: 느린 연결의 사용자도 정적 셸은 즉시 봐요. 동적 콘텐츠는 대역폭이 허용하는 대로 도착하죠.

  4. 캐싱 단순화: 정적 파트는 영구 캐시 가능(content-hash 기반). 동적 파트는 캐시 안 함. 하이브리드 페이지의 캐시 무효화 고민이 사라져요.

  5. React 방향성과 일치: PPR은 React Server Components, Suspense, Streaming의 자연스러운 수렴이에요. Next.js만의 해킹이 아니라 React 아키텍처 비전의 완성이죠.

마무리

Partial Prerendering은 웹 개발에서 가장 오래되고 가장 고통스러웠던 트레이드오프를 없애 버렸어요: 정적 vs 동적. React Suspense를 프리렌더 콘텐츠와 요청 시 계산의 경계선으로 활용해서, CDN 속도의 TTFB와 개인화된 실시간 데이터를 동시에 제공해요.

멘탈 모델은 단순해요: <Suspense> 바깥은 정적, 안쪽은 동적. 나머지는 프레임워크가 알아서 처리해요.

Next.js 16의 Cache Components로 PPR이 실험에서 정식으로 졸업했어요. 대부분의 Next.js 앱에서 PPR은 앞으로의 기본 렌더링 전략이 돼야 해요. 성능 차이가 무시하기엔 너무 크고, 기존 SSR 페이지에서의 마이그레이션도 간단하니까요.

next.config.ts에서 cacheComponents: true 하나면 시작이에요. 빌드 결과에서 ◐ 표시가 보이면, 페이지가 전보다 훨씬 빨라진 거예요.

Next.jsReactPPRperformanceSSRSSGweb developmentServer ComponentsSuspense

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요