React SSR의 진화: Next.js Page Router에서 App Router로의 여정

들어가며

안녕하세요! 오늘은 React SSR(Server-Side Rendering)의 진화, 특히 Next.js의 Page Router에서 App Router로 전환하면서 가능해진 스트리밍 SSR에 대해 깊이 있게 살펴보겠습니다.

“왜 Page Router에서는 스트리밍이 안 되고, App Router에서는 되는 거야?”라는 질문에 대한 명확한 답을 찾아가는 여정이 될 것입니다.

먼저 체감해보기: 전통적 SSR의 한계

🐌 Page Router의 SSR 경험

javascript
// pages/dashboard.js (Page Router)
export default function Dashboard({ user, analytics, notifications }) {
  return (
    <Layout>
      <UserProfile data={user} />
      <AnalyticsChart data={analytics} />  {/* 3초 걸림 */}
      <NotificationList items={notifications} />
    </Layout>
  );
}

export async function getServerSideProps() {
  // 모든 데이터를 병렬로 가져와도...
  const [user, analytics, notifications] = await Promise.all([
    fetchUser(),           // 0.5초
    fetchAnalytics(),      // 3초 (병목!)
    fetchNotifications()   // 0.3초
  ]);

  // 가장 느린 요청(3초)이 끝날 때까지 기다려야 함
  return {
    props: { user, analytics, notifications }
  };
}

사용자는 3초 동안 빈 화면을 보고 있어야 합니다!

✨ App Router의 스트리밍 SSR

javascript
// app/dashboard/page.js (App Router)
export default function Dashboard() {
  return (
    <Layout>
      <Suspense fallback={<UserProfileSkeleton />}>
        <UserProfile />  {/* 0.5초 후 표시 */}
      </Suspense>

      <Suspense fallback={<AnalyticsChartSkeleton />}>
        <AnalyticsChart />  {/* 3초 후 표시 */}
      </Suspense>

      <Suspense fallback={<NotificationListSkeleton />}>
        <NotificationList />  {/* 0.3초 후 표시 */}
      </Suspense>
    </Layout>
  );
}

// 각 컴포넌트가 독립적으로 데이터 페칭
async function UserProfile() {
  const user = await fetchUser();
  return <UserProfileComponent data={user} />;
}

사용자는 0.3초만에 첫 콘텐츠를 보기 시작합니다!

Page Router와 App Router의 사용자 경험 타임라인 비교. Page Router는 모든 데이터 페칭이 끝나는 3초 뒤에야 첫 콘텐츠가 나오고, App Router는 레이아웃부터 순서대로 흘러 0.1초에 첫 콘텐츠가 나온다

Page Router의 SSR: 왜 스트리밍이 불가능했나?

📦 모놀리식 렌더링 파이프라인

Page Router의 SSR은 이런 순서로 작동합니다:

text
1. 요청 수신
2. getServerSideProps 실행 (모든 데이터 페칭)
3. React 컴포넌트를 문자열로 렌더링
4. 완성된 HTML 전송
5. 클라이언트에서 Hydration

코드로 표현하면:

javascript
// Next.js Page Router 내부 동작 (간소화)
async function renderPage(req, res) {
  // 1단계: 모든 데이터 가져오기
  const props = await getServerSideProps({ req, res });

  // 2단계: 전체 페이지를 문자열로 렌더링
  const html = ReactDOMServer.renderToString(
    <App pageProps={props} />
  );

  // 3단계: 완성된 HTML 전송
  res.send(`
    <!DOCTYPE html>
    <html>
      <body>
        <div id="__next">${html}</div>
        <script>
          window.__NEXT_DATA__ = ${JSON.stringify(props)}
        </script>
      </body>
    </html>
  `);
}

🚫 renderToString의 한계

renderToString동기적으로 작동합니다:

renderToString의 블로킹 방식 다이어그램. 서버가 모든 API를 3초간 기다렸다가 전체 HTML 15KB를 한 번에 보내고, 그동안 브라우저는 빈 화면이다

React 서버 렌더링 API 비교. renderToString은 컴포넌트 트리를 전부 순회한 뒤 한 번에 전송하고 Suspense를 무시하지만, renderToPipeableStream은 초기 셸을 먼저 렌더링해 Suspense 경계별로 점진 전송한다

javascript
// React 17까지의 서버 렌더링
const html = ReactDOMServer.renderToString(<App />);
// 이 시점에서 모든 컴포넌트가 완전히 렌더링되어야 함
// Suspense? 무시됨!

만약 컴포넌트가 Promise를 throw하면?

