Back to blog
Apr 12, 2026
14 min read

React 동시성(Concurrency) 이해하기 — 2편: Hydration과 Streaming SSR

React API에 대해 간단히 알아보고, 1편에 이어 Streaming SSR과 Hydration에 대해 깊게 알아봅니다.

시작하기 전에 React의 Server API 들을 먼저 알아보고 시작하면 좋을 것 같습니다.

천천히 예시와 함께 이해하면서 같이 알아보겠습니다.

1. createRoot & hydrateRoot

두 API는 구조 자체는 거의 동일합니다.

createRoot = 빈 DOM에 React를 새로 그린다

hydrateRoot = 서버가 미리 그려둔 HTML에 React를 붙인다

// createRoot: 생성 후 render()를 별도로 호출해야 함
const root = createRoot(domNode, options?)
root.render(<App />)

// hydrateRoot: 생성과 동시에 reactNode를 두 번째 인자로 받음
const root = hydrateRoot(domNode, reactNode, options?)
// root.render()는 거의 호출 안 함

hydrateRoot가 두 번째 인자로 reactNode를 바로 받는 이유는, HTML이 이미 화면에 있기 때문에 즉시 hydration을 시작해야 하기 때문입니다. createRoot처럼 render()를 나중에 호출하면 그 사이 HTML이 비어있는 순간이 생길 수 있습니다.


핵심 동작 차이

createRoot — DOM을 완전히 초기화하고 새로 그림

const root = createRoot(document.getElementById('root'));
root.render(<App />); // 기존 HTML 콘텐츠를 모두 지우고 새로 렌더링

root.render()가 처음 호출되면 컨테이너 내부의 기존 HTML을 모두 삭제합니다. 서버 렌더링 HTML이 있는 컨테이너에 실수로 createRoot를 쓰면, 깜빡임이 생기고 포커스/스크롤 위치가 초기화되며 사용자 입력이 날아갑니다.

hydrateRoot — 기존 HTML을 재활용하며 연결

const root = hydrateRoot(document.getElementById('root'), <App />);
// DOM을 다시 그리지 않고, 이벤트 핸들러만 붙임
// root.render()는 이후 업데이트가 필요할 때만 호출

서버 HTML과 클라이언트 React 트리를 비교(reconcile)해서, DOM 노드를 재사용하면서 이벤트 핸들러만 연결합니다. 이것이 SSR의 성능 이점의 핵심입니다.


SPA 앱에서 createRoot를 hydrateRoot로 바꾸면?

vite의 CSR 예시로 들어보면,

<!-- index.html -->
<div id="root"></div> <!-- 비어있음 -->
// main.tsx — 원래 코드
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')!).render(<App />);

위와 같은 코드에서, hydrateRoot로 바꾸면 아래와 같은 코드가 됩니다.

// main.tsx — 잘못 바꾼 코드
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root')!, <App />);

이때 벌어지는 일을 단계별로 보면:

1단계 — hydrateRoot가 컨테이너의 기존 HTML을 확인

<div id="root"></div>  ← 비어있음

2단계 — React가 자신이 렌더링할 트리와 비교 시도

서버 HTML: (없음)
클라이언트: <div class="app"><h1>Hello</h1>...</div>
↑ mismatch!

3단계 — Hydration mismatch 발생 → 자동 복구

React는 mismatch를 감지하면 서버 HTML을 버리고 클라이언트 렌더링으로 폴백합니다.

결과적으로 createRoot와 동일하게 동작합니다.

대신, dev모드에서는 콘솔에 흔히 보는 hydration 경고 오류가 찍혀있습니다.

Warning: An error occurred during hydration. The server HTML was replaced
with client content in <#document>.

정적 HTML이 있을 경우에는 오류가 발생하고, 의도된 api 사용이 아니기 때문에 실제로는 사용해선 안됩니다.

2. hydrate → hydrateRoot

React 18+ 부터는 hydratehydrateRoot로 변경되었습니다.


hydrate (legacy)

// 시그니처
hydrate(reactNode, domNode, callback?) → null
// 사용
import { hydrate } from 'react-dom';

