Partial Prerendering

I
Inkyu Oh

Front-End2025.11.24

Wyatt Johnson 블로그 포스트 번역
2025년 9월 1일


지난해 Next.js Conf에서 저희는 새로운 렌더링 패러다임인 Partial Prerendering(PPR)을 발표했습니다. Next.js의 강력한 데이터 및 라우트 캐싱 지원을 기반으로 하는 PPR은 페이지의 정적 부분을 빠르게 제공하면서도 요청 데이터에 접근해야 하는 컴포넌트의 서버 렌더링을 허용하는 방식으로 Next.js의 제공 방식을 최적화합니다.
기능에 대한 실험적 지원을 발표한 이후, 저희는 새로운 실험적 플래그인 dynamicIO를 통해 Next.js가 요청 데이터 접근을 감지하는 데 사용하는 핵심 메커니즘 중 일부를 반복했습니다.

문제 공간 이해하기

PPR의 구현 세부 사항을 살펴보기 전에, 이 기능이 중요한 이유를 먼저 확인해 봅시다. 현대 웹 개발에서 저희는 성능과 기능 사이의 근본적인 긴장 관계에 직면하고 있습니다:
  • 정적 렌더링: 엣지에서 매우 빠르게 콘텐츠를 제공하지만 요청별 데이터에 접근할 수 없습니다
  • 동적 렌더링: 완전한 기능을 제공하지만 서버 계산이 필요하므로 첫 바이트까지의 시간이 증가합니다
이러한 이분법은 개발자들을 라우트 수준에서 모두 아니면 무(all-or-nothing) 결정을 내리도록 강요합니다. 단 하나의 쿠키에 접근하는 것만으로도 전체 페이지가 동적으로 표시되어 정적 생성의 성능 이점을 포기하게 됩니다.

Core Web Vitals: 북극성

PPR의 설계는 Core Web Vitals 최적화, 특히 다음을 중심으로 합니다:
  • Time to First Byte (TTFB): 서버가 초기 콘텐츠로 응답하는 속도
  • Largest Contentful Paint (LCP): 주요 콘텐츠가 표시되는 시점
목표는 2.5초 이하의 LCP 시간이며, 이상적으로는 수백 밀리초를 목표로 합니다. PPR은 정적 셸을 즉시 제공하면서 동적 콘텐츠를 병렬로 가져와 이를 달성합니다.

레거시 Partial Prerendering

Next.js 14에서 PPR을 발표했을 때, 이는 요청 데이터가 접근되었음을 Next.js에 신호하기 위해 오류를 던지는 방식에 의존했습니다. 저희는 redirect()notFound()와 같은 다른 API들처럼 작동할 것이라고 가정했으며, 이들은 오늘날에도 여전히 오류 던지기를 신호로 사용합니다. 문제는 기존 애플리케이션을 마이그레이션하는 개발자들이 예상치 못한 곳에서 오류가 던져지는 상황에서 발생했습니다.
import { setTimeout } from "node:timers/promises"
import { cookies } from "next/headers"

async function getPosts() {
for (let i = 0; i < 3; i++) {
try {
// Next.js 14에서는 사전 렌더링 중에 오류를 던졌습니다.
const session = cookies().get("session")
// ...
} catch (err) {
if (i === 2) {
throw err
}

// 여기서는 fetch가 실패했을 때 타임아웃이 추가됩니다.
await setTimeout(500 + i * 500)
}
}
}

export default async function Page() {
const posts = await getPosts()

// ...
}
이는 신뢰할 수 없는 백엔드를 처리하는 인기 있는 데이터베이스 드라이버 내에서도 발견되는 일반적인 패턴입니다. 백오프를 포함한 재시도 루프는 결국 요청 데이터 접근을 신호하기 위해 저희가 사용한 오류를 던지지만, 설정된 타임아웃 세트를 실행한 후에만 빌드를 극적으로 느리게 합니다.
또한 저희는 작업이 실패했을 때 폴백이 반환되거나, 더 나쁜 경우 오류가 숨겨지는 경우를 발견했습니다:
async function checkAuthorization() {
try {
const session = cookies().get("session")
// ...
} catch (err) {
throw new Error("AUTHORIZATION_CHECK_FAILED")
}
}
이러한 경우, Next.js는 어느 컴포넌트가 요청 데이터에 접근했는지 결정할 수 없었습니다. 이는 던져진 특수 오류를 포착하기 위해 주변 suspense 경계에 의존했기 때문입니다.
저희는 처음에 unstable_rethrow(err) API를 도입하여 이 문제를 해결하려고 시도했습니다:
import { unstable_rethrow } from "next/navigation"

