1 / 2

04. Hydration 완전 이해

예상 시간: 5분

04. Hydration 완전 이해

이 문서에서 배우는 것

  • Hydration이란 무엇인지: "죽은 HTML"에 이벤트 핸들러를 연결하는 과정
  • hydrateRoot()의 동작 과정 3단계
  • hydrateRoot vs createRoot의 차이 (재사용 vs 새로 그리기)
  • Hydration 불일치(mismatch) 에러가 발생하는 경우
  • Uncanny Valley: 보이지만 동작하지 않는 구간
  • 시간 순서 다이어그램: HTML 수신 → JS 다운로드 → Hydration 완료

PM 요청 (김도연)

김도연 PM: 준혁님, renderToString으로 HTML을 만드는 건 이해했어요. 근데 그 다음에 "Hydration"이라는 게 필요하다고 했잖아요. 이게 정확히 뭐예요? 그냥 HTML을 보냈으면 끝 아닌가요?

이준혁 시니어: 끝이 아니야! renderToString은 "보이기만 하는 HTML"을 만들 뿐이고, 그걸 진짜 앱으로 만드는 게 Hydration이야. 이걸 제대로 이해하면 SSR의 전체 그림이 완성돼.


시니어 멘토링 (이준혁)

Hydration이란: 죽은 HTML에 생명을 불어넣기

이준혁: renderToString의 결과를 다시 한번 봐봐.

<!-- 서버가 보내준 HTML -->
<div class="container">
  <h1>안녕하세요</h1>
  <p>방문자 수: <!-- -->0</p>
  <button>+1</button>
</div>

이준혁: 이 HTML에서 버튼을 클릭하면?

김도연: 아무 일도 안 일어나요.

이준혁: 맞아. onClick이 없으니까. 이게 "죽은 HTML"이야. 보이기는 하지만 동작하지 않는 상태.

죽은 HTML (renderToString 결과):
  ┌──────────────────────┐
  │ 안녕하세요            │  보인다  ✅
  │ 방문자 수: 0         │  보인다  ✅
  │ [+1]                 │  보인다  ✅ / 클릭? ❌ 반응 없음
  └──────────────────────┘
 
Hydration 후 (살아있는 앱):
  ┌──────────────────────┐
  │ 안녕하세요            │  보인다  ✅
  │ 방문자 수: 0         │  보인다  ✅
  │ [+1]                 │  보인다  ✅ / 클릭? ✅ 카운트 증가!
  └──────────────────────┘

이준혁: Hydration은 이 "죽은 HTML"에 이벤트 핸들러와 상태 관리 로직을 연결해서 "살아있는 앱"으로 만드는 과정이야.

김도연: "Hydrate"라는 단어가 "수분을 공급하다"라는 뜻이잖아요. 마른 HTML에 물을 주는 느낌인가요?

이준혁: 비유가 딱 맞아!

비유:
 
  건조 식품 (Dehydrated Food):
    물기를 빼서 보존한 음식
    가볍고 운반이 쉬움
    하지만 그대로는 먹기 힘듦
 
  Hydration (수분 공급):
    물을 부으면 원래 음식으로 복원
    먹을 수 있는 상태가 됨
 
  SSR에서:
    renderToString = 탈수 (컴포넌트 → HTML 문자열)
    hydrateRoot    = 수분 공급 (HTML + JS → 살아있는 앱)

hydrateRoot()의 동작 과정: 3단계

이준혁: hydrateRoot가 내부적으로 하는 일을 3단계로 나눠볼게.

// entry-client.jsx (A003 프로젝트)
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import HomePage from './HomePage.jsx';
 
hydrateRoot(document.getElementById('root'), <HomePage />);

1단계: 컴포넌트를 브라우저 메모리에서 실행

hydrateRoot가 <HomePage />를 브라우저에서 실행한다.
 
  function HomePage() {
    const [count, setCount] = useState(0);  // ← 상태 생성
    return (
      <div className="container">
        <h1>안녕하세요</h1>
        <p>방문자 수: {count}</p>
        <button onClick={() => setCount(count + 1)}>+1</button>
                ↑ 이벤트 핸들러 함수 생성 (브라우저 메모리에 존재)
      </div>
    );
  }
 
  결과: React가 "이 컴포넌트의 가상 DOM"을 메모리에 생성
        + onClick 함수, useState 등 모든 로직이 준비됨

