SSR에서 데이터베이스 쿼리가 3초 걸리는 컴포넌트가 하나 있다면, 전체 페이지의 HTML 전송도 3초 후에야 시작될까요?

전통적인 SSR에서는 "전부 아니면 전무(all-or-nothing)"였습니다. 하나의 느린 데이터 소스가 전체 페이지의 렌더링을 블로킹했습니다. React 18의 Streaming SSR과 Selective Hydration은 이 한계를 근본적으로 해결합니다.

전통 SSR의 4단계 병목

기존 SSR은 네 가지 단계가 순차적으로 진행되며, 각 단계가 전체를 블로킹합니다.

1단계: 서버에서 모든 데이터 페칭

PLAINTEXT
서버: Header 데이터 fetch (0.1s) ✓
서버: 메인 콘텐츠 fetch (0.3s) ✓
서버: 댓글 fetch (2.5s) ← 여기서 모든 것이 멈춤
서버: Footer 데이터 fetch (대기 중...)

2단계: 전체 HTML 렌더링 및 전송

PLAINTEXT
서버: 전체 HTML 렌더링 → 3초 후에야 첫 바이트 전송
클라이언트: 3초 동안 빈 화면

3단계: 모든 JavaScript 로드

PLAINTEXT
클라이언트: main.js (200KB) 다운로드 + 파싱
클라이언트: 모든 JS가 로드될 때까지 hydration 불가

4단계: 전체 Hydration

PLAINTEXT
클라이언트: 전체 트리를 한 번에 hydrate
클라이언트: hydration 완료될 때까지 인터랙션 불가

이 모든 단계에서 "전부 완료될 때까지 다음으로 진행 불가" 라는 제약이 있었습니다.

Streaming SSR — renderToPipeableStream

JSX
// 서버
import { renderToPipeableStream } from 'react-dom/server';

app.get('/', (req, res) => {
  const { pipe } = renderToPipeableStream(
    <App />,
    {
      bootstrapScripts: ['/main.js'],
      onShellReady() {
        // 셸(Suspense fallback 포함)이 준비되면 즉시 전송 시작
        res.setHeader('Content-Type', 'text/html');
        pipe(res);
      },
      onError(error) {
        console.error(error);
      },
    }
  );
});

서버 Suspense

JSX
function App() {
  return (
    <html>
      <body>
        <Header />        {/* 즉시 렌더링 */}
        <MainContent />   {/* 즉시 렌더링 */}

        <Suspense fallback={<CommentsSkeleton />}>
          <Comments />     {/* 데이터 로딩이 느림 */}
        </Suspense>

        <Footer />         {/* 즉시 렌더링 */}
      </body>
    </html>
  );
}

스트리밍 동작

PLAINTEXT
[0.1초] 서버 → 클라이언트:
<html>
  <body>
    <header>...</header>
    <main>...</main>
    <div id="comments-placeholder">
      <!-- CommentsSkeleton -->
      <div class="skeleton">로딩 중...</div>
    </div>
    <footer>...</footer>
    <!-- JavaScript 로드 시작 -->
    <script src="/main.js"></script>
  </body>
</html>

사용자: 0.1초 만에 페이지의 대부분을 볼 수 있음!

[2.5초] 서버 → 클라이언트 (추가 스트리밍):
<div hidden id="comments-content">
  <!-- 실제 댓글 HTML -->
  <div class="comment">좋은 글이네요!</div>
  <div class="comment">감사합니다</div>
</div>
<script>
  // fallback을 실제 콘텐츠로 교체
  document.getElementById('comments-placeholder').replaceWith(
    document.getElementById('comments-content')
  );
</script>

핵심: 댓글이 준비되기 전에 나머지 페이지가 먼저 표시됩니다. 댓글 데이터가 준비되면 인라인 스크립트와 함께 스트리밍되어 스켈레톤을 실제 콘텐츠로 교체합니다.

Selective Hydration

기존: 전체 Hydration

PLAINTEXT
HTML 도착 → JS 로드 → 전체 hydrate → 인터랙션 가능
                       (500ms 블로킹)

개선: 선택적 Hydration

PLAINTEXT
HTML 도착 → JS 로드 → Header hydrate ✓ (즉시 인터랙션 가능)
                    → MainContent hydrate ✓
                    → Comments: HTML 아직 미도착, 건너뜀
                    → Footer hydrate ✓

댓글 HTML 도착 → Comments hydrate ✓

