Next.js 15/16 Partial Prerendering 실전 가이드 — 정적 셸과 동적 스트리밍으로 FCP를 최소화하는 Suspense 경계 설계
TTFB(Time To First Byte)가 680ms이던 상품 상세 페이지가 PPR 적용 후 65ms로 줄었습니다. Vercel 사례 연구에서 확인된 이 수치는 단순한 최적화 트릭의 결과가 아니라, 렌더링 모델 자체를 바꾼 결과입니다. FCP(First Contentful Paint)는 DB 쿼리를 기다리는 동안 사용자가 빈 화면을 보는 가장 큰 원인 중 하나인데, Partial Prerendering은 이 문제를 구조적으로 해결합니다. 이 글을 끝까지 읽으시면 기존 Next.js App Router 라우트에 PPR을 30분 안에 적용할 수 있습니다.
TTFB(Time To First Byte): 브라우저가 서버에 요청을 보낸 후 첫 번째 바이트를 수신하기까지의 시간입니다. FCP(First Contentful Paint): 브라우저가 DOM에서 텍스트·이미지 등 첫 번째 콘텐츠를 화면에 그리는 시점으로, Core Web Vitals의 핵심 지표입니다.
웹 성능 최적화의 오랜 딜레마는 단순했습니다. "정적으로 빠르게 제공하느냐, 동적으로 최신 데이터를 보여주느냐." SSG는 CDN 엣지에서 파일을 즉시 응답하지만 실시간 데이터를 담지 못하고, SSR은 모든 데이터를 기다리느라 TTFB가 늘어납니다. ISR은 타협점이지만 여전히 "전체 페이지를 정적으로 만들 것인가"라는 이분법에서 벗어나지 못합니다. Next.js의 Partial Prerendering(PPR) 은 이 이분법을 해소하는 렌더링 모델로, 하나의 라우트 안에서 변하지 않는 부분은 빌드 타임에 정적 HTML로 CDN에 올리고, 요청마다 달라져야 하는 부분은 런타임에 스트리밍으로 채워 넣습니다.
이 글은 React Suspense와 Next.js App Router 기본 개념을 알고 있는 프론트엔드 개발자를 대상으로 합니다. PPR의 동작 원리와 HTTP 스트리밍 메커니즘, 실제 프로덕션 시나리오별 적용법, 그리고 Next.js 16에서 안정화된 Cache Components API(use cache, cacheLife, cacheTag)까지 한 번에 정리합니다.
핵심 개념
PPR의 세 단계 동작 원리와 HTTP 스트리밍 메커니즘
PPR은 빌드 타임과 요청 타임 두 구간에 걸쳐 동작합니다.
1단계 — 정적 셸 사전 빌드
빌드 타임에 <Suspense> 경계 바깥의 모든 컴포넌트를 정적 HTML로 렌더링해 CDN 엣지에 저장합니다. 네비게이션, 레이아웃, 상품 이미지처럼 요청 데이터에 의존하지 않는 콘텐츠가 모두 여기에 포함됩니다.
2단계 — 동적 홀(hole) 예약
<Suspense>로 감싼 동적 컴포넌트 영역은 빌드 타임에 폴백 UI(스켈레톤, 로딩 스피너 등)로 대체됩니다. React는 내부적으로 이 영역을 <template> 태그 기반의 마커로 표시해, 이후 동적 콘텐츠가 어디에 삽입될지 위치를 예약해둡니다.
3단계 — 단일 HTTP 응답으로 스트리밍
요청이 들어오면 서버는 CDN에 있는 정적 셸을 즉시 첫 번째 HTTP 청크로 전송하기 시작합니다. 동시에 서버 오리진에서는 동적 홀에 해당하는 컴포넌트들을 병렬로 렌더링하고, 완료되는 순서대로 동일한 HTTP 응답 스트림에 추가 청크로 실어 보냅니다. 브라우저는 정적 셸 청크를 먼저 파싱해 화면을 그리고, 이후 도착하는 동적 청크로 홀을 채웁니다. 단일 HTTP 연결 안에서 정적과 동적이 공존하므로 네트워크 왕복이 추가로 발생하지 않는 것이 PPR의 가장 큰 기술적 이점입니다.
핵심 경계:
<Suspense>바깥 = 정적 사전 렌더링 /<Suspense>안쪽 = 요청 타임 동적 스트리밍. 이 경계 하나가 PPR 설계의 전부입니다.
기존 렌더링 방식과의 비교
| 방식 | FCP 속도 | 동적 데이터 | 서버 부하 | 설계 복잡도 |
|---|---|---|---|---|
| SSG | 매우 빠름 (CDN) | 불가 | 낮음 | 낮음 |
| SSR | 느림 (DB 대기) | 가능 | 높음 | 낮음 |
| ISR | 빠름 (만료 전) | 부분적 | 중간 | 중간 |
| PPR | 매우 빠름 (CDN) | 가능 (스트리밍) | 중간 | 중간~높음 |
PPR 활성화: Next.js 15와 16의 차이
PPR은 Next.js 14에서 실험적으로 도입되어 Next.js 16(2025년 10월)에서 안정화되었습니다.
중요: PPR은 App Router 전용 기능입니다. React Server Components(RSC) 아키텍처를 전제로 하기 때문에 Pages Router에서는 사용할 수 없습니다.
Next.js 15 — incremental 모드
incremental 모드는 기존 프로젝트에 리스크 없이 PPR을 도입하기 위해 존재합니다. 전체 앱에 PPR을 한 번에 적용하는 대신, 원하는 라우트 파일에만 experimental_ppr = true를 선언해 라우트 단위로 점진적으로 적용할 수 있습니다.
// next.config.ts (Next.js 15)
const nextConfig = {
experimental: {
ppr: 'incremental', // 앱 전체가 아닌 라우트별 선택 적용
},
};
export default nextConfig;// app/products/[id]/page.tsx
export const experimental_ppr = true; // 이 라우트에만 PPR 활성화
export default function ProductPage() {
return (
<main>
{/* 정적 셸: 빌드 타임에 CDN으로 */}
<ProductHeader />
<ProductImages />
{/* 동적 홀: 요청 타임에 스트리밍 */}
<Suspense fallback={<PriceSkeleton />}>
<RealtimePrice />
</Suspense>
</main>
);
}Next.js 16 — Cache Components와 use cache 지시문
Next.js 16에서 PPR은 Cache Components라는 이름으로 안정화되었습니다. 핵심 변화는 캐싱 방식의 전환입니다. 기존에는 Next.js가 암묵적으로 컴포넌트의 캐싱 여부를 결정했다면, Next.js 16부터는 개발자가 use cache 지시문을 통해 "이 컴포넌트의 결과를 정적 캐시에 포함한다"는 의도를 명시적으로 선언합니다. use cache가 선언된 컴포넌트는 빌드 타임 또는 첫 요청 시 결과가 캐시되어 이후 요청에서는 CDN에서 즉시 응답합니다. 즉, 자동으로 정적 셸의 일부가 됩니다.
// next.config.ts (Next.js 16)
const nextConfig = {
cacheComponents: true, // experimental.ppr 대신 안정화된 설정
};
export default nextConfig;// components/ProductHeader.tsx (Next.js 16)
'use cache'; // 이 컴포넌트의 결과는 정적 캐시에 포함됩니다
import { cacheLife, cacheTag } from 'next/cache';
export async function ProductHeader({ productId }: { productId: string }) {
// 캐시 유효기간: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks'
cacheLife('days');
// 태그 등록 → 이후 revalidateTag()로 이 컴포넌트만 선택적 무효화 가능
cacheTag(`product-${productId}`);
const product = await fetch(`/api/products/${productId}`).then(r => r.json());
return <header>{product.name}</header>;
}cacheTag로 등록한 태그는 Server Action이나 Route Handler에서 revalidateTag('product-123')를 호출하는 것만으로 해당 캐시를 즉시 무효화(On-Demand Revalidation)할 수 있습니다. 예를 들어 상품 정보가 수정되면 관리자 API에서 revalidateTag(product-${productId})를 호출해 해당 상품 페이지만 선택적으로 다시 캐싱하는 방식으로 활용합니다.
실전 적용
예시 1: 이커머스 상품 상세 페이지
가장 대표적인 PPR 적용 시나리오입니다. 상품명·설명·이미지는 거의 변하지 않으므로 정적 셸에, 실시간 재고·가격·개인화 추천은 동적 홀로 분리합니다.
// app/products/[id]/page.tsx (Next.js 15)
export const experimental_ppr = true;
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>; // Next.js 15부터 params는 Promise
}) {
const { id } = await params;
return (
<main className="product-page">
{/* 정적 셸: 빌드 타임에 CDN으로 배포 */}
<Breadcrumb />
<ProductImages productId={id} />
<ProductDescription productId={id} />
<ShippingInfo />
{/* 동적 홀: 요청마다 서버에서 병렬 스트리밍 */}
<Suspense fallback={<PriceSkeleton />}>
<RealtimePrice productId={id} />
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<StockStatus productId={id} />
</Suspense>
<Suspense fallback={<CartSkeleton />}>
<CartButton productId={id} />
</Suspense>
<Suspense fallback={<RecommendationSkeleton />}>
<PersonalizedRecommendations productId={id} />
</Suspense>
</main>
);
}ProductImages와 ProductDescription은 내부에서 fetch를 사용하더라도 기본값이 캐시이므로 정적 셸에 포함됩니다:
// components/ProductImages.tsx — 정적 셸 컴포넌트
export async function ProductImages({ productId }: { productId: string }) {
// 기본 fetch는 캐시됨 → 빌드 타임에 정적 셸에 포함
const images = await fetch(`/api/products/${productId}/images`).then(r =>
r.json()
);
return (
<div className="product-images">
{images.map((img: { src: string; alt: string }) => (
<img key={img.src} src={img.src} alt={img.alt} />
))}
</div>
);
}반면 실시간 가격처럼 매 요청마다 새로 가져와야 하는 컴포넌트는 cache: 'no-store'를 명시해 동적으로 처리되도록 합니다:
// components/RealtimePrice.tsx — 동적 홀 컴포넌트
export async function RealtimePrice({ productId }: { productId: string }) {
// cache: 'no-store' → 매 요청마다 새로 페치 → Suspense 안에서 동적 처리
const price = await fetch(`/api/products/${productId}/price`, {
cache: 'no-store',
}).then(r => r.json());
return <div className="price">{price.amount.toLocaleString()}원</div>;
}| 영역 | 처리 방식 | 이유 |
|---|---|---|
| 상품 이미지·설명 | 정적 셸 | fetch 기본값(캐시), 거의 변하지 않음 |
| 네비게이션·브레드크럼 | 정적 셸 | 공통 레이아웃, 사용자 독립적 |
| 실시간 가격·재고 | 동적 홀 | cache: 'no-store', 실시간성 요구 |
| 장바구니 버튼 | 동적 홀 | 쿠키 기반 사용자 상태 필요 |
| 개인화 추천 | 동적 홀 | 사용자별 다른 결과 |
예시 2: SaaS 대시보드 — 병렬 스트리밍의 이점
대시보드 시나리오에서 PPR이 SSR보다 유리한 핵심 이유는 병렬 스트리밍입니다. SSR에서는 모든 데이터 페치가 완료되어야 첫 화면이 그려지지만, PPR에서는 각 <Suspense> 홀이 독립적으로 병렬 처리됩니다. 아래 예시에서 각 주석의 소요 시간에 주목하시면 좋습니다:
// app/dashboard/page.tsx
export const experimental_ppr = true;
export default function DashboardPage() {
return (
<DashboardLayout> {/* 정적: 사이드바, 헤더, 그리드 컨테이너 */}
{/* 아래 네 홀은 각각 독립적으로 병렬 시작됩니다 */}
<Suspense fallback={<KpiSkeleton />}>
<KpiMetrics /> {/* DB 쿼리 200ms */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* 외부 API 450ms */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders /> {/* DB 쿼리 120ms */}
</Suspense>
<Suspense fallback={<AlertSkeleton />}>
<SystemAlerts /> {/* 내부 API 80ms */}
</Suspense>
</DashboardLayout>
);
}병렬 스트리밍 효과: SSR이라면 200 + 450 + 120 + 80 = 850ms를 모두 기다린 뒤 페이지가 그려집니다. PPR에서는 레이아웃이 CDN에서 즉시 응답하고, 네 홀이 동시에 시작되므로 가장 느린 외부 API 기준인 450ms 이후 모든 홀이 채워집니다. 사용자는 즉시 인터페이스 구조를 보고, 빠른 홀부터 순서대로 채워지는 자연스러운 경험을 하게 됩니다.
예시 3: 인증 앱의 공개/개인화 영역 분리
// app/home/page.tsx
export const experimental_ppr = true;
export default function HomePage() {
return (
<>
{/* 정적: 로그인 여부와 무관한 공개 콘텐츠 */}
<HeroSection />
<FeaturedContent />
<PublicNav />
{/* 동적: 로그인 상태 및 사용자별 콘텐츠 */}
<Suspense fallback={<UserNavSkeleton />}>
<UserNavBar /> {/* 내부에서 cookies()로 세션 확인 */}
</Suspense>
<Suspense fallback={<NotificationSkeleton />}>
<Notifications /> {/* 사용자별 알림 */}
</Suspense>
</>
);
}cookies(), headers() 같은 요청 종속 API를 호출하는 컴포넌트는 반드시 <Suspense> 안에 위치시키는 것이 중요합니다. 그렇지 않으면 해당 컴포넌트가 페이지 전체를 동적으로 만들어버립니다.
흔한 실수 3가지
1. cookies() / headers()를 <Suspense> 바깥에서 호출하는 경우
<Suspense> 바깥의 컴포넌트에서 cookies()나 headers()를 호출하면 해당 페이지 전체가 동적으로 처리됩니다. 요청 종속 데이터에 접근하는 컴포넌트는 항상 <Suspense> 안쪽에 위치시키는 것이 안전합니다.
// ❌ 잘못된 예: Suspense 바깥의 UserGreeting이 cookies()를 호출 → 페이지 전체 동적 처리
export default function Page() {
return (
<main>
<StaticHero />
<UserGreeting /> {/* cookies() 호출 → 정적 셸 무효화 */}
</main>
);
}
// ✅ 올바른 예: Suspense로 감싸 해당 영역만 동적 처리
export default function Page() {
return (
<main>
<StaticHero />
<Suspense fallback={<UserGreetingSkeleton />}>
<UserGreeting />
</Suspense>
</main>
);
}2. 폴백 UI 없이 <Suspense>만 선언하는 경우
fallback prop을 생략하거나 null로 두면 동적 데이터가 채워지기 전까지 해당 영역이 비어 보이고 레이아웃 이동(CLS)이 발생할 수 있습니다. 스켈레톤이나 스피너처럼 시각적 피드백을 주는 폴백 UI를 함께 설계하는 것을 권장합니다.
// ❌ fallback 없는 Suspense — 레이아웃 이동(CLS) 발생 가능
<Suspense>
<DynamicWidget />
</Suspense>
// ✅ 스켈레톤으로 공간을 예약하고 시각적 피드백 제공
<Suspense fallback={<WidgetSkeleton />}>
<DynamicWidget />
</Suspense>3. Suspense 경계를 너무 크게 잡는 경우
페이지의 넓은 영역을 하나의 <Suspense>로 감싸면 그 안의 모든 콘텐츠가 가장 느린 데이터를 기다려야 합니다. 독립적인 데이터를 가진 컴포넌트는 각각의 <Suspense>로 분리해 병렬 스트리밍의 이점을 최대로 활용하는 것이 좋습니다.
// ❌ 하나의 큰 Suspense — 모든 위젯이 가장 느린 API를 기다림
<Suspense fallback={<DashboardSkeleton />}>
<KpiMetrics />
<RevenueChart /> {/* 가장 느린 외부 API */}
<RecentOrders />
</Suspense>
// ✅ 독립적인 Suspense — 각 위젯이 준비되는 즉시 화면에 등장
<Suspense fallback={<KpiSkeleton />}><KpiMetrics /></Suspense>
<Suspense fallback={<ChartSkeleton />}><RevenueChart /></Suspense>
<Suspense fallback={<TableSkeleton />}><RecentOrders /></Suspense>장단점 분석
장점
| 항목 | 내용 |
|---|---|
| FCP 대폭 단축 | 정적 셸이 CDN 엣지에서 즉시 제공되므로 TTFB가 DB 쿼리 속도가 아닌 사용자-CDN 지리적 거리에 의해 결정됩니다 |
| 단일 HTTP 왕복 | 정적 HTML과 동적 스트리밍이 동일한 응답 스트림으로 전달되어 불필요한 네트워크 왕복이 없습니다 |
| 서버 부하 절감 | 정적 부분은 CDN이 처리하고, 오리진 서버는 동적 부분만 담당합니다 |
| SSG+SSR 균형 | SSG처럼 빠르면서 SSR처럼 동적인 콘텐츠를 동시에 제공할 수 있습니다 |
| 점진적 도입 가능 | incremental 모드로 기존 프로젝트의 특정 라우트에만 선택적으로 적용할 수 있습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Suspense 경계 설계 부담 | 정적/동적 경계를 명시적으로 설계해야 하며, 잘못 배치 시 효과가 없거나 빌드 오류가 발생합니다 | 컴포넌트별 데이터 의존성을 사전에 분류하고, 설계 문서를 팀과 공유하는 것을 권장합니다 |
| 쿠키·헤더 접근 주의 | cookies(), headers() 호출 컴포넌트가 <Suspense> 밖에 있으면 페이지 전체가 동적으로 처리됩니다 |
요청 데이터에 접근하는 모든 컴포넌트는 <Suspense> 안에 위치시키는 규칙을 팀 전체가 공유하는 것이 좋습니다 |
| 빌드 오류 감지 난이도 | <Suspense> 미적용 상태에서 캐시되지 않은 데이터 접근 시 Uncached data was accessed outside of <Suspense> 오류가 발생합니다 |
개발 환경에서 오류 메시지를 적극 활용하고, 빌드 파이프라인에 PPR 호환성 체크를 포함시키면 좋습니다 |
| 플랫폼 의존성 | CDN 엣지 캐싱과 스트리밍 최적화를 최대로 활용하려면 Vercel 또는 PPR을 지원하는 플랫폼이 필요합니다 | 자체 호스팅 환경에서는 Node.js 스트리밍 HTTP 설정과 CDN 레이어를 별도로 구성해야 합니다 |
| App Router 전용 | React Server Components 아키텍처를 전제로 하므로 Pages Router에서는 사용할 수 없습니다 | App Router로 마이그레이션하거나 신규 라우트에서만 적용하는 전략이 필요합니다 |
| 학습 곡선 | 기존 SSR/SSG 사고방식에서 "어느 경계까지 정적인가"를 고민하는 새로운 설계 사고가 필요합니다 | 소규모 라우트부터 적용해보면서 경계 설계 감각을 키워가는 점진적 학습을 권장합니다 |
마치며
PPR을 도입할 때 가장 먼저 점검해야 할 것은 Suspense 경계 설계입니다. 어떤 컴포넌트가 요청 종속 데이터(cookies(), headers(), cache: 'no-store' fetch)를 사용하는지 파악하고, 그 경계를 각각의 <Suspense>로 감싸는 것만으로도 SSG 수준의 FCP와 SSR 수준의 동적 데이터를 동시에 얻을 수 있습니다.
Next.js 15의 incremental 모드를 활용하면 기존 프로젝트에서 라우트 단위로 시작해볼 수 있으며, Next.js 16에서 use cache 지시문과 함께 안정화된 Cache Components API로 자연스럽게 마이그레이션할 수 있습니다. 아래 3단계로 시작해보시면 좋습니다.
next.config.ts에 PPR 플래그를 추가합니다. Next.js 15라면experimental: { ppr: 'incremental' }, Next.js 16이라면cacheComponents: true를 설정한 뒤, 가장 트래픽이 많은 라우트 파일 최상단에export const experimental_ppr = true를 선언해보시면 됩니다.- 해당 라우트의 컴포넌트를 정적/동적으로 분류합니다.
cookies(),headers(),cache: 'no-store'fetch, 사용자 개인화 데이터에 접근하는 컴포넌트를 리스트업하고, 이 컴포넌트들을 각각<Suspense fallback={<ComponentSkeleton />}>으로 감싸보시면 됩니다. - 빌드 후 Network 탭과 Core Web Vitals를 비교합니다.
pnpm build && pnpm start로 프로덕션 빌드를 실행한 뒤 Chrome DevTools의 Network 탭에서 첫 응답이 스트리밍되는 모습을 확인하고, Lighthouse로 FCP 수치 변화를 측정해보시면 됩니다.
다음 글: 이 글에서 간략히 소개한
cacheLife의 전체 옵션과cacheTag기반 On-Demand 무효화 패턴, 그리고 Server Action에서revalidateTag를 활용한 실시간 캐시 갱신 전략을 다음 글에서 이어서 다룹니다 — Next.js 16 Cache API 완전 가이드.
참고 자료
- Partial Prerendering | Next.js 공식 문서 (v15)
- Partial Prerendering — Cache Components | Next.js 공식 문서 (v16)
- Partial prerendering: Building towards a new default rendering model | Vercel 블로그
- Next.js 16 릴리스 노트
- Next.js 15 릴리스 노트
- ppr 설정 API 레퍼런스 | Next.js
- cacheComponents 설정 API 레퍼런스 | Next.js
- Next.js Partial Prerendering (PPR) Deep Dive | DEV Community
- Next.js 16 Release Analysis | InfoQ
- A guide to enabling partial pre-rendering in Next.js | LogRocket
- partialprerendering.com