2단계: 실행 결과와 기존 HTML 비교

React가 비교하는 것:
 
  브라우저 메모리의 가상 DOM:          실제 DOM (서버가 보내준 HTML):
  ┌─────────────────────┐            ┌─────────────────────┐
  │ div.container        │    vs     │ div.container        │
  │   h1: "안녕하세요"   │    vs     │   h1: "안녕하세요"   │
  │   p: "방문자 수: 0"  │    vs     │   p: "방문자 수: 0"  │
  │   button: "+1"       │    vs     │   button: "+1"       │
  └─────────────────────┘            └─────────────────────┘
 
  비교 결과: "일치!" ✅

3단계: DOM 재사용 + 이벤트 핸들러 부착

일치하면:
  → 기존 DOM 노드를 그대로 재사용 (새로 안 만듦)
  → 이벤트 핸들러만 부착
  → useState, useEffect 등 Hook 연결
 
  결과:
  <button>+1</button>
    + onClick = () => setCount(count + 1)  ← 이제 클릭 가능!
    + setCount → 상태 변경 → 리렌더링     ← 이제 동작!

이준혁: 핵심은 DOM을 새로 만들지 않는다는 거야. 서버가 보내준 HTML의 DOM 노드를 그대로 재사용하고, 거기에 이벤트 핸들러만 "꽂아 넣는" 거지.


hydrateRoot vs createRoot 비교

김도연: hydrateRoot 대신 createRoot를 쓰면 안 되나요? 둘 다 React를 시작하는 거잖아요.

이준혁: 결과는 같아 보이지만, 과정이 완전히 달라.

// createRoot — 새로 그리기 (CSR 방식)
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')).render(<HomePage />);
// → 기존 HTML 무시, DOM을 새로 생성, 처음부터 다시 렌더링
 
// hydrateRoot — 재사용 (SSR 방식)
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root'), <HomePage />);
// → 기존 HTML의 DOM을 재사용, 이벤트 핸들러만 부착
동작createRoothydrateRoot
기존 DOM지우고 새로 만듦그대로 재사용
HTML 비교안 함가상 DOM과 실제 DOM 비교
화면 깜빡임있음 (DOM 교체 시)없음 (DOM 유지)
이벤트 핸들러새 DOM에 부착기존 DOM에 부착
용도CSR (클라이언트만)SSR (서버 HTML 활용)

이준혁: SSR에서 createRoot를 쓰면 어떻게 될까?

SSR + createRoot (잘못된 조합):
 
  1. 서버가 HTML 전송 → 사용자가 화면을 봄
  2. JS 로드 → createRoot 실행
  3. createRoot: "기존 DOM? 몰라. 내가 새로 만들게"
  4. 기존 DOM 제거 → 새 DOM 생성
  5. 화면이 깜빡! (기존 HTML 사라졌다가 새 DOM이 나타남)
 
  결과:
  - 서버에서 HTML을 보낸 의미가 없어짐
  - 사용자 경험 나빠짐 (깜빡임)
  - 네트워크 대역폭 낭비 (서버 HTML을 그냥 버림)
 
SSR + hydrateRoot (올바른 조합):
 
  1. 서버가 HTML 전송 → 사용자가 화면을 봄
  2. JS 로드 → hydrateRoot 실행
  3. hydrateRoot: "기존 DOM 확인... 일치하네. 재사용할게"
  4. 이벤트 핸들러만 조용히 부착
  5. 화면 변화 없음. 하지만 이제 클릭 가능!

Hydration 불일치(Mismatch) 에러

이준혁: hydrateRoot의 2단계에서 "비교"한다고 했지? 만약 서버와 클라이언트의 결과가 다르면?

김도연: 에러가 나나요?

이준혁: 맞아. React가 경고를 띄워.

Hydration 불일치 상황:
 
  서버 (renderToString):
    <p>현재 시각: 14:30:00</p>   ← 서버의 시각
 
  클라이언트 (hydrateRoot):
    <p>현재 시각: 14:30:02</p>   ← 브라우저의 시각 (2초 차이)
 
  React 경고:
  ⚠️ Text content did not match.
     Server: "현재 시각: 14:30:00"
     Client: "현재 시각: 14:30:02"