각 Suspense 바운더리가 독립적으로 hydrate됩니다. 댓글의 HTML이 아직 도착하지 않아도, 나머지 부분은 이미 인터랙티브합니다.

사용자 인터랙션 기반 우선순위

JSX
function App() {
  return (
    <>
      <Suspense fallback={<NavSkeleton />}>
        <Nav />
      </Suspense>
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />         {/* 사용자가 여기를 클릭! */}
      </Suspense>
      <Suspense fallback={<ContentSkeleton />}>
        <HeavyContent />
      </Suspense>
    </>
  );
}

React는 Nav, Sidebar, HeavyContent 순서로 hydrate하려 했지만, 사용자가 Sidebar를 클릭하면:

  1. React가 Sidebar의 hydration 우선순위를 높입니다
  2. Sidebar를 먼저 hydrate합니다
  3. 클릭 이벤트를 처리합니다
  4. 나머지 컴포넌트를 이어서 hydrate합니다

사용자 인터랙션이 hydration 순서를 동적으로 변경합니다.

전체 그림

PLAINTEXT
전통 SSR:
데이터 페칭 ▓▓▓▓▓▓▓▓▓▓▓▓▓
HTML 렌더링                ▓▓▓▓▓
전송                            ▓▓
JS 로드                           ▓▓▓▓
Hydration                              ▓▓▓▓
인터랙션 가능                                  ✓
                                            12초

Streaming SSR + Selective Hydration:
셸 렌더링 ▓▓
전송 시작   ▓→→→→ (스트리밍)
JS 로드      ▓▓▓▓
부분 Hydration    ▓▓
인터랙션 가능 (부분)  ✓
느린 데이터          ▓▓▓▓▓▓▓
나머지 스트리밍                   ▓
나머지 Hydration                  ▓
전체 인터랙션 가능                  ✓
                                 5초

Next.js에서의 활용

Next.js App Router는 이 모든 기능을 기본으로 활용합니다.

JSX
// app/page.js
import { Suspense } from 'react';

export default function Page() {
  return (
    <main>
      <h1>대시보드</h1>

      <Suspense fallback={<StatsSkeleton />}>
        <Stats />  {/* 서버에서 스트리밍 */}
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <Chart />  {/* 별도로 스트리밍 */}
      </Suspense>
    </main>
  );
}

// Stats는 async 서버 컴포넌트
async function Stats() {
  const data = await fetchStats();  // 서버에서 데이터 페칭
  return <div>{data.total} 사용자</div>;
}

기존 SSR과의 비교

특성renderToStringrenderToPipeableStream
전송 시작전체 완료 후셸 준비 시 즉시
느린 컴포넌트전체 블로킹Suspense로 분리
Hydration전체 일괄Suspense 단위 선택적
인터랙션 우선순위없음사용자 행동에 따라 동적
TTFB느림빠름

정리

Streaming SSR과 Selective Hydration은 SSR의 "전부 아니면 전무" 한계를 해결합니다.

  • Streaming SSR: 준비된 HTML을 즉시 전송하고, 느린 부분은 나중에 스트리밍합니다
  • ** 서버 Suspense**: fallback을 먼저 보내고, 데이터 준비 후 실제 콘텐츠로 교체합니다
  • Selective Hydration: Suspense 단위로 독립적으로 hydrate하여, 일부가 느려도 나머지는 인터랙티브합니다
  • ** 사용자 인터랙션 우선순위 **: 사용자가 클릭한 영역의 hydration을 우선 처리합니다
  • renderToPipeableStreamrenderToString을 대체하는 새 표준입니다

주의할 점

Suspense 바운더리의 배치가 성능을 결정

Suspense를 너무 넓게 감싸면 작은 데이터 하나가 느려도 큰 영역이 fallback으로 교체됩니다. 느릴 수 있는 데이터 의존성만 ** 정확히 감싸야** 나머지 영역이 즉시 인터랙티브해집니다.

renderToString을 계속 사용하면 Streaming의 이점을 누릴 수 없음

renderToString은 전체 HTML이 완성될 때까지 기다리므로, Streaming SSR의 점진적 전송 이점이 없습니다. renderToPipeableStream으로 전환해야 합니다.

이 모든 최적화의 핵심은 Suspense 바운더리를 어디에 배치하느냐입니다. 느릴 수 있는 데이터 의존성을 Suspense로 감싸면, React가 나머지를 자동으로 최적화합니다.

댓글 로딩 중...