⚡ Next.js 심화 6장: Partial Prerendering (PPR) — 정적과 동적의 완벽한 공존
📋 개요
PPR로 정적 셸과 동적 콘텐츠를 하나의 응답으로 결합하는 Next.js 최신 렌더링 전략입니다.
📋 목차
- 📌 이 문서를 읽기 전에
- 🤔 왜 알아야 하는가
- 🏗️ 비유로 먼저 이해하기
- 🧩 PPR의 핵심 아이디어: 정적 셸 + 동적 구멍 🟢
- 🔧 PPR 활성화와 기본 패턴 🟡
- 📐 Suspense 경계와 PPR의 관계 🟡
- ⚡ PPR vs ISR vs Dynamic Rendering 비교 🔴
- 💥 에러 해결 카탈로그
- 🏁 이번에 배운 내용 총정리
- 📝 마무리 퀴즈
- 🔗 더 알아보기
📌 이 문서를 읽기 전에
⏱️ 예상 읽기 시간: 15분 (전체) / 핵심 파트만: 8분
🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'
- 영철(신입): "게시판 페이지에서 헤더 Nav, 사이드바는 항상 똑같은데, 메인 글 목록만 실시간으로 바뀌어요. 그런데 Dynamic Rendering이라 헤더도 매 요청마다 새로 렌더해야 한대요. 정적인 헤더는 캐시하고 동적인 목록만 즉시 렌더하면 안 되나요?"
- 영호(리드): "영철 님이 방금 PPR을 설명한 거예요! Partial Prerendering은 한 페이지 안에서 '변하지 않는 껍데기(셸)'는 빌드 타임에 미리 만들어두고, '실시간 데이터가 필요한 구멍'만 요청 시 채워 넣는 거예요. 헤더·푸터는 즉시 HTML로 브라우저에 도달하고, 글 목록은 스트리밍으로 뒤따라와요."
🗺️ 이 문서의 흐름
PPR의 탄생 배경 → 정적 셸 + 동적 구멍 개념 → Suspense 경계와의 관계 → 기존 전략들과 비교
🎯 이 문서를 다 읽으면 할 수 있는 것
- PPR이 기존 SSG/SSR/ISR의 어떤 한계를 해결하는지 설명할 수 있다
-
<Suspense>경계로 페이지의 정적/동적 영역을 명확히 나눌 수 있다 - 어떤 상황에서 PPR이 최선의 선택인지 판단할 수 있다
🤔 왜 알아야 하는가
지금까지 배운 렌더링 전략의 한계를 돌아봐:
Static Rendering (SSG/ISR): 빠르지만 개인화 불가. 모든 사람에게 똑같은 HTML.
Dynamic Rendering (SSR): 개인화 가능하지만 느림. 매 요청마다 처음부터 렌더링.
실제 페이지를 분석해보면:
- 헤더 Nav: 항상 동일 → 정적으로 해도 됨
- 사이드바: 항상 동일 → 정적으로 해도 됨
- 글 목록: 실시간 변경 → 동적 필요
- 추천 유저: 로그인한 사람마다 다름 → 동적 필요
현실의 대부분 페이지는 "일부는 정적, 일부는 동적" 인데, 지금까지는 하나라도 동적이면 페이지 전체가 Dynamic Rendering이 돼버렸어.
PPR(Partial Prerendering) 은 이 모순을 해결해. 한 페이지 안에서 영역별로 정적/동적을 독립적으로 결정할 수 있어.
🏗️ 비유로 먼저 이해하기
🧒 5살에게 설명한다면?
신문 인쇄소를 생각해봐. 신문 틀(레이아웃, 헤더, 섹션 제목)은 미리 찍어두고, 뉴스 내용만 마지막에 집어넣는 거야. 틀은 공장에서 대량으로 미리 만들어 (정적 셸), 내용은 인쇄 직전에 오늘 기사로 채워 (동적 구멍).PPR이 그거야. 페이지의 "틀"은 빌드 타임에 완성되어 CDN에 올라가 있어. 요청이 오면 틀을 즉시 보내고, 동적 내용은 스트리밍으로 뒤따라 와.
PPR 페이지가 로드되는 순서:
⚡ 즉시 (빌드 타임에 미리 만들어진 정적 HTML):
- 헤더 (Nav, 로고)
- 페이지 제목, 설명
- 레이아웃 구조 (Suspense fallback 포함)
🌊 스트리밍으로 뒤따라오는 동적 콘텐츠:
- 로그인한 유저 이름
- 실시간 게시글 목록
- 추천 스터디 (개인화)
🧩 PPR의 핵심 아이디어: 정적 셸 + 동적 구멍 🟢
🎯 이 섹션을 읽고 나면:
- PPR에서 "정적 셸"과 "동적 구멍"이 무엇인지 구분할 수 있다
<Suspense>가 동적 구멍을 정의하는 역할을 한다는 걸 이해한다
PPR의 핵심 아이디어는 단순해:
<Suspense>로 감싼 부분 = "동적 구멍"
<Suspense>밖 = "정적 셸"
// app/posts/page.tsx — PPR 활성화된 페이지
import { Suspense } from 'react'
import { NavBar } from '@/components/features/NavBar'
import { PostList } from '@/components/features/posts/PostList'
import { PostListSkeleton } from '@/components/ui/PostListSkeleton'
import { PersonalizedSidebar } from '@/components/features/PersonalizedSidebar'
export default function PostsPage() {
return (
<div>
{/* ✅ 정적 셸: 빌드 타임에 HTML로 고정 */}
<NavBar />
<h1>스터디 게시판</h1>
<div className="layout">
{/* 🕳️ 동적 구멍: 요청 시점에 스트리밍 */}
<Suspense fallback={<PostListSkeleton />}>
<PostList /> {/* DB 조회, 실시간 데이터 */}
</Suspense>
{/* 🕳️ 동적 구멍: 사용자별 개인화 사이드바 */}
<Suspense fallback={<div>추천 로딩 중...</div>}>
<PersonalizedSidebar /> {/* cookies(), headers() 사용 → 동적 */}
</Suspense>
</div>
{/* ✅ 정적 셸: 푸터도 정적으로 처리 */}
<footer>© 2026 영수 커뮤니티</footer>
</div>
)
}동적 구멍이 되는 조건:
컴포넌트가 아래 중 하나를 사용하면 자동으로 동적 처리돼:
cookies()— 쿠키 읽기headers()— 요청 헤더 읽기searchParams— URL 쿼리 파라미터noStore()— 캐시 비활성화 명시- 외부 fetch 중
cache: 'no-store'
💡 한 줄로 기억하기
<Suspense>는 PPR의 "구멍 뚫는 도구"야.<Suspense>경계 안의 동적 컴포넌트는 정적 셸을 방해하지 않아.
🔧 PPR 활성화와 기본 패턴 🟡
🎯 이 섹션을 읽고 나면:
next.config.ts에서 PPR을 활성화하는 방법을 안다- 특정 라우트에만 PPR을 적용하는 방법을 안다
PPR 활성화 (Next.js 15, experimental):
// next.config.ts
const nextConfig = {
experimental: {
ppr: 'incremental', // 점진적 도입: 라우트별 opt-in
// ppr: true, // 전체 활성화 (Next.js 16+에서 안정화 예정)
},
}라우트별 PPR 활성화:
// app/posts/page.tsx
// 이 라우트에서만 PPR 사용 opt-in
export const experimental_ppr = true
export default function PostsPage() {
// ...
}PPR과 함께 쓰는 <Suspense> 패턴:
// components/features/posts/PostList.tsx
// 이 컴포넌트 자체는 서버 컴포넌트
import { db } from '@/lib/db'
export async function PostList() {
// DB 조회 (동적)
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
})
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// app/posts/page.tsx에서 Suspense로 감싸서 사용
// <Suspense fallback={<PostListSkeleton />}>
// <PostList />
// </Suspense>📐 Suspense 경계와 PPR의 관계 🟡
🎯 이 섹션을 읽고 나면:
- Suspense 경계를 세밀하게 나눌수록 더 많은 영역이 정적 처리되어 성능이 좋아진다는 걸 안다
Suspense 경계를 얼마나 세밀하게 나누느냐가 성능에 직결돼.
// ❌ Suspense를 너무 크게 쳐서 정적 영역이 줄어드는 경우
export default function PostsPage() {
return (
// 이렇게 하면 h1까지 동적 구멍에 들어가버림!
<Suspense fallback={<Loading />}>
<h1>스터디 게시판</h1> {/* 이건 정적이어도 되는데... */}
<PostList /> {/* 이것만 동적이어야 하는데 */}
</Suspense>
)
}
// ✅ Suspense를 정확히 동적 컴포넌트에만 적용
export default function PostsPage() {
return (
<>
<h1>스터디 게시판</h1> {/* 정적 셸에 포함 → 즉시 브라우저에 도달 */}
<Suspense fallback={<PostListSkeleton />}>
<PostList /> {/* 동적 구멍 → 스트리밍 */}
</Suspense>
</>
)
}중첩 Suspense 패턴:
export default function PostsPage() {
return (
<>
<h1>스터디 게시판</h1>
{/* 외부 Suspense: 게시글 섹션 전체 */}
<Suspense fallback={<SectionSkeleton />}>
<div className="posts-section">
<PostList />
{/* 내부 Suspense: 댓글 수는 별도로 로드 */}
<Suspense fallback={<CommentCountSkeleton />}>
<CommentCounts />
</Suspense>
</div>
</Suspense>
</>
)
}⚠️ 주의: Suspense 중첩이 너무 깊어지면 오히려 UX가 안 좋아질 수 있어. "이 영역이 얼마나 자주 바뀌는가"를 기준으로 경계를 나눠.
⚡ PPR vs ISR vs Dynamic Rendering 비교 🔴
| 전략 | 언제 생성 | 개인화 | 즉시 응답 | 실시간 |
|---|---|---|---|---|
| Static | 빌드 타임 | ❌ | ✅ 최고 | ❌ |
| ISR | 빌드 + 재검증 | ❌ | ✅ 좋음 | △ (주기적) |
| Dynamic | 매 요청 | ✅ | ❌ 느림 | ✅ |
| PPR | 셸=빌드, 구멍=요청 | ✅ | ✅ 셸은 즉시 | ✅ 구멍은 스트리밍 |
PPR이 최선인 상황:
- 헤더·푸터·레이아웃은 정적이지만 메인 콘텐츠가 동적인 페이지
- 로그인 여부에 따라 콘텐츠가 달라지는 대시보드
- 공개 콘텐츠(블로그 헤더)와 개인화 섹션(추천)이 혼재하는 페이지
PPR이 불필요한 상황:
- 페이지 전체가 100% 정적 → 그냥 Static이 더 단순
- 페이지 전체가 실시간 데이터 → Dynamic Rendering
💥 에러 해결 카탈로그
❌ PPR is experimental and may change in future versions
원인: PPR은 아직 experimental 기능이라 빌드 경고 발생.
해결책: next.config.ts에서 experimental.ppr을 명시적으로 설정하면 경고는 사라져.
❌ 정적 셸이어야 하는 컴포넌트가 동적으로 처리됨
원인: 정적 셸 영역의 컴포넌트가 간접적으로 cookies()나 headers()를 사용하는 경우.
해결책: 동적 함수를 사용하는 컴포넌트는 반드시 <Suspense> 안으로 이동시켜.
🏁 이번에 배운 내용 총정리
📋 PPR 구성 요소
| 구성 요소 | 역할 | 언제 생성 |
|---|---|---|
| 정적 셸 | <Suspense> 밖 영역 | 빌드 타임 |
| 동적 구멍 | <Suspense> 안 영역 | 요청 시 스트리밍 |
| Suspense fallback | 동적 구멍 로딩 중 표시 | 즉시 (정적 셸 포함) |
⚠️ 절대 하지 말 것
| 상황 | ❌ 나쁜 예 | ✅ 좋은 예 |
|---|---|---|
| 정적 컨텐츠를 Suspense 안에 | h1, 헤더를 Suspense로 감쌈 | 동적 컴포넌트만 감쌈 |
| Suspense fallback 생략 | <Suspense><PostList/></Suspense> | 반드시 fallback UI 제공 |
📝 마무리 퀴즈
Q1. PPR에서 정적 셸을 오염시키는 대표적인 실수는 무엇인가?
✅ 정답: Suspense 경계 바깥에서 cookies(), headers(), no-store fetch처럼 요청 시점 데이터에 접근하는 것이다.
💡 상세 해설: PPR은 정적 셸을 먼저 보내고 동적 구멍을 스트리밍으로 채운다. 동적 API가 셸 영역에 섞이면 그 셸은 더 이상 미리 만들 수 없다. Suspense는 "느린 컴포넌트를 감싸는 장식"이 아니라 정적/동적 책임을 나누는 경계다.
Q2. PPR과 ISR의 차이를 가장 잘 설명한 것은?
✅ 정답: ISR은 경로 단위 HTML을 일정 주기로 다시 만들고, PPR은 한 경로 안에서 정적 셸과 동적 구멍을 함께 둔다.
💡 상세 해설: ISR은 "이 페이지를 언제 다시 구울까"에 가깝고, PPR은 "이 페이지 안에서 무엇을 미리 보내고 무엇을 요청 시점에 채울까"에 가깝다. 그래서 PPR 설계에서는 Suspense 위치와 동적 데이터 격리가 핵심 판단 기준이 된다.
Q3. 영철이의 테스트 타임: 대시보드의 좌측 내비게이션은 항상 같고, 중앙 알림 목록은 사용자마다 다르며 느리다. PPR을 적용한다면 어떻게 나눌까?
✅ 정답: 내비게이션과 제목은 정적 셸에 두고, 사용자 알림 목록은 Suspense 안의 동적 구멍으로 분리한다.
💡 상세 해설: 사용자 알림은 쿠키와 권한에 의존하므로 셸에 들어가면 정적성을 깨뜨린다. 반대로 공통 레이아웃은 요청마다 달라지지 않으므로 즉시 전송되는 셸에 적합하다. fallback은 실제 레이아웃 크기를 보존하는 스켈레톤으로 만들어 CLS도 함께 줄여야 한다.
🐣 영철이의 퇴근 일기
오늘은 PPR을 "정적과 동적 중 하나를 고르는 기능"이 아니라 한 화면 안에서 책임을 나누는 렌더링 모델로 이해했다. 빠른 첫 응답을 얻으려면 무엇을 먼저 보여줘도 안전한지, 무엇은 요청 시점까지 기다려야 하는지 구분해야 했다.
💡 "PPR의 핵심은 Suspense를 로딩 UI가 아니라 정적성의 경계로 읽는 것이다."
다음부터 대시보드 설계를 볼 때 컴포넌트를 시각적 덩어리로만 나누지 않겠다. 데이터가 사용자별인지, 공개인지, 느린지, 레이아웃을 밀어내는지까지 함께 보고 Suspense 경계를 제안하겠다.