hydrate(<App />, document.getElementById('root'));
  • 반환값이 null — 루트에 대한 참조를 반환하지 않아 이후 업데이트가 불가
  • 세 번째 인자로 callback을 받아 hydration 완료 후 실행 가능
  • Concurrent Mode, Streaming SSRReact 18의 신규 기능을 전혀 활용 못 함
  • 에러 핸들링 옵션 없음

hydrateRoot (React 18+)

// 시그니처
hydrateRoot(domNode, reactNode, options?) → { render, unmount }
import { hydrateRoot } from 'react-dom/client';

const root = hydrateRoot(document.getElementById('root'), <App />);
  • SSR 진입점, react-dom/client에서 import
  • 루트 객체를 반환 — render(), unmount() 메서드로 이후 제어 가능
  • options 객체로 세밀한 에러 핸들링 설정 지원
  • Concurrent Mode 기반 동작
  • document 전체를 첫 번째 인자로 넘겨 전체 문서를 hydrate 가능

특징을 테이블로 비교해보면 아래와 같습니다.

항목hydratehydrateRoot
import 경로react-domreact-dom/client
반환값null{ render, unmount }
이후 업데이트불가root.render()로 가능
에러 핸들링없음onRecoverableError 등 옵션 지원
Concurrent Mode미지원기본 동작
Streaming SSR미지원지원
전체 문서 hydrate불가document 전달 가능

Concurrent Mode를 기본으로 만들어 React의 동시성 모델을 확장하고, Streaming SSRSelective Hydration을 지원하기 위해 변경되었습니다.

3. 기존 SSR의 한계

1편에서는 클라이언트 렌더링에서의 동시성을 다루었습니다.

이번에는 SSR이 동시성을 적용하여 어떻게 개선되었는지 알아보겠습니다.

기존 SSR의 흐름은 이렇습니다.

[기존 SSR 흐름]

① 서버: 전체 데이터 fetch 완료까지 대기

② 서버: 전체 앱을 HTML로 렌더링

③ 브라우저: HTML 수신 → 화면 표시 (non-interactive)

④ 브라우저: JS 번들 다운로드

⑤ 브라우저: 전체 앱 hydration

인터랙션 가능

문제는 각 단계가 전체 앱 기준으로 완료되어야 다음으로 넘어간다는 점입니다.

// [병목 예시]

export async function getServerSideProps() {
  const [header, comments, footer] = await Promise.all([
    fetchHeader(), // 10ms
    fetchComments(), // 3000ms  ← 얘 하나 때문에 전체 대기
    fetchFooter(), // 50ms
  ]);
}

Comments가 3초 걸리면, Header와 Footer도 3초 동안 사용자에게 보이지 않습니다.

이렇게 세 가지 병목이 생깁니다.

  1. 가장 느린 데이터가 준비될 때까지 HTML 전송 못 함
  2. JS 번들 전체가 로드돼야 hydration 시작 가능
  3. 전체 hydration이 완료돼야 어디든 클릭 가능

4. Suspense

React 18은 이 세 가지 병목을 모두 <Suspense>로 해결합니다.

function App() {
  return (
    <Layout>
      <Header />
      <Suspense fallback={<Spinner />}>
        <Comments /> {/* 느려도 나머지를 막지 않음 */}
      </Suspense>
      <Footer />
    </Layout>
  );
}

<Suspense>가 하는 일은 단순합니다. 자식 컴포넌트가 아직 준비되지 않았을 때, React에게 “이 경계 안은 잠깐 건너뛰어도 돼” 라고 알리는 역할입니다.

서버에서는 fallback을 먼저 스트리밍하고 데이터가 준비되면 해당 청크를 이어서 보내고, 클라이언트에서는 다른 영역의 hydration을 먼저 진행하고 이 경계는 나중에 처리합니다.

즉, <Suspense> 하나가 Streaming SSRSelective Hydration 두 가지 모두의 경계 단위가 됩니다.

     <Suspense fallback={<Spinner />}>

        ┌───────────┴───────────┐
        │                       │
      Server                 Client
        │                       │
        ▼                       ▼
┌───────────────┐     ┌─────────────────────┐
│ Streaming SSR │     │ Selective Hydration │
└───────────────┘     └─────────────────────┘

5. Streaming SSR

Shell이란

renderToPipeableStream을 이해하는 핵심 개념은 shell입니다.

React 공식 문서는 shell을 이렇게 정의합니다.

The part of your app outside of any <Suspense> boundaries is called the shell.