async function checkAuthorization() {
try {
const session = cookies().get("session")
// ...
} catch (err) {
unstable_rethrow(err)

throw new Error("AUTHORIZATION_CHECK_FAILED")
}
}
이는 Next.js가 신호하기 위해 사용하는 내부 오류를 다시 던질 것입니다. 그러나 이는 새로운 문제를 도입했습니다: 개발자들이 이를 추가하는 것을 기억해야 했습니다. Next.js 내부 오류를 잠재적으로 생성할 수 있는 모든 호출 사이트는 애플리케이션 코드의 다른 throw 이전에 이 재던지기 함수를 삽입해야 합니다. 저희는 이것이 수용 가능한 개발자 경험이 아니라고 생각했습니다.
저희는 두 가지 옵션이 남았습니다. 요청 데이터가 접근되었음을 감지할 수 있지만 어디서인지는 알 수 없었으므로, 전체 페이지를 동적으로 표시하고 개발자에게 경고를 제공하거나 감지 메커니즘을 다시 생각할 수 있었습니다. 첫 번째 옵션은 이상적이지 않았습니다. 애플리케이션의 로직에 따라 재검증 시 전체 정적 셸을 제거할 수 있기 때문입니다. 저희는 후자의 접근 방식을 결정했습니다.

현대적 Partial Prerendering

저희는 사용자가 요청 데이터에 접근하려고 할 때 사용자 코드의 처리를 일시 중단할 수 있는 방법이 필요했습니다. 오류는 저희의 첫 번째 선택이었지만, 중첩된 try/catch 블록의 문제는 요청 데이터가 사용되고 있음을 Next.js에 알리기 위한 이러한 신호가 신뢰할 수 없을 것이며, 재시도 로직으로 인해 예상보다 긴 빌드 시간으로 이어질 수 있음을 의미했습니다.
하지만 오류를 던질 수 없다면, 저희에게 남은 원시 요소는 무엇일까요?
답은? Promise입니다.
개발자들은 이미 Promise 관리에 능숙합니다. React Server Components의 출현으로, 처음으로 사용자들은 비동기 컴포넌트를 작성할 수 있게 되었으며, 데이터 가져오기를 함께 배치할 수 있으면서도 fetch와 같이 이미 익숙한 네이티브 API를 사용할 수 있습니다.

Promise 기반 접근 방식

핵심 통찰력은 JavaScript의 고유한 비동기 동작을 활용하는 것이었습니다. 포착되고 잘못 처리될 수 있는 오류를 던지는 대신, 저희는 요청 API가 정적 사전 렌더링 중에 절대 해결되지 않을 Promise를 반환하도록 해야 했습니다. 이 접근 방식은 여러 이점을 제공합니다:
// 이전 - 오류 던지기를 포함한 동기식
function cookies() {
if (isPrerendering) {
throw new PostponeError("Cannot access cookies during prerendering")
}
return getCookies()
}

// 이후 - Promise를 포함한 비동기식
async function cookies() {
if (isPrerendering) {
// 절대 해결되지 않는 Promise 반환
return new Promise(() => {})
}
return getCookies()
}
이 변화는 모든 요청 API를 비동기식으로 업데이트해야 했습니다:
// Next.js 15 요청 API
await cookies()
await headers()
await connection() // unstable_noStore() 대체

Node.js 이벤트 루프 활용

실제 혁신은 Node.js 이벤트 루프가 어떻게 작동하는지 이해하는 것에서 비롯됩니다. Node.js는 단일 스레드이므로 한 번에 하나의 동기 코드 블록만 실행할 수 있습니다. I/O 작업을 수행해야 할 때, 이를 네이티브 코드로 오프로드합니다.
여기서 중요한 통찰력은: 비결정적 I/O는 동일한 Task에서 완료될 수 없습니다.
Next.js는 영리한 스케줄링 메커니즘을 사용하여 이를 활용합니다:
import { prerender } from 'react-dom/static.edge'

