Search

04-1_Hydration

Hydration

Hydration은 직렬화로 잃어버린 이벤트 핸들러를 클라이언트에서 복원하는 거예요.
이걸 이해하는 가장 쉬운 멘탈모델은 “뼈와 살”이에요.
서버 HTML = 뼈대 (구조, 마크업, 텍스트) 이벤트 핸들러 = 살 (클릭, 입력, 인터랙션) Hydration = 뼈에 살을 붙이는 과정
Plain Text
복사
서버에서 만든 HTML은 뼈대예요. 형태는 갖췄지만 움직이지 않아요. 클릭해도 아무 일도 일어나지 않죠.
왜 그럴까요? 이벤트 핸들러(살)는 함수인데, 함수는 네트워크로 전송할 수 없기 때문이에요. 이 장에서는 이 문제가 왜 발생하는지, 그리고 Hydration이 어떻게 해결하는지 알아볼게요.

왜 이벤트 핸들러가 사라지는가

SSR 직접 구현하기에서 봤던 코드를 다시 볼게요.
function App() { const handleClick = () => { alert("클릭!"); }; return <button onClick={handleClick}>클릭</button>; }
TypeScript
복사
이 컴포넌트를 renderToString으로 변환하면 <button>클릭</button>이 돼요. onClick이 사라졌어요.
왜 그럴까요? 서버에서 클라이언트로 데이터를 보내려면 직렬화(Serialization)가 필요하기 때문이에요.

직렬화란