<Suspense> 경계 바깥에 있는 모든 것이 shell입니다. 사용자가 페이지 뼈대를 처음 보게 되는 부분이기도 합니다.

┌──────────────────────────────────┐
│ <Layout>             ← shell     │
│   <Header />         ← shell     │
│   ┌──────────────────────────┐   │
│   │ <Suspense>               │   │
│   │   <Comments />           │   │
│   │ <Suspense>               │   │
│   └──────────────────────────┘   │
│   <Footer />         ← shell     │
└──────────────────────────────────┘

shell 렌더링이 완료되는 시점에 onShellReady가 호출되고, 이때부터 스트리밍이 시작됩니다. shell 안에서 에러가 나면 onShellError가 호출됩니다.

renderToPipeableStream

기존 renderToString은 동기 함수였습니다. 전체 HTML이 완성될 때까지 기다린 뒤 한 번에 응답합니다. React 18의 renderToPipeableStream은 다릅니다.

const { pipe, abort } = renderToPipeableStream(<App />, {
  bootstrapScripts: ['/client.js'],

  onShellReady() {
    // shell 렌더 완료 → 즉시 스트리밍 시작
    res.statusCode = 200
    res.setHeader('Content-Type', 'text/html')
    pipe(res)
  },

  onShellError(error) {
    // shell 렌더링 자체가 실패한 경우 (onShellReady는 호출되지 않음)
    res.statusCode = 500
    res.setHeader('Content-Type', 'text/html')
    res.send('<p>오류 발생</p>')
  },

  onAllReady() {
    // 모든 Suspense 포함 전체 렌더 완료
    // 크롤러, SSG 전용 → onShellReady 대신 사용
    res.statusCode = 200
    res.setHeader('Content-Type', 'text/html')
    pipe(res)
  },

  onError(error) {
    console.error(error)
  },
})
콜백호출 시점용도
onShellReadyshell 렌더 완료일반 유저 → 스트리밍 시작
onShellErrorshell 렌더 실패에러 폴백 HTML 전송 (onShellReady 미호출)
onAllReady전체 렌더 완료크롤러, SSG → onShellReady 대신 사용
onErrorshell 안팎 모든 에러에러 로깅

onShellReadyonAllReady둘 중 하나만 pipe(res)를 호출해야 합니다. 일반 유저에게는 onShellReady에서, 크롤러/SSG에는 onAllReady에서만 호출합니다.

실제로 브라우저에 뭐가 날아가나

일반적인 HTTP 응답은 데이터를 한 번에 전송하고 연결을 끊습니다. Streaming SSR은 HTTP의 chunked transfer encoding 방식을 활용합니다. 응답 연결을 열어둔 채로 준비된 HTML 조각을 그때그때 흘려보내는 방식입니다.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[chunk 1]  onShellReady → 즉시 전송
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

<!DOCTYPE html>
<html>
<body>
  <nav>Header</nav>

  <!-- Suspense fallback -->
  <div>Loading...</div>

  <footer>Footer</footer>

<!-- HTTP 연결은 아직 열려있음 -->
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[chunk 2]  Comments 데이터 준비 완료 → 추가 전송
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  <!-- 실제 내용을 hidden div로 삽입 -->
  <div hidden id="S:0">
    <ul>
      <li>Comment 1</li>
      <li>Comment 2</li>
    </ul>
  </div>

  <!-- fallback을 실제 내용으로 교체 -->
  <script>$RC("B:0", "S:0")</script>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[chunk 3]  모든 Suspense 해결 → 연결 종료
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

</body>
</html>

브라우저는 받는 대로 렌더링합니다. chunk 1이 도착하는 순간 Header와 Footer가 화면에 나타납니다.

Suspense가 없으면?

// Suspense 없는 경우
function App() {
  return (
    <div>
      <Header />
      <Comments /> {/* 느려도 Suspense 없음 */}
      <Footer />
    </div>
  );
}
[Suspense 없을 때]

shell = 전체 앱 (쪼갤 boundary가 없음)
onShellReady = 전체 렌더 완료 후 호출

→ streaming의 이점을 활용할 수 없음
→ TTFB는 renderToString보다 나을 수 있지만
   쪼갤 단위가 없어 의미 있는 streaming이 일어나지 않음