javascript
// Page Router에서는 작동하지 않음!
function AsyncComponent() {
  if (!data) {
    throw fetchData();  // Promise를 throw
  }
  return <div>{data}</div>;
}

// 서버에서 에러 발생!

React 18의 혁명: 스트리밍 가능한 SSR

🌊 renderToPipeableStream의 등장

React 18은 새로운 서버 렌더링 API를 도입했습니다:

renderToPipeableStream의 스트리밍 방식 다이어그램. 초기 HTML과 헤더, 빠른 데이터가 먼저 청크로 나가고 느린 차트 데이터가 뒤따르면서 브라우저 화면이 단계적으로 채워진다

javascript
// React 18의 스트리밍 SSR
import { renderToPipeableStream } from 'react-dom/server';

app.get('/', (req, res) => {
  const { pipe } = renderToPipeableStream(
    <App />,
    {
      bootstrapScripts: ['/main.js'],
      onShellReady() {
        // 초기 HTML 셸이 준비되면 스트리밍 시작
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/html');
        pipe(res);
      },
      onAllReady() {
        // 모든 Suspense 경계가 해결됨
      },
      onError(error) {
        console.error(error);
      }
    }
  );
});

🎯 스트리밍의 실제 동작

javascript
function Page() {
  return (
    <html>
      <body>
        <nav>네비게이션</nav>  {/* 즉시 전송 */}

        <Suspense fallback={<div id="loading">로딩 중...</div>}>
          <SlowComponent />  {/* 나중에 전송 */}
        </Suspense>

        <footer>푸터</footer>  {/* 즉시 전송 */}
      </body>
    </html>
  );
}

스트리밍 SSR의 네트워크 전송 방식

🌐 왜 HTTP/2 Server Push가 아닌가?

많은 분들이 “스트리밍이면 HTTP/2 Server Push를 쓰는 거 아니야?”라고 생각하실 수 있습니다. 하지만 실제로는 HTTP/1.1의 Chunked Transfer Encoding을 사용합니다.

HTTP/2 Server Push 유무 비교 시퀀스 다이어그램. 푸시가 없으면 브라우저가 html을 받은 뒤 css를 다시 요청하지만, 푸시가 있으면 서버가 css를 함께 내려준다

HTTP/2 Server Push의 문제점

javascript
// HTTP/2 Server Push의 이상적인 시나리오
server.on('stream', (stream, headers) => {
  // HTML 요청 시 CSS/JS를 미리 푸시
  if (headers[':path'] === '/index.html') {
    stream.pushStream({ ':path': '/styles.css' }, (err, pushStream) => {
      pushStream.respond({ ':status': 200 });
      pushStream.end(cssContent);
    });
  }
});

하지만 실제로는:

  1. 캐시 무시 문제: 클라이언트가 이미 리소스를 캐시했어도 서버가 알 수 없음
  2. 우선순위 제어 어려움: 브라우저가 필요한 순서대로 리소스를 요청하는 게 더 효율적
  3. 복잡성: 구현과 디버깅이 어려움
  4. 브라우저 지원 중단: Chrome은 HTTP/2 Server Push 지원을 제거

📡 HTTP/1.1 Chunked Transfer Encoding

React의 스트리밍 SSR은 검증된 기술인 Chunked Transfer Encoding을 사용합니다:

Chunked Transfer Encoding 개념도. 2MB 청크가 연달아 전송되고 마지막 빈 청크가 전송 종료를 알린다

HTTP/1.1 Chunked Transfer Encoding 예시. 초기 HTML 셸 청크, Suspense 콘텐츠를 담은 template과 $RC 스크립트 청크, 그리고 0으로 끝나는 스트림 종료 청크가 차례로 나간다

각 청크의 구조:

  • 청크 크기 (16진수): 1a2, f8, 2b5
  • 청크 데이터: HTML 또는 JavaScript
  • 청크 종료: \r\n
  • 스트림 종료: 0\r\n\r\n

🔍 브라우저에서의 처리 과정

javascript
// 브라우저의 스트리밍 HTML 파서 (개념적 표현)
class StreamingHTMLParser {
  constructor() {
    this.buffer = '';
    this.suspended = new Map();  // Suspense 경계 추적
  }

  processChunk(chunk) {
    this.buffer += chunk;

    // 완성된 HTML 요소가 있으면 즉시 렌더링
    while (this.hasCompleteElement()) {
      const element = this.parseNextElement();

      if (element.tagName === 'template') {
        // Suspense 교체를 위한 템플릿
        this.suspended.set(element.id, element);
      } else if (element.tagName === 'script' && element.textContent.includes('$RC')) {
        // React의 교체 스크립트 실행
        this.executeSuspenseReplacement(element);
      } else {
        // 일반 요소는 즉시 DOM에 추가
        this.appendToDOM(element);
      }
    }
  }
}