const controller = new AbortController()
await { prelude, postponed } = new Promise((resolve, reject) => {
let result
setImmediate(() => {
try {
result = prerender(<App />, { signal: controller.signal })
resolve(result)
} catch (err) {
reject(err)
}
})

setImmediate(() => {
controller.abort()
resolve(result)
})
})
이 접근 방식은 애플리케이션을 사전 렌더링하는 작업을 스케줄한 다음 즉시 이를 중단하는 다른 작업을 스케줄합니다. Node.js는 모든 마이크로태스크(이미 해결된 Promise 해결과 같은)를 다음 Task로 이동하기 전에 처리하므로, 동기 작업이 완료되도록 하면서 비동기 I/O 실행을 방지합니다.

예상적 렌더 전략

한 가지 과제가 남았습니다: 정적 셸에 외부 데이터를 포함하는 방법은 무엇일까요? CMS 또는 데이터베이스의 데이터는 단일 이벤트 루프 작업 내에서 완료되지 않을 비동기 가져오기가 필요합니다.
해결책은 예상적 렌더입니다:
// Next.js 캐시 API 사용
async function getData() {
'use cache'
const res = await fetch('...')
return res.json()
}

// 또는 unstable_cache 포함
const getData = unstable_cache(async () => {
const res = await fetch('...')
return res.json()
})
빌드 시간 동안, Next.js는 예상적 렌더를 수행하여 캐시 가능한 데이터에 대한 캐시 항목을 채웁니다. 이 렌더의 출력은 버려지지만 캐시는 준비됩니다. 실제 사전 렌더가 발생하면, 이러한 캐시된 값은 첫 번째 작업 내에서 동기식으로 해결될 수 있으므로 정적 셸에 포함될 수 있습니다.

부분 정적 vs. 완전 정적 페이지

이는 PPR의 두 가지 페이지 범주로 이어집니다:

부분 정적 페이지

캐시되지 않은 데이터 또는 요청 정보에 접근하는 페이지:
async function Page() {
const data = await fetch('...') // 캐시 없음
return (
<Suspense fallback={<Skeleton />}>
<Component data={data} />
</Suspense>
)
}
이들은 동적 콘텐츠를 스트리밍하기 위해 재개 렌더가 필요합니다.

완전 정적 페이지

모든 비동기 작업이 캐시되는 페이지:
async function Page() {
const data = await getData() // 캐시됨
return <Component data={data} />
}
이들은 원본 호출 없이 엣지에서 완전히 제공될 수 있습니다.

스트리밍 아키텍처

PPR은 단일 HTTP 응답 스트림을 사용하여 정적 및 동적 콘텐츠를 모두 제공합니다. 이 접근 방식은 왕복을 최소화하고 성능을 최적화합니다:
  1. 정적 셸이 즉시 스트리밍됩니다 엣지에서
  1. 재개 렌더가 동시에 시작됩니다 원본에서
  1. 동적 콘텐츠가 스트리밍됩니다 준비되면
  1. 단일 응답이 모든 것을 포함합니다
이 타이밍은 중요합니다. 브라우저가 정적 셸에서 힌트된 정적 리소스(CSS, JS)를 다운로드하는 동안, 서버는 이미 동적 콘텐츠를 렌더링하고 있습니다.

실제 구현

실제 예제를 통해 PPR이 실제로 어떻게 작동하는지 살펴봅시다:
import { Suspense } from "react"
import { cookies } from "next/headers"

async function getCart() {
const jar = await cookies()
// 세션을 기반으로 장바구니 데이터 가져오기
return fetchCartData(jar.get("session"))
}

async function Cart() {
const cart = await getCart()
return <CartDisplay items={cart.items} />
}

export default function Page() {
return (
<div>
<Header /> {/* 정적 - 셸에 포함됨 */}

<Suspense fallback={<CartSkeleton />}>
<Cart /> {/* 동적 - 나중에 스트리밍됨 */}
</Suspense>

<ProductListing /> {/* 정적 - 셸에 포함됨 */}
</div>
)
}
이 예제에서:
  • HeaderProductListing은 정적이며 초기 셸에 포함됩니다
  • Cart는 쿠키에 접근하므로 동적입니다
  • CartSkeleton은 정적 셸의 일부입니다
  • 실제 장바구니 콘텐츠는 사용 가능해지면 스트리밍됩니다

동적 I/O 활성화

현재 PPR은 두 가지 실험적 플래그가 필요합니다:
// next.config.js
module.exports = {
experimental: {
ppr: true,
dynamicIO: true
}
}
dynamicIO 플래그는 Promise 기반 감지 메커니즘을 활성화합니다. 계획은 PPR이 안정화되면 결국 이 플래그를 제거하는 것입니다.

