Prologue: 정적(Static)과 동적(Dynamic) 사이의 영원한 이분법
사학 연구에서 논문을 집필할 때, 역사적 사실이라는 변하지 않는 '골격' 위에 새로운 사료 해석이라는 '살'을 실시간으로 붙여나가는 작업은 균형이 생명이었습니다. 프론트엔드 개발에서도 이와 아주 비슷한 웹 렌더링 모델 디자인의 균형 잡기 문제가 오랫동안 우리를 괴롭혀 왔습니다.
바로 **"이 페이지를 정적(SSG)으로 빌드할 것인가, 아니면 동적(SSR)으로 서버에서 실시간 렌더링할 것인가?"**의 양자택일 문제입니다.
그동안 제가 직면했던 타협의 딜레마는 다음과 같았습니다.
- 정적 사이트 생성(SSG): 블로그나 랜딩 페이지처럼 속도가 생명인 곳에 적용하면 CDN을 통해 세계 어디서나 눈 깜짝할 사이에 폼이 뜹니다. 하지만 우측 상단 헤더에 '로그인한 사용자 프로필'이나 '쇼핑몰 장바구니 수량' 같은 동적 데이터를 넣으려고 하면 골치 아파집니다. 클라이언트 사이드에서
useEffect로 토큰을 읽어와 뒤늦게 띄우자니 레이아웃이 덜컥거리며 깜빡이는 현상(FOUC)이 발생했습니다. - 서버 사이드 렌더링(SSR): 전체 페이지를 동적으로 렌더링하면 실시간 데이터가 잘 보이지만, 서버에서 데이터베이스 조회가 끝나고 HTML을 완성할 때까지 브라우저는 하얀 화면만 보며 마냥 대기해야 했습니다. 첫 페이지 로딩 속도(LCP)가 눈에 띄게 나빠졌습니다.
"왜 페이지 전체를 한 가지 방식으로만 처리해야 하지? 변하지 않는 레이아웃 껍데기는 초고속으로 먼저 띄우고, 사용자 로그인 정보나 실시간 차트 같은 동적 영역만 핀포인트로 나중에 꽂아 넣을 수는 없을까?"
이 타협 없는 갈증을 완벽하게 해결하기 위해 Next.js가 제안한 패러다임이 바로 **Partial Prerendering (PPR, 부분 사전 렌더링)**이었습니다.
Concept: 정적 껍데기(Shell)와 동적 스트리밍의 만남
Next.js PPR의 핵심 개념은 **"빌드 시점에 페이지의 정적 영역(HTML Shell)을 미리 구워두고, 사용자가 페이지를 요청하면 정적 껍데기를 즉시 브라우저로 전송한 다음, 빈자리에 채워질 동적 콘텐츠는 서버에서 비동기로 렌더링이 끝나는 대로 스트리밍(Streaming)하여 채워 넣는 기술"**입니다.
이 기술의 아름다운 점은 추가적인 자바스크립트 프레임워크나 API를 배울 필요가 없다는 점입니다. 오직 리액트 표준 스펙인 <Suspense> 경계를 기준으로 정적 영역과 동적 영역이 자동으로 나뉩니다.
// PPR 작동 컨셉 예시
export default function Page() {
return (
<main>
{/* 1. 정적 영역: 빌드 시 생성되어 즉각 화면에 보임 */}
<h1>나의 대시보드</h1>
<p>오늘의 정적 안내 메시지</p>
{/* 2. 동적 영역: Suspense로 감싸져 빈 껍데기가 먼저 뜨고, 나중에 채워짐 */}
<Suspense fallback={<SkeletonChart />}>
<RealtimeAnalyticsChart />
</Suspense>
</main>
);
}
Next.js 컴파일러는 빌드할 때 페이지를 훑으며 <Suspense> 바깥의 코드들을 추출해 dashboard.html이라는 정적 셸 파일로 디스크에 저장합니다.
사용자가 대시보드에 접근하면, 브라우저는 CDN을 통해 단 0.05초 만에 전체 페이지 레이아웃과 뼈대(Skeleton)를 렌더링합니다. 그 사이에 Node.js 서버는 데이터베이스에서 통계 데이터를 조회해 RealtimeAnalyticsChart를 렌더링하고, 완성된 조각을 이미 열려 있는 HTTP 연결을 통해 브라우저로 스트리밍합니다.
브라우저는 이 조각을 받아 스켈레톤 영역에 자연스럽게 덮어씁니다.
Deep Dive: PPR 설정법과 컴파일 구조
Next.js에서 PPR을 적용하는 구체적인 설정법과 작동 원리는 다음과 같습니다.
1. next.config.js에서 PPR 옵션 활성화
PPR은 현재 점진적으로 내장되고 있는 실험적 스펙이므로, 설정을 명시해 주어야 합니다.
// next.config.js
const nextConfig = {
experimental: {
ppr: true, // PPR 활성화
},
};
module.exports = nextConfig;
2. 세그먼트별 점진적 적용 (ppr 옵션)
특정 라우트 레이아웃이나 페이지 세그먼트에서만 점진적으로 PPR을 켜고 싶다면, 해당 파일 상단에 구성을 추가할 수 있습니다.
// app/dashboard/layout.tsx
export const experimental_ppr = true; // 이 레이아웃 이하 경로에 PPR 적용
3. 컴파일러 내부: 정적/동적 경계 판단 기준
Next.js가 특정 컴포넌트를 '동적(Dynamic)'으로 분류하여 셸에서 제외하는 기준은 다음과 같습니다.
- 쿠키나 세션 정보 조회:
cookies(),headers()함수 호출. - 검색 파라미터 조회:
searchParamsprop 참조. - 캐싱되지 않은 데이터 요청:
fetch(url, { cache: 'no-store' })나revalidate = 0사용.
이 함수나 속성들이 사용된 컴포넌트가 <Suspense>로 감싸져 있으면 그 경계면이 완벽히 정적/동적으로 분리되어 스트리밍 타겟이 됩니다. 만약 감싸지 않았다면 페이지 전체가 동적 렌더링(SSR)으로 이탈하게 되므로 주의해야 합니다.
Application: 이커머스 상세 페이지 리팩토링 및 성능 검증
이 설정을 내 사이트의 '제품 상세 정보 및 추천 상품 페이지'에 적용하여 리팩토링하고 성능 변화를 분석해 보았습니다.
기존 코드는 로그인 상태에 따른 장바구니 수량 표시와 실시간 개인화 추천 상품 목록 때문에 페이지 컴포넌트 상단에 headers() 호출이 존재했고, 이로 인해 페이지 전체가 동적으로 빌드되어 구동 속도가 느렸습니다.
// 리팩토링 후 상세 페이지 레이아웃
import { Suspense } from 'react';
import { ProductInfo, ProductSkeleton } from '@/components/ProductInfo';
import { RecommendationList, RecommendationSkeleton } from '@/components/Recommendations';
import { HeaderCartStatus } from '@/components/HeaderCartStatus';
export const experimental_ppr = true;
export default function ProductDetailPage({ params }: { params: { id: string } }) {
return (
<div className="min-h-screen bg-slate-50">
{/* 1. 정적 헤더 영역 (장바구니 상태만 동적) */}
<header className="flex justify-between p-4 bg-white shadow-sm">
<span className="font-bold text-lg">Codemapo Shop</span>
{/* 장바구니 정보만 동적으로 스트리밍 */}
<Suspense fallback={<div className="w-8 h-8 rounded-full bg-slate-100" />}>
<HeaderCartStatus />
</Suspense>
</header>
<main className="max-w-4xl mx-auto p-6">
{/* 2. 제품 기본 정보는 정적 캐싱 로딩 가능 */}
<Suspense fallback={<ProductSkeleton />}>
<ProductInfo productId={params.id} />
</Suspense>
{/* 3. 추천 엔진 상품 리스트는 느린 동적 API 조회이므로 별도 Suspense 격리 */}
<div className="mt-12">
<h2 className="text-xl font-bold mb-4">당신을 위한 추천 상품</h2>
<Suspense fallback={<RecommendationSkeleton />}>
<RecommendationList productId={params.id} />
</Suspense>
</div>
</main>
</div>
);
}
PPR을 적용하고 빌드를 돌린 뒤 브라우저 검사기에서 성능을 측정한 핵심 웹 지표(Core Web Vitals)의 변화는 획기적이었습니다.
- 최초 바이트 수신 시간 (TTFB, Time to First Byte):
- 기존(SSR): 480ms (추천 상품 API 조회가 완료될 때까지 서버가 응답을 주지 않아 대기)
- 변경(PPR): 32ms (정적 HTML 셸이 즉각 전송되어 사실상 캐싱된 파일 로딩 속도로 단축)
- 최대 콘텐츠 페인트 (LCP, Largest Contentful Paint):
- 기존(SSR): 1.2s
- 변경(PPR): 0.4s (제품 상세 설명 등 핵심 레이아웃이 TTFB 단축과 동시에 렌더링 완료)
서버 성능이나 타사 외부 API 조회 속도 문제로 추천 상품 목록 로딩이 2초 이상 지연되더라도, 사용자는 이미 완성된 상세 정보 페이지를 막힘없이 스크롤하며 읽을 수 있게 되었습니다. 사용자가 체감하는 사이트의 빠릿빠릿함이 비약적으로 증가했습니다.
Summary: 성능과 구현 단순함의 결합
과거의 개발 패러다임에서는 최상의 성능을 내기 위해 복잡한 구조적 아키텍처(예: Static Export 후 클라이언트 사이드에서 React Query로 데이터 복잡하게 바인딩하기)를 억지로 설계해야 했습니다. 그 과정에서 코드의 직관성은 떨어지고 유지보수 비용은 늘어났습니다.
Next.js PPR이 준 가장 큰 축복은 **"코드를 가장 자연스럽고 단순하게 작성하되, 최상의 성능 혜택은 컴파일러가 알아서 제공한다"**는 선언적 설계의 완성형을 보여준 점입니다.
우리는 그저 평소처럼 서버 컴포넌트를 작성하고, 데이터 로딩이 늦거나 세션 조회가 필요한 영역을 리액트 표준 <Suspense>로 감싸기만 하면 됩니다.
복잡한 네트워크 패칭 설계나 캐싱 정책 고민에서 벗어나 비즈니스 로직에 집중하면서도, 브라우저 로딩 속도의 극한을 달성할 수 있게 해주는 PPR이야말로 앞으로 모든 프론트엔드 웹 서비스가 기본적으로 지향해야 할 새로운 렌더링 표준 표준임을 확신합니다.
Implementing Next.js PPR (Partial Prerendering) in Production: Seamless Blending of Static and Dynamic Content
Prologue: The Binary Split of Static vs. Dynamic
When drafting historical research papers, balancing the static "skeleton" of verified historical facts with the dynamic "flesh" of newly discovered source materials was a constant challenge. In frontend engineering, we have long grappled with a similar balancing act regarding our choice of rendering models.
The dilemma has historically forced a binary decision: "Do I build this page statically (SSG) or render it dynamically on the server (SSR)?"
Both options came with structural trade-offs:
- Static Site Generation (SSG): Delivers fast response times because assets are cached and served from edge CDNs. However, displaying personalized user-specific information (like a profile widget or cart count in the header) is difficult. Fetching it on the client side using a
useEffecthook triggers layout shifts and flashes of unauthenticated states. - Server-Side Rendering (SSR): Provides dynamic, real-time data, but forces the browser to wait on a blank screen while the server queries databases and runs server-side rendering logic. The Time to First Byte (TTFB) and Largest Contentful Paint (LCP) suffer.
"Why must we treat the entire page as a single rendering unit? Why can't we immediately serve the static shell of the page, and stream dynamic widgets into place as they finish processing?"
Next.js solves this with Partial Prerendering (PPR).
Concept: Blending Static Shells with Dynamic Streaming
The design principle of Next.js PPR is that the build engine compiles a static HTML shell for the page, serves this cached shell instantly on request, and streams dynamic components asynchronously over the same HTTP connection as they finish rendering on the server.
The most elegant feature of this architecture is zero API overhead. You do not need to learn any custom hooks or proprietary state variables. The boundaries for static versus dynamic rendering are defined purely by standard React <Suspense> blocks.
// Conceptual representation of PPR
export default function Page() {
return (
<main>
{/* 1. Static Content: Pre-rendered at build time, displays instantly */}
<h1>My Dashboard</h1>
<p>Static general announcements</p>
{/* 2. Dynamic Content: Wrapped in Suspense, streamed to the client later */}
<Suspense fallback={<SkeletonChart />}>
<RealtimeAnalyticsChart />
</Suspense>
</main>
);
}
During the build process, the Next.js compiler parses the codebase, extracts the components outside the <Suspense> boundaries, and saves them as a static HTML shell (dashboard.html).
When a user visits the dashboard, the browser receives the static layout shell from the CDN in milliseconds. In the background, the server queries the database, renders the RealtimeAnalyticsChart, and streams the resulting HTML chunks over the open HTTP connection, swapping out the skeleton placeholders.
Deep Dive: PPR Setup and Compilation Logic
Here is how you can implement PPR in Next.js and understand its compilation criteria.
1. Enabling PPR in next.config.js
Because PPR is currently an experimental feature, you must explicitly enable it in your configuration:
// next.config.js
const nextConfig = {
experimental: {
ppr: true, // Enable PPR support
},
};
module.exports = nextConfig;
2. Incremental Route Adoptions
To adopt PPR incrementally within specific layouts or page folders, you can set the config export inside the file:
// app/dashboard/layout.tsx
export const experimental_ppr = true; // Apply PPR to this layout and child sub-routes
3. Compilation Criteria: Static vs. Dynamic
Next.js classifies a component as dynamic (and thus excludes it from the static shell) if it utilizes:
- Cookies or Headers: Accessing properties via
cookies()orheaders(). - Search Parameters: Referencing the
searchParamspage prop. - Uncached Data Requests: Invoking
fetch(url, { cache: 'no-store' })or definingrevalidate = 0.
If a component using any of these dynamic parameters is wrapped inside <Suspense>, it is isolated as a dynamic stream. If it is not wrapped, the compiler reverts the entire page to dynamic Server-Side Rendering (SSR).
Application: Refactoring an E-commerce Product Page
I refactored a product detail page containing a shopping cart status header and a dynamic product recommendations list.
Previously, invoking the headers() function to get session tokens forced the entire page into SSR, causing slower initial loading times.
// Refactored product detail page layout
import { Suspense } from 'react';
import { ProductInfo, ProductSkeleton } from '@/components/ProductInfo';
import { RecommendationList, RecommendationSkeleton } from '@/components/Recommendations';
import { HeaderCartStatus } from '@/components/HeaderCartStatus';
export const experimental_ppr = true;
export default function ProductDetailPage({ params }: { params: { id: string } }) {
return (
<div className="min-h-screen bg-slate-50">
{/* 1. Static header container (cart status is dynamic) */}
<header className="flex justify-between p-4 bg-white shadow-sm">
<span className="font-bold text-lg">Codemapo Shop</span>
{/* Stream only the cart widget dynamically */}
<Suspense fallback={<div className="w-8 h-8 rounded-full bg-slate-100" />}>
<HeaderCartStatus />
</Suspense>
</header>
<main className="max-w-4xl mx-auto p-6">
{/* 2. Product info is static/cached */}
<Suspense fallback={<ProductSkeleton />}>
<ProductInfo productId={params.id} />
</Suspense>
{/* 3. Recommendations list is dynamic and isolated */}
<div className="mt-12">
<h2 className="text-xl font-bold mb-4">Recommended for You</h2>
<Suspense fallback={<RecommendationSkeleton />}>
<RecommendationList productId={params.id} />
</Suspense>
</div>
</main>
</div>
);
}
After deploying this layout, I analyzed the Core Web Vitals inside the browser dev tools. The results were impressive:
- Time to First Byte (TTFB):
- Before (SSR): 480ms (The server paused response delivery until recommendation API calls resolved)
- After (PPR): 32ms (The static HTML shell was sent instantly, matching CDN asset speeds)
- Largest Contentful Paint (LCP):
- Before (SSR): 1.2s
- After (PPR): 0.4s (The main product description rendered instantly alongside the shell delivery)
Even if a backend recommendation service experiences latency spikes and takes 2 seconds to load, the user can read the product specifications without frustration. The perceived speed of the web application increased dramatically.