renderToPipeableStream을 써도 <Suspense> boundary가 없으면 쪼갤 단위가 없어서 streaming의 이점을 활용할 수 없습니다.


6. Hydration이란

Streaming으로 HTML이 내려왔습니다. 브라우저는 이 HTML을 화면에 표시하지만, 아직 버튼을 클릭해도 아무 반응이 없습니다. JavaScript가 아직 연결되지 않은 상태이기 때문입니다.

이 HTML에 React를 “붙여서” 인터랙티브하게 만드는 과정이 Hydration입니다.

비유하자면, 서버가 보내준 HTML은 형태만 갖춘 조각상입니다. Hydration은 그 조각상에 신경계를 연결해 움직일 수 있게 만드는 과정입니다.

여기서 중요한 점은, Hydration은 DOM을 새로 만드는 게 아니라는 것입니다. CSR과 비교해보면 차이가 명확합니다.

// CSR: 빈 div에서 시작, React가 DOM을 처음부터 생성
ReactDOM.createRoot(document.getElementById('root')).render(<App />);

// SSR 이후: 서버가 만든 HTML이 이미 있음, React는 "연결"만 함
ReactDOM.hydrateRoot(document.getElementById('root'), <App />);

createRoot는 컨테이너를 비우고 React가 DOM을 직접 만들지만, hydrateRoot는 이미 있는 DOM을 그대로 두고 React 컴포넌트 트리를 그 위에 맞춰 붙입니다. 그래서 화면이 다시 그려지지 않고도 인터랙션이 활성화되는 것입니다.

서버가 만든 HTML DOM 위에, React 컴포넌트 트리를 “맞춰 붙이는” 과정

hydrateRoot의 내부 동작을 좀 더 살펴보면:

[Hydration 내부 동작]

① JS 번들 로드 → React가 메모리 안에 Virtual DOM 트리 구성

② 실제 DOM 노드와 Virtual DOM 노드를 하나씩 매칭

   Virtual DOM <Header />  ←→  실제 DOM <header>...</header>
   Virtual DOM <Footer />  ←→  실제 DOM <footer>...</footer>

③ root에 이벤트 위임 리스너 등록
   (각 DOM 요소에 개별 리스너를 붙이는 게 아님)

④ 인터랙션 가능

서버 HTML과 클라이언트 렌더 결과가 일치하면 DOM을 건드리지 않고 연결만 합니다. 불일치하면 경고를 출력하고 해당 subtree를 다시 그립니다.

[일치 vs 불일치]

서버: <button>0</button>
클라: <button>0</button>  →  DOM 재사용, 이벤트만 부착  ✅

서버: <button>0</button>
클라: <button>1</button>  →  경고 + DOM 교체            ❌

mismatch가 자주 발생하는 원인들:

// 서버/클라이언트 결과가 달라지는 코드들

typeof window !== 'undefined' ? 'browser' : 'server';
new Date().toLocaleString(); // 시간 기반
Math.random(); // 랜덤
localStorage.getItem('theme'); // 브라우저 전용 API

7. Page Router vs App Router

Page Router

[Page Router: 접속 → hydration]

브라우저  GET /post

서버  getServerSideProps 실행
      └─ 전체 데이터 완료까지 대기

서버  renderToString(<Page {...props} />) 실행
      └─ 동기, 전체 HTML 한 번에 생성

서버  응답 구성

      <div id="__next">렌더링된 HTML</div>

      <script id="__NEXT_DATA__" type="application/json">
        {"props": {"pageProps": {"data": {...}}}}
      </script>

      <script src="/main.js"></script>


브라우저  HTML 파싱 → 화면 표시 (non-interactive)

브라우저  JS 번들 로드 → __NEXT_DATA__ 읽어서 hydrateRoot 실행
          └─ 전체 트리를 한 덩어리로 hydration

인터랙션 가능

__NEXT_DATA__가 필요한 이유는, 서버에서 쓴 데이터와 동일한 데이터로 클라이언트가 Virtual DOM을 구성해야 mismatch 없이 DOM을 재사용할 수 있기 때문입니다.

클라이언트 이동 시에는 흐름이 달라집니다.

[첫 접속]  getServerSideProps → HTML + __NEXT_DATA__ JSON
[클라이언트 이동]  getServerSideProps → JSON만 (HTML 없음, CSR처럼 동작)

