| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | ||
| 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 |
| 27 | 28 | 29 | 30 |
- Next.js 렌더링 흐름 이해하기: Static
- 전역 상태관리
- context api
- zustand
- 상태관리
- 자바스크립트 클로저
- 실행 컨텍스트
- zustand persist
- next.js 배포
- Partial Prerendering
- PPR #
- 새로고침 상태 유지
- next build
- partialize
- 웹뷰 쿠키
- 리팩터링 2판
- 프론트엔드 면접
- userdefaults
- react context
- 렉시컬 환경
- useReducer
- Next.js 16
- 만다라트 계획서
- Context vs Redux
- body-parser
- 파일 트레이싱
- WKHTTPCookieStore
- 디자인 지구력 가설
- Node.js 프로젝트 세팅
- react
- Today
- Total
modori@
Next.js 렌더링 흐름 이해하기: Static, Dynamic, PPR 본문
Next.js PPR, 정적 렌더링과 동적 렌더링 사이
PPR은 한 페이지 안에 있는 정적인 부분과 동적인 부분을 나눠서, 정적인 건 미리 만들어두고 동적인 건 요청이 왔을 때만 계산하는 부분 사전 렌더링 방식을 말합니다.

Next.js 16(정확히는 cacheComponents 옵션)에서 기본으로 지향하는 방식으로 2023년에 처음 나왔고, Next.js 16의 Cache Components에서 비로소 완성된 형태가 됐습니다. PPR이 왜 나왔는지 이해하기위해 Next.js가 원래 페이지를 어떻게 그렸는지부터 짚고 가보도록 하겠습니다.
Static Rendering
Static Rendering은 페이지를 미리 만들어 두는 방식입니다. Next.js는 빌드할 때, 또는 데이터를 재검증할 때 페이지를 렌더링하고, 그 결과를 캐시에 저장해 CDN에서 바로 내려줄 수 있습니다.
// app/about/page.tsx
export default function Page() {
return <h1>회사 소개</h1>
}
이 페이지는 누가 언제 들어와도 "회사 소개" 한 줄이 전부입니다.
따로 바뀔 컨텐츠가 없는거죠.
그러니 빌드할 때 HTML을 만들어 두고, 요청이 오면 그걸 그대로 내려주면 됩니다.

요청을 받은 서버는 따로 할 일이 없으니 빠르고 비용도 적게 듭니다. 대신 빌드 시점에 내용이 정해지기 때문에 사용자에 따라 다른 컨텐츠를 보여주거나 상태에 따라서 새로운 컨텐츠를 보여줄 수 없습니다.
Dynamic Rendering
반대로 Dynamic Rendering은 요청이 올 때마다 서버에서 페이지를 새로 만듭니다.
// app/page.tsx
import { cookies } from 'next/headers'
export default async function Page() {
const user = (await cookies()).get('user')?.value
return <h1>{user}님, 안녕하세요</h1>
}
user는 쿠키에 들어 있어서 요청이 와야 알 수 있는 값입니다. 미리 만들어 둘 방법이 없으니 요청마다 페이지를 렌더링하게 됩니다.

사용자마다 다른 내용을 보여줄 수 있다는 건 분명한 장점입니다. 하지만 그림처럼 서버가 렌더링을 끝낼 때까지 사용자는 빈 화면만 보고 있어야 합니다.
애매한 페이지들은 어떻게 하지?
문제는 렌더링 방식의 선택을 페이지 단위로 해야 했다는 점입니다.
예전엔 페이지 하나가 "완전 정적" 아니면 "완전 동적" 둘 중 하나였습니다. 페이지 대부분이 정적이어도 로그인 인사말 하나, 실시간 가격 하나 때문에 페이지 전체를 동적으로 만들어야 했죠. 페이지 맨 위에서 cookies()나 headers()를 한 번만 읽어도 라우트 전체가 동적 렌더링으로 넘어갔습니다.
상품 페이지를 떠올려 보면, 상품명이나 설명은 누가 봐도 같지만 장바구니나 인사말은 사람마다 다릅니다. 그 몇 줄 때문에 나머지 부분까지 매번 새로 그리는 건 꽤 아까운 일이죠.
PPR은 이 낭비를 없애줍니다.
PPR(Partial Prerendering)은 어떻게 동작할까
import { Suspense } from 'react'
import { connection } from 'next/server'
export default function Page() {
return (
<>
<h1>Hello!</h1>
<Suspense fallback={<p>Loading time...</p>}>
<Now />
</Suspense>
</>
)
}
async function Now() {
await connection()
return <p>{new Date().toLocaleTimeString()}</p>
}
<h1>Hello!</h1>은 언제 봐도 똑같으니 빌드할 때 HTML로 만들어 둡니다. 여기까지는 Static Rendering과 다를 게 없습니다.
달라지는 건 Suspense부터입니다. PPR에서는 Suspense를 기준으로 페이지가 나뉩니다. 바깥은 빌드할 때 렌더링하고, 안쪽은 요청이 왔을 때 렌더링합니다. 빌드 결과물에는 <Now /> 대신 fallback으로 넘긴 Loading time...이 들어갑니다. 이렇게 만들어진 HTML을 static shell이라고 부릅니다.

