🚀 Next.js 심화 6장: Partial Prerendering (PPR) — 정적과 동적의 완벽한 공존

📋 개요

PPR로 정적 셸과 동적 콘텐츠를 하나의 응답으로 결합하는 Next.js 최신 렌더링 전략입니다.

📋 목차


📌 이 문서를 읽기 전에

⏱️ 예상 읽기 시간: 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>
 
      {/* ✅ 정적 셸: 푸터도 정적으로 처리 */}
      <footer2026 영수 커뮤니티</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에서 "동적 구멍"이 되는 조건이 아닌 것은?

  • A) cookies()를 사용하는 컴포넌트
  • B) headers()를 사용하는 컴포넌트
  • C) <Suspense>로 감싸지지 않은 서버 컴포넌트
  • D) cache: 'no-store' 옵션으로 fetch하는 컴포넌트

정답: C

해설: 동적 함수(cookies, headers)를 사용하더라도 <Suspense>로 감싸지 않으면 정적 셸을 오염시켜 페이지 전체가 Dynamic Rendering이 돼. 동적 구멍은 <Suspense> 경계로 격리해야 해.

📌 핵심 기억법: "동적 구멍 = 동적 함수 + Suspense 경계, 둘 다 있어야 해."


Q2. 아래 코드에서 문제를 찾아보자.

export default function DashboardPage() {
  return (
    <Suspense fallback={<Loading />}>
      <h1>대시보드</h1>         {/* 항상 동일한 정적 텍스트 */}
      <UserGreeting />           {/* cookies()로 사용자 이름 읽음 */}
    </Suspense>
  )
}

이 코드의 문제점은?

  • A) <h1>key prop이 없다
  • B) 정적인 <h1>이 불필요하게 Suspense 안에 들어가 있다
  • C) UserGreeting<Suspense> 안에 있으면 안 된다
  • D) fallback prop에 컴포넌트를 직접 전달하면 안 된다

정답: B

해설: <h1>은 정적인데 <Suspense> 안에 넣으면 UserGreeting이 로드될 때까지 <h1>도 안 보여. PPR의 이점이 사라져.

📌 핵심 기억법: Suspense 경계는 정확히 동적 컴포넌트만 감싸야 해.


Q3. 친구에게 설명한다면?

PPR이 "일반 SSR보다 빠른 이유"를 비유로 설명해봐.

예시 답변:

"음식점 비유야. SSR은 모든 요리가 다 완성돼야 서빙하는 식당이야. 국밥이 다 끓을 때까지 손님이 빈 상에 앉아서 기다려. PPR은 먼저 물이랑 반찬부터 내주고(정적 셸), 국밥은 끓으면서 가져다줘(스트리밍). 손님이 기다리는 시간이 체감상 훨씬 줄어드는 거야."


🐣 영철이의 퇴근 일기

오늘은 정말 넥스트의 미래라 불리는 'Partial Pre-rendering(PPR)' 을 배우면서 무릎을 탁 쳤어! 그동안 "정적이냐 동적이냐" 하나만 골라야 하는 줄 알았는데, 한 페이지 안에서 정적 셸과 동적 구멍이 공존할 수 있다는 게 정말 혁명적이었어.

💡 오늘의 교훈: "사용자에게 0.1초 만에 '껍데기(셸)' 를 먼저 비춰주고, '속살(데이터)' 은 스트리밍으로 채워넣자. PPR은 성능과 개인화라는 두 마리 토끼를 잡는 마법의 열쇠다!"

영호 리드 님이 국밥집 반찬 비유를 들어 설명해 주실 때, 왜 Suspense 경계를 잘 나누는 게 실력인지 단번에 이해가 가더라. 단순히 속도를 높이는 걸 넘어, 사용자가 기다리는 시간을 지루하지 않게 설계하는 게 진짜 '센스 있는' 개발자의 자세라는 걸 깨달았어. 오늘 너무 기분 좋게 일했더니 보상심리가 발동하네. 퇴근길에 진짜 '배달 국밥' 하나 시켜서 맛있게 먹어야지! 내일은 더 '빠릿빠릿한' 서비스를 만드는 개발자가 될 거야! 🐣


🔗 더 알아보기