📊 실제 네트워크 타임라인

javascript
// 개발자 도구에서 볼 수 있는 패턴
fetch('/dashboard')
  .then(response => {
    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    function read() {
      reader.read().then(({ done, value }) => {
        if (done) {
          console.log('스트림 완료');
          return;
        }

        const chunk = decoder.decode(value, { stream: true });
        console.log('청크 수신:', chunk.length, 'bytes');

        // 청크 타임스탬프
        // 0ms: 초기 HTML 셸 (5KB)
        // 100ms: 빠른 컴포넌트 (2KB)
        // 500ms: 중간 컴포넌트 (8KB)
        // 2000ms: 느린 컴포넌트 (15KB)

        read();
      });
    }

    read();
  });

App Router: 스트리밍 SSR의 완전한 구현

🏗️ App Router의 아키텍처

App Router는 React 18의 기능을 완전히 활용하도록 설계되었습니다:

javascript
// app/layout.js
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Header />  {/* 즉시 렌더링 */}
        {children}  {/* 페이지별로 스트리밍 */}
      </body>
    </html>
  );
}

// app/page.js
export default async function Page() {
  // 서버 컴포넌트에서 직접 async/await 사용!
  const data = await fetchData();

  return (
    <main>
      <h1>홈페이지</h1>
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid />
      </Suspense>
    </main>
  );
}

🔑 핵심 차이점: Server Components

App Router의 가장 큰 혁신은 React Server Components (RSC)입니다:

javascript
// 서버 컴포넌트 (기본값)
async function ProductList() {
  // 서버에서만 실행 - 번들에 포함 안 됨!
  const products = await db.query('SELECT * FROM products');

  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// 클라이언트 컴포넌트 (명시적 선언)
'use client';

function ProductCard({ product }) {
  const [liked, setLiked] = useState(false);

  return (
    <div onClick={() => setLiked(!liked)}>
      {product.name}
    </div>
  );
}

스트리밍 SSR의 내부 동작

📡 Progressive Enhancement

App Router의 스트리밍은 점진적으로 페이지를 향상시킵니다:

javascript
// 복잡한 대시보드 예제
export default function Dashboard() {
  return (
    <>
      {/* 1단계: 즉시 표시되는 정적 콘텐츠 */}
      <DashboardHeader />

      {/* 2단계: 빠른 데이터 (0.1초) */}
      <Suspense fallback={<StatsCardSkeleton />}>
        <QuickStats />
      </Suspense>

      {/* 3단계: 중간 속도 데이터 (0.5초) */}
      <Suspense fallback={<ChartSkeleton />}>
        <RecentActivityChart />
      </Suspense>

      {/* 4단계: 느린 데이터 (2초) */}
      <Suspense fallback={<TableSkeleton />}>
        <DetailedAnalyticsTable />
      </Suspense>
    </>
  );
}

실제 전송되는 HTML 스트림:

html
<!-- 청크 1: 초기 셸 -->
<!DOCTYPE html>
<html>
<body>
  <header>대시보드</header>
  <div id="S:0">로딩 중...</div>
  <div id="S:1">차트 로딩 중...</div>
  <div id="S:2">테이블 로딩 중...</div>

<!-- 청크 2: QuickStats (100ms 후) -->
<template id="T:0">
  <div class="stats">
    <div>총 사용자: 1,234</div>
    <div>활성 세션: 567</div>
  </div>
</template>
<script>
$RC("T:0", "S:0");  // Suspense 경계 교체
</script>

<!-- 청크 3: RecentActivityChart (500ms 후) -->
<template id="T:1">
  <canvas id="chart">...</canvas>
</template>
<script>
$RC("T:1", "S:1");
// 차트 초기화 코드
</script>

Suspense 경계와 스트리밍 청크의 매핑. UserProfile·AnalyticsChart·NotificationList 각각의 경계가 독립적인 청크로 전송된다

🧩 선택적 Hydration

App Router는 컴포넌트별로 독립적인 hydration을 수행합니다:

선택적 하이드레이션 진행 과정. 정적 상태에서 Header와 Content A가 먼저 하이드레이션되고 Content B가 뒤따른다

브라우저의 스트리밍 청크 처리 과정. 청크 수신, 파싱, DOM 업데이트, 하이드레이션 순서와 HTML·Template·Script·종료 청크의 처리 방법

javascript
// 각 Suspense 경계가 독립적으로 hydrate됨
function Page() {
  return (
    <>
      <Suspense fallback={<LoadingA />}>
        <InteractiveComponentA />  {/* 준비되면 즉시 상호작용 가능 */}
      </Suspense>

      <Suspense fallback={<LoadingB />}>
        <HeavyComponentB />  {/* A와 독립적으로 hydrate */}
      </Suspense>
    </>
  );
}

브라우저의 처리:

javascript
// React의 선택적 Hydration (개념적 표현)
class SelectiveHydration {
  hydrateSuspenseBoundary(boundaryId, componentCode) {
    // 1. 해당 경계의 정적 HTML 찾기
    const boundary = document.getElementById(boundaryId);

    // 2. 이벤트 리스너 임시 캡처
    const pendingEvents = this.capturePendingEvents(boundary);

    // 3. 컴포넌트 hydrate
    ReactDOM.hydrateRoot(boundary, componentCode);

    // 4. 캡처된 이벤트 재실행
    pendingEvents.forEach(event => {
      boundary.dispatchEvent(event);
    });
  }
}

실전 패턴: 스트리밍 SSR 최적화

1️⃣ 데이터 폭포수 방지

javascript
// ❌ 나쁜 예: 순차적 로딩
async function BadPattern() {
  const user = await fetchUser();
  const preferences = await fetchUserPreferences(user.id);  // user를 기다림
  const recommendations = await fetchRecommendations(preferences);  // preferences를 기다림

  return <div>...</div>;
}

// ✅ 좋은 예: 병렬 로딩 + 스트리밍
function GoodPattern({ userId }) {
  return (
    <>
      <Suspense fallback={<UserSkeleton />}>
        <UserInfo userId={userId} />
      </Suspense>

      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations userId={userId} />  {/* 독립적으로 로드 */}
      </Suspense>
    </>
  );
}

2️⃣ 중요도별 콘텐츠 우선순위

javascript
// app/product/[id]/page.js
export default function ProductPage({ params }) {
  return (
    <div className="product-page">
      {/* 핵심 정보는 블로킹 */}
      <ProductEssentials productId={params.id} />

      {/* 보조 정보는 스트리밍 */}
      <div className="secondary-content">
        <Suspense fallback={<ReviewsSkeleton />}>
          <ProductReviews productId={params.id} />
        </Suspense>

        <Suspense fallback={<RelatedSkeleton />}>
          <RelatedProducts productId={params.id} />
        </Suspense>
      </div>
    </div>
  );
}

// 핵심 정보는 페이지와 함께 전송
async function ProductEssentials({ productId }) {
  const product = await fetchProduct(productId);  // 빠른 쿼리
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}</p>
      <AddToCartButton productId={productId} />
    </div>
  );
}