Now 안에 있는 await connection()은 처음 보면 좀 낯섭니다. 이 컴포넌트는 현재 시간을 보여주는데, 빌드할 때 시간을 계산해 버리면 빌드한 순간의 시각이 HTML에 그대로 박혀 버립니다.
그래서 cacheComponents를 켠 상태에서는 사전 렌더링 중에 new Date()나 Math.random()을 호출하면 빌드가 실패합니다. 이럴 때는 해당 부분을 Suspense로 감싸고, 그 앞에서 connection()을 호출하면 됩니다.
connection()은 "이 아래 코드는 실제 요청이 들어왔을 때 실행해 달라"는 표시입니다. cookies()처럼 요청에서 무언가를 읽어 오지는 않지만, 요청 시점에 실행돼야 하는 코드 앞에 둡니다.
요청이 들어오면

브라우저가 페이지를 요청하면 서버는 빌드 때 만들어 둔 static shell부터 바로 보냅니다. 사용자는 이때 이미 Hello!와 Loading time...을 보고 있습니다.
그 사이 서버는 <Now />만 렌더링합니다. 결과가 나오면 같은 응답에 이어서 흘려보내고, Loading time... 자리가 실제 시간으로 바뀝니다.
첫 화면은 Static Rendering처럼 바로 뜨고, 요청마다 달라지는 값도 Dynamic Rendering처럼 보여줄 수 있는 셈입니다. 게다가 서버가 매번 계산하는 범위는 <Now /> 하나로 줄어듭니다.
Suspense 위치가 중요하다
PPR을 켰다고 페이지가 알아서 잘 나뉘지는 않습니다. 요청 데이터를 어디서 읽느냐에 따라 결과가 달라집니다. Suspense 밖에서 cookies()나 headers()를 읽으면 blocking-prerender-runtime 경고가 뜨는데, 이때는 요청 데이터를 읽는 코드를 Suspense 안쪽 컴포넌트로 옮겨야 합니다.
앞에서 본 Dynamic Rendering 예제를 그대로 두면 static shell을 만들 수 없습니다.
export default async function Page() {
const user = (await cookies()).get('user')?.value
return <h1>{user}님, 안녕하세요</h1>
}
쿠키를 읽는 부분만 따로 빼서 Suspense로 감싸 보겠습니다.
export default function Page() {
return (
<>
<h2>오늘의 추천 상품</h2>
<Suspense fallback={<p>Loading...</p>}>
<Greeting />
</Suspense>
</>
)
}
async function Greeting() {
const user = (await cookies()).get('user')?.value
return <h1>{user}님, 안녕하세요</h1>
}
이렇게 하면 "오늘의 추천 상품"은 static shell에 들어가고, 인사말만 요청 시점에 채워집니다. 요청 데이터를 읽는 코드는 되도록 작은 컴포넌트로 내려 두는 게 좋습니다. Suspense 경계가 아래에 있을수록 미리 만들어 둘 수 있는 영역이 넓어지니까요.
설정
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
Next.js 16에서는 experimental.ppr 플래그가 없어지고 cacheComponents로 통합됐습니다. 15 버전에서 실험 기능으로 써 봤다면 설정을 바꿔야 합니다. 그리고 Cache Components는 Node.js 런타임에서만 동작하므로 runtime = 'edge'를 쓰고 있다면 함께 확인해야 합니다.
마치며
Static Rendering은 빠르지만 모두에게 같은 화면을 보여주고, Dynamic Rendering은 사람마다 다른 화면을 보여줄 수 있지만 느립니다. 예전에는 이 둘을 페이지 단위로 골라야 했는데, PPR 덕분에 Suspense를 경계로 한 페이지 안에서 섞어 쓸 수 있게 됐습니다. PPR을 쓰다 보면 가장 많이 고민하게 되는 건 결국 Suspense를 어디에 둘지일 것 같습니다.