App Router

[App Router: 접속 → hydration]

브라우저  GET /post

서버  Server Components 렌더링 시작
      └─ async 컴포넌트 안에서 직접 fetch
      └─ Suspense boundary 단위로 분리

서버  shell 완성 → 즉시 스트리밍 시작

      <html><body>
        <nav>Header</nav>
        <div>Loading...</div>   ← Suspense fallback
        <footer>Footer</footer>
        <script>self.__next_f = []</script>  ← RSC Payload 버퍼

    ↓ (Suspense 해결될 때마다 chunk 추가 전송)

      <div hidden>실제 Comments HTML</div>
      <script>self.__next_f.push([...])</script>  ← RSC Payload chunk


브라우저  JS 번들 로드 → hydrateRoot 실행
          ├─ Server Component → HTML 그대로 인정, 건너뜀
          └─ 'use client' → 해당 subtree만 hydration

인터랙션 가능

핵심 차이

Page RouterApp Router
데이터 fetchgetServerSideProps (페이지 단위)컴포넌트 안에서 직접
렌더링 APIrenderToString (동기)renderToPipeableStream (비동기, 스트리밍)
데이터 전달__NEXT_DATA__ JSON (단일 블록)self.__next_f RSC Payload (청크 스트리밍)
Hydration 대상전체 컴포넌트 트리Client Component만
스트리밍

8. Selective Hydration

Streaming SSR로 병목 1을 풀었습니다. 그런데 hydration에도 병목이 남아있습니다.

React 17까지

큰 페이지라면 hydration 자체가 메인 스레드를 수백ms 블로킹할 수 있습니다. 그 동안 사용자가 클릭해도 아무 반응이 없습니다.

[React 17]

┌──────────────────────────────────────────────┐
│   Hydrate entire tree (sync, all at once)    │  ← main thread blocked
└──────────────────────────────────────────────┘

                  interactive

React 18: Suspense boundary 단위로 분리

Selective Hydration은 <Suspense>로 감싼 영역에만 적용됩니다. 각 boundary 안의 코드(JS 번들)가 로드되는 대로 독립적으로 hydrate됩니다.

// Comments, Sidebar가 'use client' 컴포넌트인 경우
<>
  <Header /> {/* Suspense 없음 → 일반 hydration */}
  <Suspense fallback={<Spinner />}>
    <Comments /> {/* 독립 hydration 단위 */}
  </Suspense>
  <Suspense fallback={<Spinner />}>
    <Sidebar /> {/* 독립 hydration 단위 */}
  </Suspense>
</>
[React 18 — Selective Hydration]

┌──────────┐  ┌──────────┐
│ Comments │  │  Sidebar │
└──────────┘  └──────────┘
     │              │
  JS pending      JS ready

               hydrated      ← Comments보다 먼저
               interactive

  JS ready

  hydrated
  interactive

각 boundary의 JS가 준비되는 순서대로 독립적으로 hydrate됩니다.

클릭 → 우선순위 역전

React는 hydrateRoot 시점에 root에 capture phase 이벤트 리스너를 등록합니다. hydration이 진행 중일 때 클릭이 발생하면, React가 이 리스너로 이벤트를 먼저 감지해 클릭된 영역의 Suspense boundary를 우선 hydrate합니다. 개발자가 별도로 구현하는 게 아니라 React 내부에 내장된 동작입니다.

[우선순위 역전 시나리오]

Sidebar: hydrating 중
Comments: hydration 대기 중

사용자가 Comments 영역 클릭!

root의 capture phase 리스너가 이벤트 감지

React: "클릭된 노드가 Comments boundary 안에 있네"

Sidebar hydration 잠깐 중단

Comments 먼저 synchronous hydrate  ← Concurrent Mode가 가능하게 함

클릭 이벤트 정상 처리

Sidebar hydration 재개

트리 순서가 아니라 사용자 행동이 우선순위를 결정합니다.

이게 가능한 이유는 1편에서 다룬 동시성 덕분입니다.

[동시성과 Hydration의 연결]

React 17  →  hydration = 동기 작업
              한번 시작하면 끝까지, 중단 불가

React 18  →  hydration = Concurrent 작업
              중단 / 재개 / 우선순위 변경 가능
              Suspense boundary 단위로 독립 실행

