Next.js App Router의 RSC와 PPR: 새로운 렌더링 패러다임의 이해

조주영·2025년 7월 6일

Next.js App Router의 RSC와 PPR: 새로운 렌더링 패러다임의 이해

N Next.js 13+ 새로운 렌더링 패러다임 RSC React Server Components PPR Partial Pre- rendering Fiber Tree 직렬화 컴포넌트별 최적화 Server Components Streaming Static + Dynamic

들어가며

Next.js 13에서 도입된 App Router는 React Server Components(RSC)라는 혁신적인 기술을 기반으로 하고 있습니다. 그리고 최근 PPR(Partial Pre-rendering)이라는 새로운 개념이 등장하면서 웹 애플리케이션의 성능을 한 단계 더 끌어올리고 있습니다. 이 글에서는 RSC의 동작 원리와 PPR의 도입 배경에 대해 깊이 있게 알아보겠습니다.


1. App Router의 RSC 방식: 기존 SSR과는 다른 접근

전통적인 SSR의 한계

기존의 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을 비교하며 이벤트 리스너를 연결해야 합니다.

RSC: React Fiber 아키텍처를 활용한 새로운 패러다임

React Server Components는 React의 Fiber 아키텍처를 활용해 완전히 다른 접근을 시도합니다.

Fiber Tree의 직렬화

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의 동작 흐름

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
}

RSC vs 전통적인 SSR 비교

구분전통적인 SSRRSC
서버 출력HTML 문자열RSC Payload (JSON)
클라이언트 처리하이드레이션직접 렌더링
컴포넌트 실행서버/클라이언트 모두서버 또는 클라이언트 중 하나
정보 보존HTML로 변환 시 손실Fiber Tree 구조 유지

RSC의 장점과 한계

장점:

  • 서버 컴포넌트는 클라이언트 번들에 포함되지 않음
  • 데이터 페칭을 서버에서 직접 수행 가능
  • 하이드레이션 불일치 문제 해결

한계:

  • 서버에서 Fiber Tree 생성으로 인한 메모리/CPU 사용량 증가
  • RSC Payload 직렬화 비용
  • 복잡한 디버깅

2. PPR의 도입 배경: 페이지 단위의 한계를 넘어서

현재 App Router의 렌더링 결정

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>
  )
}

문제 상황: "하나라도 동적이면 전체가 SSR"

실제 웹 애플리케이션에서는 대부분의 콘텐츠가 정적이고, 일부분만 동적인 경우가 많습니다:

export default function BlogPost() {
  return (
    <div>
      <Header />           {/* 정적 */}
      <Navigation />       {/* 정적 */}
      <Article />          {/* 정적 */}
      <Sidebar />          {/* 정적 */}
      <Comments />         {/* 정적 */}
      <CurrentTime />      {/* 동적! */}
      <Footer />           {/* 정적 */}
    </div>
  )
}
// 현재: CurrentTime 때문에 전체가 SSR로 처리됨

이런 상황에서 성능 문제가 발생합니다:

  • 99%는 정적 콘텐츠인데 매번 서버에서 렌더링
  • 불필요한 서버 자원 사용
  • 사용자 경험 저하 (느린 초기 로딩)

PPR: Partial Pre-rendering의 등장

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의 동작 방식

  1. 빌드 시: 정적 부분들을 HTML 파일로 사전 생성
  2. 요청 시:
    • 정적 HTML을 CDN에서 즉시 전송
    • 동적 부분만 서버에서 처리하여 스트리밍

성능 비교 시각화

현재 방식과 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만 처리

PPR vs Suspense 아키텍처 비교

현재 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>
  )
}

PPR 도입의 핵심 가치

  1. 불필요한 서버 렌더링 최소화

    • 정적 콘텐츠는 한 번만 생성
    • 서버 자원 절약
  2. 사용자 경험 개선

    • 빠른 초기 로딩 (정적 부분)
    • 점진적 콘텐츠 로딩 (동적 부분)
  3. 개발자 경험 향상

    • 자동 최적화
    • 기존 Suspense 패턴 재사용

마무리

Next.js App Router의 RSC는 React Fiber 아키텍처를 활용해 서버-클라이언트 렌더링의 새로운 패러다임을 제시했습니다. 전통적인 SSR에서 벗어나 Fiber Tree를 직렬화하여 전송함으로써, 더 효율적이고 유연한 렌더링이 가능해졌습니다.

PPR은 이러한 RSC 기반에서 한 단계 더 나아가, "페이지 단위" 렌더링 결정의 한계를 "컴포넌트 단위"로 세분화했습니다. 이를 통해 정적 콘텐츠의 속도와 동적 콘텐츠의 유연성을 모두 확보할 수 있게 되었습니다.

앞으로 이러한 기술들이 어떻게 발전할지, 그리고 실제 프로덕션 환경에서 어떤 성능 개선을 가져올지 기대됩니다.

profile
꾸준히 성장하기

0개의 댓글