실무 체크리스트

📋 스트리밍 SSR 도입 시 고려사항

  • 초기 셸의 중요성: 레이아웃과 네비게이션은 즉시 표시
  • Suspense 경계 설계: 너무 많으면 오버헤드, 너무 적으면 효과 감소
  • 에러 경계 배치: 부분 실패를 우아하게 처리
  • SEO 고려: 중요한 메타데이터는 초기 응답에 포함
  • 캐싱 전략: 스트리밍과 캐싱의 균형

🎯 언제 스트리밍 SSR을 사용할까?

javascript
// ✅ 스트리밍이 효과적인 경우
- 대시보드처럼 여러 독립적인 섹션이 있는 페이지
- 일부는 빠르고 일부는 느린 데이터 소스
- 사용자가 스크롤하면서 점진적으로 콘텐츠를 보는 경우

// ❌ 스트리밍이 불필요한 경우
- 모든 데이터가 빠르게 로드되는 단순한 페이지
- 전체 콘텐츠가 한 번에 필요한 경우 (예: PDF 생성)
- 레거시 시스템과의 호환성이 중요한 경우

마무리

Page Router에서 App Router로의 전환은 단순한 API 변경이 아닙니다. React의 렌더링 모델에 대한 근본적인 재고찰이었죠.

Page Router의 한계:

  • renderToString의 동기적 특성
  • 모놀리식 데이터 페칭
  • All-or-nothing 렌더링

App Router의 혁신:

  • React 18의 스트리밍 기능 완전 활용
  • Server Components로 번들 크기 감소
  • 점진적 향상과 선택적 hydration
  • 검증된 HTTP/1.1 Chunked Transfer Encoding 사용

스트리밍 SSR은 단순히 “빠른 SSR”이 아니라, 사용자가 콘텐츠를 소비하는 방식에 맞춘 SSR입니다.

여러분의 Next.js 앱도 이제 스트리밍의 힘을 활용해보세요. 사용자는 그 차이를 즉시 체감할 것입니다!