직렬화는 객체를 문자열로 변환하는 과정이에요. HTTP 통신은 문자열(바이트 스트림)만 전송할 수 있기 때문에, 서버의 JavaScript 객체를 클라이언트로 보내려면 문자열로 바꿔야 해요.
이 “텍스트로 변환”이라는 과정 덕분에 Python 서버와 JavaScript 클라이언트처럼 서로 다른 언어 사이에서도 통신이 가능해요. JSON이라는 언어 간에 통용되는 공통 형식만 지키면 상대방의 내부 구현을 몰라도 되니까요. (이 개념은 5-1장에서 더 깊이 다뤄요.)
// 직렬화 const obj = { name: "홍길동", count: 42 }; const str = JSON.stringify(obj); // '{"name":"홍길동","count":42}' // 역직렬화 const restored = JSON.parse(str); // { name: "홍길동", count: 42 }
JavaScript
복사
문제는 모든 것이 직렬화 가능한 건 아니라는 점이에요.
const data = { name: "홍길동", // ✅ 문자열: 직렬화 가능 count: 42, // ✅ 숫자: 직렬화 가능 active: true, // ✅ 불리언: 직렬화 가능 onClick: () => {}, // ❌ 함수: 직렬화 불가! date: new Date(), // ⚠️ Date 객체: 문자열로 변환됨 }; JSON.stringify(data); // 결과: {"name":"홍길동","count":42,"active":true,"date":"2025-01-03T..."} // onClick은 완전히 사라짐!
JavaScript
복사
함수는 직렬화할 수 없어요. 그래서 renderToString이 컴포넌트를 HTML 문자열로 변환할 때 onClick 같은 이벤트 핸들러가 사라지는 거예요.
핵심: 네트워크는 문자열만 전송할 수 있고, 함수는 문자열로 바꿀 수 없어요. 이것이 이벤트 핸들러가 사라지는 근본 원인이에요.

Hydration: 껍데기에 생명 불어넣기

서버가 보낸 HTML은 껍데기예요. 보이기만 할 뿐, 클릭해도 아무 일도 일어나지 않아요.
이 상태로 두면 어떻게 될까요?
텍스트, 이미지, 레이아웃 → 화면에 보임
버튼 클릭 → 무반응
입력 필드 onChange → 무반응
useState, useEffect → 동작 안 함
완전히 정적인 페이지가 돼요. 신문 기사 프린트물처럼요.
이 껍데기에 이벤트 핸들러를 다시 연결해야 해요. 이 과정을 Hydration(수화)이라고 불러요.
“수화”라는 표현은 직관적이에요. 직렬화 과정에서 “말라버린” HTML에 이벤트 핸들러라는 “수분”을 다시 채워넣는 거죠.
서버 렌더링 HTML (정적, 이벤트 없음) ↓ [hydrate] React 앱 (상호작용 가능)
Plain Text
복사

Hydration 구현하기

서버 코드부터 수정할게요. 클라이언트 JavaScript 번들을 로드하는 스크립트 태그를 추가해야 해요.
// server.ts import { Hono } from "hono"; import { renderToString } from "react-dom/server"; import App from "./App"; const app = new Hono(); app.get("/", (c) => { const content = renderToString(<App />); const html = ` <!DOCTYPE html> <html> <head> <title>SSR with Hydration</title> </head> <body> <div id="root">${content}</div> <script type="module" src="/client.tsx"></script> </body> </html> `; return c.html(html); }); export default app;
TypeScript
복사
그리고 클라이언트 진입점 파일을 만들어요.
// client.tsx import { hydrateRoot } from "react-dom/client"; import App from "./App"; hydrateRoot(document.getElementById("root")!, <App />);
TypeScript
복사
hydrateRoot가 하는 일은 이래요:
1.
서버가 보낸 DOM을 찾음
2.
React 컴포넌트 트리를 메모리에 구축 (실제 DOM 생성은 안 함)
3.
기존 DOM 노드에 이벤트 핸들러를 연결
핵심은 DOM을 새로 만들지 않고 기존 DOM을 재사용한다는 점이에요. 그래서 화면 깜빡임 없이 인터랙션만 추가돼요.
이제 버튼을 클릭하면 alert('클릭!')이 실행돼요.
핵심: Hydration은 DOM을 새로 만들지 않고, 기존 DOM에 이벤트 핸들러만 연결해요. 이것이 “수화(水化)”라는 이름의 의미예요.

Hydration이 작동하는 조건

Hydration은 “기존 DOM을 재사용”하는 최적화예요. 이 최적화가 동작하려면 전제 조건이 필요해요:
서버 렌더링 결과 = 클라이언트 렌더링 결과
왜 이 조건이 필요할까요? hydrateRoot는 DOM을 새로 만들지 않고 기존 DOM에 이벤트만 연결해요. 그런데 서버와 클라이언트의 결과가 다르면, React는 “어? 이 DOM은 내가 예상한 것과 다른데?”라고 혼란에 빠져요.
이 불일치를 Hydration Mismatch라고 불러요. 예시를 볼게요.
function Clock() { return <div>현재 시간: {new Date().toLocaleTimeString()}</div>; }
TypeScript
복사
이 컴포넌트는 문제가 있어요. 서버에서 렌더링할 때와 클라이언트에서 Hydration할 때 시간이 다르기 때문이에요.
서버 (12:30:45): <div>현재 시간: 12:30:45</div> 클라이언트 (12:30:47): <div>현재 시간: 12:30:47</div> → Mismatch!
Plain Text
복사
React는 이런 불일치를 감지하면 경고를 표시해요. 불일치를 무시하면 이벤트 핸들러가 잘못된 요소에 연결되는 심각한 문제가 생길 수 있어요.
그래서 React는 불일치가 크면 기존 DOM을 버리고 전체를 다시 렌더링해요. 이게 바로 “Hydration mismatch” 에러의 원인이에요.
[처음부터 다시 렌더링하면] 1. 서버가 보낸 HTML이 화면에 보임 2. JavaScript가 로드됨 3. 기존 DOM을 버리고 새로 렌더링 4. 화면이 깜빡이거나 레이아웃이 흔들림
Plain Text
복사
정상적인 Hydration은 이 문제를 피해요. 기존 DOM 노드를 재사용하면서 이벤트 핸들러만 연결하기 때문에, 화면이 깜빡이지 않아요. 사용자 입장에서는 HTML이 도착한 순간부터 콘텐츠가 보이고, JavaScript가 로드되면 자연스럽게 인터랙션이 가능해져요.
대신 이 최적화가 동작하려면, 서버와 클라이언트의 렌더링 결과가 동일해야 해요. 그래서 mismatch가 문제가 되는 거예요.

Mismatch의 원인: 비결정적 값

Hydration mismatch가 발생하는 근본 원인은 비결정적 값(Non-deterministic Value)이에요. 같은 코드를 실행해도 매번 다른 결과가 나오는 값들이죠.
대표적인 비결정적 값:
new Date() - 실행 시점에 따라 다름
Math.random() - 매번 다른 값
crypto.randomUUID() - 매번 다른 ID
서버와 클라이언트는 다른 시점에, 다른 환경에서 실행돼요. 그래서 이런 값들은 필연적으로 불일치를 일으켜요.
해결 원칙: 비결정적 값은 서버에서 한 번 생성하고, 클라이언트에 전달해서 재사용해요.
function Clock({ initialTime }: { initialTime: string }) { const [time, setTime] = useState(initialTime); useEffect(() => { // Hydration 완료 후에만 시간 업데이트 const timer = setInterval(() => { setTime(new Date().toLocaleTimeString()); }, 1000); return () => clearInterval(timer); }, []); return <div>현재 시간: {time}</div>; }
TypeScript
복사
서버에서 초기 시간을 props로 전달하고, 클라이언트에서 Hydration이 완료된 후에만 시간을 업데이트하는 방식이에요.
핵심: Mismatch를 피하려면 비결정적 값(시간, 랜덤)을 서버에서 한 번만 생성하고, 클라이언트에 전달해서 재사용해요.

정리: 직렬화 한계가 만든 구조

[근본 문제] 네트워크는 문자열만 전송할 수 있다 ↓ 함수는 직렬화할 수 없다 ↓ 이벤트 핸들러가 사라진다 ↓ [해결책: Hydration] 클라이언트에서 이벤트 핸들러를 다시 연결한다
Plain Text
복사
이 직렬화 한계는 SSR 전반에 걸쳐 반복적으로 등장해요. Next.js 같은 프레임워크의 'use client' 같은 지시어도 결국 “이 코드는 클라이언트에서 실행되어야 한다”를 선언하는 것인데, 그 이유가 바로 이벤트 핸들러와 상태 때문이에요. 이 부분은 5장에서 더 자세히 다뤄요.