04. Hydration 완전 이해
04. Hydration 완전 이해
이 문서에서 배우는 것
- Hydration이란 무엇인지: "죽은 HTML"에 이벤트 핸들러를 연결하는 과정
hydrateRoot()의 동작 과정 3단계hydrateRootvscreateRoot의 차이 (재사용 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을 재사용, 이벤트 핸들러만 부착| 동작 | createRoot | hydrateRoot |
|---|---|---|
| 기존 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 (보이는 것과 동작하는 것 사이에 간격)| 지표 | CSR | SSR |
|---|---|---|
| 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 />
// ↑
// 같은 컴포넌트! 이번에는 브라우저에서 실행
);핵심 정리
-
Hydration은 "죽은 HTML"에 이벤트 핸들러를 연결하는 과정이다. 서버가 보내준 HTML을 버리지 않고 그대로 재사용한다.
-
hydrateRoot()의 3단계: (1) 컴포넌트를 브라우저에서 실행, (2) 가상 DOM과 실제 DOM 비교, (3) 일치하면 DOM 재사용 + 이벤트 핸들러 부착.
-
hydrateRoot vs createRoot: hydrateRoot는 기존 DOM을 재사용하고, createRoot는 새로 만든다. SSR에서는 반드시 hydrateRoot를 써야 깜빡임이 없다.
-
Hydration 불일치는 서버와 클라이언트의 렌더링 결과가 다를 때 발생한다. Date.now(), Math.random(), window 등이 주요 원인이다.
-
Uncanny Valley는 HTML은 보이지만 JS가 로드되기 전까지 상호작용이 불가능한 구간이다. SSR의 고유한 트레이드오프다.
-
SSR에서 FCP는 빠르고 TTI는 느리다. CSR과 정반대 특성이다.