Next.js 13에서 도입된 App Router는 React Server Components(RSC)라는 혁신적인 기술을 기반으로 하고 있습니다. 그리고 최근 PPR(Partial Pre-rendering)이라는 새로운 개념이 등장하면서 웹 애플리케이션의 성능을 한 단계 더 끌어올리고 있습니다. 이 글에서는 RSC의 동작 원리와 PPR의 도입 배경에 대해 깊이 있게 알아보겠습니다.
기존의 Server-Side Rendering은 다음과 같은 방식으로 동작했습니다:
전통적인 SSR 흐름:
[사용자 요청]
↓
[서버에서 컴포넌트 실행] ← 🔴 중복 작업 1
↓
[Fiber Tree 생성]
↓
[HTML 문자열 변환]
↓
[HTML 전송]
↓
[클라이언트에서 동일한 컴포넌트 실행] ← 🔴 중복 작업 2
↓
[Fiber Tree 생성]
↓
[서버 HTML과 비교 (하이드레이션)]
↓
[✅ 완료]
// 전통적인 SSR
import { renderToString } from 'react-dom/server'
function App() {
return (
<div>
<h1>Hello World</h1>
<button onClick={() => alert('clicked')}>Click me</button>
</div>
)
}
// 서버에서
const html = renderToString(<App />) // HTML 문자열 생성
// 결과: "<div><h1>Hello World</h1><button>Click me</button></div>"
// 클라이언트에서
ReactDOM.hydrate(<App />, document.getElementById('root')) // 하이드레이션
이 방식의 문제점은 같은 컴포넌트를 서버와 클라이언트에서 두 번 실행한다는 것입니다. 특히 하이드레이션 과정에서 서버에서 생성된 HTML과 클라이언트에서 생성된 Virtual DOM을 비교하며 이벤트 리스너를 연결해야 합니다.
React Server Components는 React의 Fiber 아키텍처를 활용해 완전히 다른 접근을 시도합니다.
RSC는 서버에서 생성된 Fiber Tree를 직렬화하여 클라이언트로 전송합니다:
// 서버 컴포넌트
async function ServerComponent() {
const data = await fetchDataFromDB()
return (
<div>
<h1>Server Generated Content</h1>
<ClientComponent data={data} />
</div>
)
}
// 클라이언트 컴포넌트
'use client'
function ClientComponent({ data }) {
const [count, setCount] = useState(0)
return (
<div>
<p>{data}</p>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
</div>
)
}
RSC는 기존 SSR과는 완전히 다른 방식으로 동작합니다:
RSC 흐름:
[사용자 요청]
↓
[🔵 서버 컴포넌트만 실행] ← 서버에서만
↓
[Fiber Tree 생성]
↓
[RSC Payload 직렬화 (JSON)]
↓
[JSON 형태로 전송]
↓
[클라이언트에서 Payload 파싱]
↓
[Fiber Tree 복원]
↓
[🟡 클라이언트 컴포넌트만 실행] ← 클라이언트에서만
↓
[✅ 직접 렌더링 (하이드레이션 없음)]
서버에서 생성되는 것은 HTML 문자열이 아닌 RSC Payload입니다:
{
"type": "div",
"props": null,
"children": [
{
"type": "h1",
"props": null,
"children": "Server Generated Content"
},
{
"type": "ClientComponent",
"props": { "data": "fetched data" },
"children": "$@1"
}
]
}
클라이언트는 이 payload를 받아서 Fiber Tree를 복원하고 렌더링합니다:
// 클라이언트에서 RSC Payload 처리
function processRSCPayload(payload) {
// JSON을 Fiber Node로 변환
const fiberNode = {
type: payload.type,
props: payload.props,
child: payload.children ? reconstructFiberTree(payload.children[0]) : null,
sibling: payload.children?.[1] ? reconstructFiberTree(payload.children[1]) : null,
}
// 서버 컴포넌트는 이미 처리됨, 클라이언트 컴포넌트만 실행
if (fiberNode.type === 'ClientComponent') {
return renderClientComponent(fiberNode)
}
return fiberNode
}
| 구분 | 전통적인 SSR | RSC |
|---|---|---|
| 서버 출력 | HTML 문자열 | RSC Payload (JSON) |
| 클라이언트 처리 | 하이드레이션 | 직접 렌더링 |
| 컴포넌트 실행 | 서버/클라이언트 모두 | 서버 또는 클라이언트 중 하나 |
| 정보 보존 | HTML로 변환 시 손실 | Fiber Tree 구조 유지 |
장점:
한계:
App Router는 페이지 전체를 하나의 단위로 렌더링 방식을 결정합니다:
현재 App Router의 문제점:
페이지 분석
↓
동적 요소가 하나라도 있나?
├─ 없음 → ✅ 전체 SSG (빠름)
└─ 있음 → 🔴 전체 SSR (느림)
실제 상황:
┌─────────────────────────────────┐
│ 📄 블로그 페이지 │
├─────────────────────────────────┤
│ Header (정적) ✅ │
│ Navigation (정적) ✅ │
│ Article (정적) ✅ │
│ Sidebar (정적) ✅ │ 99% 정적
│ Comments (정적) ✅ │
│ Footer (정적) ✅ │
│ CurrentTime (동적) 🔴 ← 1% │ 하지만 전체가
└─────────────────────────────────┘ SSR 처리됨!
// 정적 페이지 (SSG)
export default function StaticPage() {
return <div>This will be static</div>
}
// 동적 페이지 (SSR) - 하나라도 동적이면 전체가 SSR
export default function DynamicPage() {
const staticContent = "Hello World" // 정적
const dynamicContent = new Date() // 동적!
return (
<div>
<h1>{staticContent}</h1> {/* 정적이지만... */}
<p>{dynamicContent}</p> {/* 이것 때문에 전체가 SSR */}
</div>
)
}
실제 웹 애플리케이션에서는 대부분의 콘텐츠가 정적이고, 일부분만 동적인 경우가 많습니다:
export default function BlogPost() {
return (
<div>
<Header /> {/* 정적 */}
<Navigation /> {/* 정적 */}
<Article /> {/* 정적 */}
<Sidebar /> {/* 정적 */}
<Comments /> {/* 정적 */}
<CurrentTime /> {/* 동적! */}
<Footer /> {/* 정적 */}
</div>
)
}
// 현재: CurrentTime 때문에 전체가 SSR로 처리됨
이런 상황에서 성능 문제가 발생합니다:
PPR은 이 문제를 컴포넌트 단위로 렌더링 방식을 결정하여 해결합니다:
flowchart TD
A[페이지 분석] --> B[컴포넌트별 분류]
B --> C[정적 컴포넌트들]
B --> D[동적 컴포넌트들]
C --> E[빌드 시 HTML 생성]
D --> F[런타임 SSR]
E --> G[CDN에서 즉시 전송]
F --> H[스트리밍으로 전송]
G --> I[최종 렌더링]
H --> I
style C fill:#ccffcc
style D fill:#ffeecc
style E fill:#ccffcc
style F fill:#ffeecc
style I fill:#ccccff
// PPR 적용 후
export default function BlogPost() {
return (
<div>
{/* 빌드 시 HTML로 생성 (SSG) */}
<Header />
<Navigation />
<Article />
<Sidebar />
<Comments />
<Footer />
{/* 런타임에 생성 (SSR) */}
<Suspense fallback={<TimeLoader />}>
<CurrentTime />
</Suspense>
</div>
)
}
현재 방식과 PPR의 성능 차이를 타임라인으로 비교해보겠습니다:
성능 비교 (시간축):
현재 App Router (전체 SSR):
0ms 10ms 20ms 30ms 40ms 50ms 60ms
├──────┼──────┼──────┼──────┼──────┼──────┤
│████████████████████████████████████████│ 서버에서 전체 렌더링
│──│ HTML 전송
60ms 완료
PPR 방식:
0ms 10ms 20ms 30ms 40ms 50ms 60ms
├──────┼──────┼──────┼──────┼──────┼──────┤
│█│ 정적 HTML 즉시 전송 (1ms)
│████│ 동적 부분 처리 (5ms)
│──│ 스트리밍 전송
10ms 완료 (6배 빠름!)
// 현재 App Router (전체 SSR)
사용자 요청 → 서버에서 전체 렌더링 (50ms) → 응답
↑
Header(10ms) + Nav(10ms) + Article(15ms) + ... + Time(5ms)
// PPR 적용 후
사용자 요청 → 정적 HTML 즉시 전송 (1ms) + 동적 부분 스트리밍 (5ms)
↑ ↑
빌드 시 생성된 HTML CurrentTime만 처리
현재 Suspense 방식과 PPR의 아키텍처 차이를 시각화해보겠습니다:
현재 Suspense 방식:
┌─────────────────────────────────────────┐
│ 사용자 요청 │
│ ↓ │
│ 🔴 서버에서 모든 컴포넌트 처리 │
│ ├─ Header 렌더링 → 즉시 전송 │
│ └─ 동적 콘텐츠 처리 → 지연 스트리밍 │
└─────────────────────────────────────────┘
PPR 방식:
┌─────────────────────────────────────────┐
│ 사용자 요청 │
│ ├─ ✅ 빌드 시 생성된 정적 HTML │
│ │ → CDN에서 즉시 전송 (빠름) │
│ └─ 🔄 동적 부분만 서버 처리 │
│ → 스트리밍 전송 │
└─────────────────────────────────────────┘
핵심 차이점:
• 현재: 매번 모든 것을 서버에서 처리
• PPR: 정적 부분은 빌드 시 한 번만, 동적 부분만 런타임 처리
// 현재 Suspense (모든 것이 서버에서 렌더링)
export default function Page() {
return (
<div>
<Header /> {/* 매번 서버에서 렌더링 */}
<Suspense fallback={<Loading />}>
<DynamicContent /> {/* 비동기 스트리밍 */}
</Suspense>
</div>
)
}
// PPR (정적 부분은 빌드 시 생성)
export default function Page() {
return (
<div>
<Header /> {/* 빌드 시 HTML로 생성, CDN에서 즉시 전송 */}
<Suspense fallback={<Loading />}>
<DynamicContent /> {/* 런타임에만 처리 */}
</Suspense>
</div>
)
}
불필요한 서버 렌더링 최소화
사용자 경험 개선
개발자 경험 향상
Next.js App Router의 RSC는 React Fiber 아키텍처를 활용해 서버-클라이언트 렌더링의 새로운 패러다임을 제시했습니다. 전통적인 SSR에서 벗어나 Fiber Tree를 직렬화하여 전송함으로써, 더 효율적이고 유연한 렌더링이 가능해졌습니다.
PPR은 이러한 RSC 기반에서 한 단계 더 나아가, "페이지 단위" 렌더링 결정의 한계를 "컴포넌트 단위"로 세분화했습니다. 이를 통해 정적 콘텐츠의 속도와 동적 콘텐츠의 유연성을 모두 확보할 수 있게 되었습니다.
앞으로 이러한 기술들이 어떻게 발전할지, 그리고 실제 프로덕션 환경에서 어떤 성능 개선을 가져올지 기대됩니다.