9. ‘use client’는 CSR이 아니다

App Router를 쓰다 보면 자연스럽게 'use client'를 붙이게 됩니다. 많은 분들이 이걸 “클라이언트에서만 렌더링된다”고 오해합니다.

정확한 정의

공식 문서의 정의는 이렇습니다.

Directives provide instructions to bundlers compatible with React Server Components.

React 런타임이 아니라 webpack/turbopack 같은 번들러에게 전달하는 지시어입니다. 그리고 Next.js 공식 문서는 이렇게 설명합니다.

‘use client’ declares an entry point for the components to be rendered on the client side.

“클라이언트에서 렌더링될 컴포넌트의 진입점을 선언한다.”

즉, 이렇게 이해해야 합니다.

['use client'의 정확한 역할]

번들러에게: "여기가 서버-클라이언트 경계야.
             이 파일부터 클라이언트 번들에 포함해줘."

경계의 전파

'use client'는 해당 파일뿐 아니라 그 파일이 import하는 모든 모듈로 전파됩니다.

[경계 전파 예시]

Button.tsx  ('use client')
    └─ imports Icon.tsx  (지시어 없음)
    └─ imports utils.ts  (지시어 없음)

번들러가 보는 것:
  Button.tsx → 클라이언트 번들 포함
  Icon.tsx   → Button이 import하므로 클라이언트 번들 포함
  utils.ts   → Button이 import하므로 클라이언트 번들 포함

그래서 'use client'는 최대한 **leaf 컴포넌트(트리의 끝)**에 가깝게 붙이는 게 좋습니다. 불필요하게 많은 코드가 클라이언트 번들에 포함되는 걸 막기 위해서입니다.

CSR과 다른 점

[CSR vs 'use client']

CSR (ssr: false)
─────────────────────────────────────
서버 응답:  <div id="root"></div>    ← 빈 껍데기
브라우저:   JS 로드 후 직접 렌더링
초기 화면:  빈 화면 (또는 스피너)
Hydration: 없음 (처음부터 CSR)


'use client' 컴포넌트
─────────────────────────────────────
서버 응답:  <button>0</button>       ← HTML로 렌더링되어 옴
브라우저:   JS 로드 후 hydration
초기 화면:  즉시 HTML 표시
Hydration: 있음

진짜 CSR이 필요한 경우:

// window, localStorage 등 브라우저 전용 API를 초기 렌더에 써야 할 때
const MyWidget = dynamic(() => import('./BrowserOnly'), {
  ssr: false, // 서버 렌더링 비활성화 → 진짜 CSR
});

한 줄 정리

['use client' 요약]

없음          →  Server Component (기본값)
               JS 번들 포함 안 됨, 서버에서만 실행
               useState / onClick 사용 불가

'use client'  →  서버-클라이언트 경계 선언
               JS 번들에 포함됨
               서버에서 HTML 렌더링 + 클라이언트에서 hydration
               useState / onClick 사용 가능

'use server'  →  Server Function (Server Action)
               Server Component를 선언하는 게 아님!

'use server'는 Server Component 선언이 아니라 서버에서 실행되는 함수(Server Action)를 위한 지시어입니다. Server Component에는 별도 지시어가 없습니다.

'use client'는 SSR + hydration의 조합이지, CSR이 아닙니다.


마치며

[2편 핵심 요약]

기존 SSR의 한계
  → 전체 데이터 대기, 전체 JS 로드, 전체 hydration
  → 세 단계 모두 "전부 아니면 전무"

Streaming SSR
  → renderToPipeableStream + Suspense
  → shell 즉시 전송, 나머지는 청크로 스트리밍
  → 병목 1 해결: 느린 데이터가 빠른 화면을 막지 않음

Selective Hydration
  → Suspense boundary 단위 독립 hydration
  → 클릭 시 해당 컴포넌트 우선 hydration
  → 병목 2, 3 해결: 준비된 것부터, 필요한 것부터

'use client'
  → CSR이 아님
  → 번들러에게 "여기가 서버-클라이언트 경계"를 알려주는 지시어
  → 해당 컴포넌트도 서버에서 HTML로 렌더링됨

그리고 이 모든 것의 연결고리
  → <Suspense>

3편에서는 이 위에서 동작하는 React Server Components를 다룹니다.


참고