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은 껍데기예요. 보이기만 할 뿐, 클릭해도 아무 일도 일어나지 않아요.
이 상태로 두면 어떻게 될까요?
•
•
•
•
완전히 정적인 페이지가 돼요. 신문 기사 프린트물처럼요.
이 껍데기에 이벤트 핸들러를 다시 연결해야 해요. 이 과정을 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장에서 더 자세히 다뤄요.