동적 경로 매개변수

경로명 기반 API에 대한 특수 고려 사항: 전통적인 렌더링을 사용하면, /products/[id]와 같은 동적 경로는 전체 페이지를 동적으로 강제합니다. PPR과 비동기 params를 사용하면:
type Props = {
params: Promise<{ id: string }>
}

export default function Page({ params }: Props) {
return (
<Suspense fallback={<ProductDetailsSkeleton />}>
<ProductDetails params={params} />
</Suspense>
)
}
이는 모든 제품 ID에 대해 작동하는 폴백 정적 셸을 활성화하여 동적 라우트에서도 정적과 같은 성능을 제공합니다.

성능 영향

PPR의 성능 이점은 상당합니다:
  • TTFB 감소: 정적 셸이 엣지 위치에서 즉시 제공됩니다
  • LCP 개선: 중요한 콘텐츠가 병렬 가져오기를 통해 더 빠르게 로드됩니다
  • 더 나은 인지된 성능: 사용자는 대기하는 대신 즉시 콘텐츠를 봅니다
  • 효율적인 캐싱: 정적 부분은 CDN 엣지 노드에서 효과적으로 캐시됩니다

개발자 경험 고려 사항

PPR은 상당한 성능 이점을 제공하지만, 개발에 미치는 영향을 이해하는 것이 중요합니다:

이점

  • API 변경이 필요 없음 - 기존 Suspense 경계가 그냥 작동합니다
  • 정적/동적 경계에 대한 세분화된 제어
  • 수동 구성 없이 자동 최적화

현재 제한 사항

  • 실험적 상태는 잠재적 주요 변경을 의미합니다
  • 신중한 Suspense 경계 배치가 필요합니다
  • 스트리밍 응답으로 디버깅이 더 복잡할 수 있습니다
  • Vercel 및 자체 호스팅 외부의 플랫폼 지원 제한

향후 개발

CDN 상호 운용성

현재 단일 스트림 접근 방식은 Vercel 및 자체 호스팅 배포에서 작동하지만, PPR이 안정화되면 더 광범위한 CDN 지원이 계획되어 있습니다. 이는 CDN이 정적 셸 및 동적 스트리밍 패턴을 처리하기 위한 표준화된 프로토콜을 만드는 것을 포함합니다.

프로덕션 준비

PPR은 상당한 진전을 나타내지만 여전히 실험적입니다. Next.js 팀은 다음을 향해 작업하고 있습니다:
  • dynamicIO 플래그 요구 사항 제거
  • 더 큰 코드베이스에 대한 개발자 경험 개선
  • 플랫폼 호환성 확대
  • API 표면 안정화

결론

Partial Prerendering은 웹 애플리케이션 렌더링을 생각하는 방식의 패러다임 전환을 나타냅니다. 정적 셸을 스트리밍 동적 콘텐츠와 결합함으로써 PPR은 다음을 제공합니다:
  • 최적의 TTFB: 엣지에서 제공되는 정적 콘텐츠를 통해
  • 개선된 LCP: 병렬 데이터 가져오기를 통해
  • 더 나은 UX: 즉시 정적 콘텐츠 표시
  • 개발자 인체공학: 익숙한 React 패턴 사용
오류 기반 감지에서 Promise 기반 메커니즘으로의 여정은 이 기능의 사려 깊은 진화를 보여줍니다. 플랫폼 호환성 및 프로덕션 준비 상태 주변의 과제가 남아 있지만, PPR은 웹 애플리케이션 렌더링의 미래를 나타냅니다. 개발자들은 더 이상 속도와 기능 중 하나를 선택할 필요가 없습니다.
앞으로 나아가면서, 목표는 명확합니다: PPR을 웹 애플리케이션의 기본 렌더링 모델로 만들어 정적 사이트 생성과 동적 제공의 최고를 함께 가져오되 둘 다 타협하지 않는 것입니다. 기술적 기초는 견고하고, 성능 이점은 명확하며, 개발자 경험은 각 반복마다 계속 개선되고 있습니다.
웹 성능의 경계를 밀어붙이면서 풍부한 동적 기능을 유지하려는 팀의 경우, Partial Prerendering은 설득력 있는 경로를 제공합니다. 아직 실험적일 수 있지만, 이것이 도입하는 원칙과 패턴은 이미 현대 웹 아키텍처를 생각하는 방식을 형성하고 있습니다.

0
13

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글