이준혁: 불일치가 발생하는 흔한 원인들:

불일치 원인 Top 5:
 
1. Date.now() / new Date()
   서버와 클라이언트의 시각이 다름
 
2. Math.random()
   서버와 클라이언트에서 다른 난수 생성
 
3. 브라우저 전용 API (window, localStorage)
   서버에는 window가 없어서 undefined
 
4. 조건부 렌더링
   if (typeof window !== 'undefined') → 서버/클라이언트 결과 다름
 
5. 외부 데이터
   서버와 클라이언트가 다른 시점의 데이터를 가져옴

이준혁: 해결 방법:

// 잘못된 코드 — Hydration 불일치 발생
function Clock() {
  return <p>현재 시각: {new Date().toLocaleTimeString()}</p>;
  // 서버와 클라이언트에서 시각이 다름!
}
 
// 올바른 코드 — useEffect로 클라이언트에서만 시각 표시
function Clock() {
  const [time, setTime] = useState('');
 
  useEffect(() => {
    // useEffect는 서버에서 실행되지 않음
    // → 서버: "", 클라이언트 초기: "" (일치!)
    // → hydration 후에 시각 업데이트
    setTime(new Date().toLocaleTimeString());
  }, []);
 
  return <p>현재 시각: {time || '로딩 중...'}</p>;
}

Uncanny Valley: 보이지만 동작하지 않는 시간

이준혁: SSR에는 묘한 구간이 있어. 서버 HTML은 도착했지만 JS가 아직 로드되지 않은 시간. 이걸 Uncanny Valley라고 불러.

김도연: Uncanny Valley가 뭐예요? 좀 무서운 이름인데...

이준혁: 원래는 로봇 공학에서 나온 용어야. "거의 진짜 같은데 뭔가 이상한" 느낌. SSR에서는 "화면은 진짜 앱처럼 보이는데, 클릭하면 반응이 없는" 상태를 말해.

SSR의 Uncanny Valley:
 
  시간 ────────────────────────────────────────────────────►
 
  │ 빈 화면 │    화면은 보이지만      │   완전한 앱    │
  │         │    클릭 반응 없음       │   모든 것 동작  │
  │◄───────►│◄──────────────────────►│◄──────────────►│
  │ HTML    │   Uncanny Valley       │  Hydration     │
  │ 도착    │   (JS 다운로드 중)      │  완료          │
  │ 전      │                         │                │
  │         │  사용자: "왜 안 되지?"   │  사용자: "됐다!"|

이준혁: 사용자 입장에서 이 구간이 가장 짜증나. 화면이 보이니까 당연히 클릭 가능할 거라고 기대하는데, 아무 반응이 없거든.

Uncanny Valley 길이에 영향을 미치는 요소:
 
  JS 번들 크기        크면 다운로드 오래 걸림
  네트워크 속도        느리면 다운로드 오래 걸림
  JS 실행 시간        컴포넌트 많으면 hydration 오래 걸림
  코드 스플리팅        적용하면 필요한 JS만 다운로드 → 짧아짐
  서버 응답 시간       빠를수록 HTML이 빨리 도착 → Valley 시작 빨라짐

시간 순서 다이어그램

이준혁: SSR의 전체 타임라인을 그려볼게.

SSR 전체 타임라인:
 
  브라우저                                서버
  ────────                               ──────
  │                                        │
  │  GET / ──────────────────────────────► │
  │                                        │
  │                              renderToString(HomePage)
  │                              HTML 문자열 생성
  │                                        │
  │ ◄──────────────────────────── HTML 응답 │
  │                                        │
  │  HTML 파싱 시작                         │
  │  화면에 "안녕하세요" 표시               │
  │  ┌─── FCP (First Contentful Paint)     │
  │  │                                     │
  │  │  <script src="/bundle.js"> 발견      │
  │  │  JS 다운로드 시작                    │
  │  │                                     │
  │  │  ██████████ JS 다운로드 중 ██████████│
  │  │  (이 동안 화면은 보이지만 클릭 불가)  │
  │  │  ← Uncanny Valley →                │
  │  │                                     │
  │  │  JS 다운로드 완료                    │
  │  │  hydrateRoot() 실행 시작             │
  │  │                                     │
  │  │  1. HomePage 컴포넌트 실행           │
  │  │  2. 가상 DOM ↔ 실제 DOM 비교         │
  │  │  3. 이벤트 핸들러 부착               │
  │  │                                     │
  │  └─── TTI (Time to Interactive)        │
  │       이제 클릭 가능!                   │
  │                                        │
주요 시점:
 
  FP  (First Paint)        : 첫 번째 픽셀이 화면에 그려진 시점
  FCP (First Contentful Paint) : 의미 있는 콘텐츠가 보이는 시점
  TTI (Time to Interactive)    : 사용자 상호작용 가능 시점
 
  CSR:  FCP까지 오래 걸림 (JS 다운로드 + 실행 후에야 보임)
        FCP ≈ TTI (보이자마자 바로 상호작용 가능)
 
  SSR:  FCP 빠름 (HTML만 오면 바로 보임)
        TTI까지 시간 있음 (JS 다운로드 + Hydration 필요)
        FCP < TTI (보이는 것과 동작하는 것 사이에 간격)
지표CSRSSR
FCP (첫 화면)느림 (JS 실행 후)빠름 (HTML 도착 즉시)
TTI (상호작용)FCP와 동시FCP보다 늦음
Uncanny Valley없음있음 (FCP~TTI 사이)
SEO불리 (빈 HTML)유리 (완성된 HTML)

실제 코드에서 Hydration 흐름 추적

이준혁: A003 프로젝트에서 Hydration이 어떻게 연결되는지 전체 흐름을 봐보자.

파일 흐름:
 
  [서버 시작]
  server.js
    ├── renderToString(React.createElement(HomePage))
    │   → "<div class="container"><h1>안녕하세요</h1>..."

    └── res.send(`
          <div id="root">${html}</div>
          <script src="/public/react-bundle.js"></script>
        `)
 
  [브라우저에서]
  react-bundle.js (빌드된 client/react-entry.jsx)
    ├── import HomePage from '../react-pages/HomePage.jsx'
    └── hydrateRoot(
          document.getElementById('root'),  ← 서버가 보내준 HTML이 있는 곳
          <HomePage />                       ← 같은 컴포넌트를 다시 실행
        )
// 전체 코드 매핑
 
// 1. 서버에서 (server.js):
app.get('/', (req, res) => {
  const html = renderToString(React.createElement(HomePage));
  //                                  ↑
  //                    이 컴포넌트가 HTML로 변환됨
  res.send(`
    <div id="root">${html}</div>
    <script src="/public/react-bundle.js"></script>
    //                  ↑
    //    이 스크립트가 hydration을 실행
  `);
});
 
// 2. 브라우저에서 (client/react-entry.jsx → react-bundle.js로 빌드됨):
hydrateRoot(
  document.getElementById('root'),
  //            ↑
  //  서버가 만든 <div id="root">...HTML...</div>
  <HomePage />
  //  ↑
  //  같은 컴포넌트! 이번에는 브라우저에서 실행
);

핵심 정리

  1. Hydration은 "죽은 HTML"에 이벤트 핸들러를 연결하는 과정이다. 서버가 보내준 HTML을 버리지 않고 그대로 재사용한다.

  2. hydrateRoot()의 3단계: (1) 컴포넌트를 브라우저에서 실행, (2) 가상 DOM과 실제 DOM 비교, (3) 일치하면 DOM 재사용 + 이벤트 핸들러 부착.

  3. hydrateRoot vs createRoot: hydrateRoot는 기존 DOM을 재사용하고, createRoot는 새로 만든다. SSR에서는 반드시 hydrateRoot를 써야 깜빡임이 없다.

  4. Hydration 불일치는 서버와 클라이언트의 렌더링 결과가 다를 때 발생한다. Date.now(), Math.random(), window 등이 주요 원인이다.

  5. Uncanny Valley는 HTML은 보이지만 JS가 로드되기 전까지 상호작용이 불가능한 구간이다. SSR의 고유한 트레이드오프다.

  6. SSR에서 FCP는 빠르고 TTI는 느리다. CSR과 정반대 특성이다.