
Hydration: 마른 오징어 불리기
서버 사이드 렌더링(SSR)의 핵심 과정. 메말라 비틀어진 HTML(정적)에 수분(JS)을 공급해서 생동감 넘치는 앱(인터랙션)으로 만드는 마법.

서버 사이드 렌더링(SSR)의 핵심 과정. 메말라 비틀어진 HTML(정적)에 수분(JS)을 공급해서 생동감 넘치는 앱(인터랙션)으로 만드는 마법.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

Next.js로 처음 프로젝트를 만들었을 때 이상한 경험을 했다. 페이지는 순식간에 뜨는데 버튼을 클릭하면 아무 반응이 없었다. 1초쯤 지나면 갑자기 잘 동긴다. 콘솔에도 에러가 없다. 디버깅을 시작했고, 로딩 스피너를 추가했지만 스피너조차 서버에서 렌더링된 채 화면에 고정돼 있었다. 뭔가 근본적으로 잘못 이해하고 있다는 느낌이 들었다.
문제는 내가 SSR의 작동 원리를 이해하지 못한 채 "빠르다더라"만 믿고 썼다는 것이다. 서버가 HTML을 보내주면 끝인 줄 알았다. 하지만 그 HTML은 껍데기일 뿐이었다. 버튼 태그는 있지만 onClick 핸들러는 없다. 입력 필드는 있지만 state와 연결되지 않았다. 말하자면 마른 오징어 상태다.
이 건조한 HTML에 생명을 불어넣는 과정이 바로 Hydration이다. 한글로는 "수화" 또는 "수분 공급"인데, 나는 "마른 오징어 불리기"라는 비유가 제일 마음에 든다. 오징어는 말리면 딱딱하고 비틀어진다. 하지만 물에 담그면 다시 부드럽고 탄력 있게 돌아온다. HTML도 마찬가지다. 서버에서 온 HTML은 딱딱한 텍스트지만, 브라우저에서 JavaScript가 실행되면서 이벤트 리스너와 state가 붙으면 비로소 인터랙티브한 애플리케이션이 된다.
Next.js의 동작 방식은 이렇다:
3번과 4번 사이의 시간이 바로 "고스트 버튼" 구간이다. 화면은 보이지만 동작하지 않는다. 사용자는 답답함을 느낀다. 이 간격을 줄이는 것이 현대 프론트엔드 최적화의 핵심이다.
Hydration의 전제 조건은 서버에서 렌더링한 HTML과 클라이언트에서 렌더링한 결과가 동일해야 한다는 것이다. React는 서버 HTML을 재사용하려고 시도한다. 만약 구조가 다르면 에러가 난다.
// ❌ Hydration Mismatch 발생
function MyComponent() {
const randomNumber = Math.random();
return <div>{randomNumber}</div>;
}
이 코드는 서버에서 렌더링할 때와 클라이언트에서 렌더링할 때 다른 랜덤 숫자를 생성한다. React는 이를 감지하고 콘솔에 경고를 띄운다:
Warning: Text content did not match. Server: "0.1234" Client: "0.5678"
더 흔한 사례는 날짜나 시간을 렌더링하는 경우다.
// ❌ 서버(UTC)와 클라이언트(로컬 시간대)가 다를 수 있음
function ServerTime() {
return <div>{new Date().toString()}</div>;
}
또는 브라우저 전용 API를 서버에서 사용하려고 할 때도 문제가 생긴다.
// ❌ window는 서버에 없음
function BadComponent() {
const width = window.innerWidth; // ReferenceError
return <div>{width}</div>;
}
클라이언트에서만 실행되는 코드는 useEffect 안에 넣으면 된다. useEffect는 브라우저에서만 실행되기 때문이다.
// ✅ Hydration Mismatch 해결
function SafeComponent() {
const [windowWidth, setWindowWidth] = useState(null);
useEffect(() => {
// 클라이언트에서만 실행됨
setWindowWidth(window.innerWidth);
}, []);
if (windowWidth === null) {
// 서버와 클라이언트 첫 렌더링 시 동일한 HTML
return <div>Loading...</div>;
}
return <div>Width: {windowWidth}px</div>;
}
이 패턴의 핵심은 서버와 클라이언트의 첫 렌더링 결과가 동일하다는 것이다. 서버는 "Loading..."을 렌더링하고, 클라이언트도 첫 렌더링에서 "Loading..."을 렌더링한다. Hydration이 완료된 후 useEffect가 실행되면서 실제 값으로 업데이트된다.
기존 React는 Hydration을 한 번에 전부 처리했다. 큰 페이지에서는 JavaScript 번들이 무거워지고, 사용자가 오래 기다려야 했다. React 18은 이를 개선하기 위해 Selective Hydration을 도입했다.
핵심은 <Suspense>다. Suspense로 감싼 컴포넌트는 나중에 Hydration된다. 브라우저는 먼저 중요한 부분을 Hydration하고, 덜 중요한 부분은 나중에 처리한다.
import { Suspense } from 'react';
function Page() {
return (
<div>
<Header />
<MainContent />
<Suspense fallback={<div>Loading comments...</div>}>
<Comments />
</Suspense>
</div>
);
}
이 구조에서 React는 <Header>와 <MainContent>를 먼저 Hydration한다. <Comments>는 코드가 로드될 때까지 기다린다. 만약 사용자가 기다리는 동안 Comments 영역을 클릭하면, React는 즉시 해당 컴포넌트를 우선적으로 Hydration한다. 이를 "사용자 인터랙션 기반 우선순위 조정"이라고 한다.
Selective Hydration과 함께 등장한 개념이 Streaming SSR이다. 기존 SSR은 서버가 모든 컴포넌트를 렌더링한 후 한 번에 HTML을 보냈다. 느린 DB 쿼리가 하나라도 있으면 전체 페이지가 블록된다.
Streaming SSR은 준비된 부분부터 먼저 보낸다. 느린 부분은 <Suspense>로 감싸고, fallback을 먼저 렌더링한다. DB 쿼리가 완료되면 그때 해당 부분의 HTML을 추가로 스트리밍한다.
// Next.js 13 App Router에서의 Streaming SSR
export default async function Page() {
return (
<div>
<Header />
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>
</div>
);
}
async function SlowComponent() {
const data = await fetch('...'); // 느린 쿼리
return <div>{data}</div>;
}
브라우저는 Header를 먼저 받아서 렌더링하고, SlowComponent는 Skeleton을 보여준다. 나중에 서버에서 SlowComponent의 HTML이 도착하면 React가 DOM에 삽입하고 Hydration한다. 이 방식의 장점은 TTFB(Time to First Byte)와 FCP(First Contentful Paint)가 빨라진다는 것이다.
Hydration의 근본적인 문제는 모든 컴포넌트를 Interactive로 만들려고 한다는 것이다. 하지만 대부분의 페이지는 정적이다. 블로그 본문, 이미지, 텍스트는 클릭할 일이 없다. 버튼이나 폼 같은 몇 개의 컴포넌트만 Interactive하면 된다.
Astro 같은 프레임워크는 Islands Architecture를 채택했다. 기본적으로 모든 컴포넌트는 서버에서 렌더링되고, JavaScript를 보내지 않는다. Interactive가 필요한 컴포넌트만 "섬(Island)"처럼 Hydration한다.
---
// Astro 예시
import Counter from './Counter.jsx';
---
<div>
<h1>Welcome to my blog</h1>
<p>This is static content.</p>
<!-- 이 Counter만 Hydration됨 -->
<Counter client:load />
</div>
client:load는 "이 컴포넌트만 브라우저에서 Hydration하라"는 지시다. 나머지는 정적 HTML로 남는다. 이 방식의 장점은 JavaScript 번들이 극도로 작아진다는 것이다. 필요한 컴포넌트만 포함되기 때문이다.
Astro는 다양한 Hydration 전략을 제공한다:
client:load: 페이지 로드 시 즉시 Hydrationclient:idle: 브라우저가 idle 상태일 때 Hydrationclient:visible: 화면에 보일 때 Hydration (Intersection Observer 사용)client:media: 특정 미디어 쿼리가 매칭될 때 Hydration이런 세밀한 제어가 가능한 이유는 Astro가 Partial Hydration을 기본 설계로 채택했기 때문이다.
Next.js 13 App Router에서 등장한 React Server Components(RSC)는 아예 Hydration 개념이 없다. 서버 컴포넌트는 서버에서만 실행되고, 브라우저로는 렌더링된 HTML만 보낸다. JavaScript 코드 자체를 보내지 않기 때문에 Hydration할 게 없다.
// app/page.jsx (Server Component)
async function Page() {
const data = await db.query('...');
return (
<div>
<h1>{data.title}</h1>
<ClientButton /> {/* 이 버튼만 Client Component */}
</div>
);
}
// components/ClientButton.jsx (Client Component)
'use client';
export default function ClientButton() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
이 구조에서 Page 컴포넌트는 서버에서만 실행된다. data.title은 서버에서 렌더링되고, HTML로 브라우저에 전달된다. ClientButton만 Hydration된다. 결과적으로 JavaScript 번들 크기가 극적으로 줄어든다.
RSC와 전통적인 SSR의 차이는 이렇다:
Hydration을 이해하는 데 중요한 메트릭이 두 가지 있다:
SSR의 장점은 FCP가 빠르다는 것이다. 서버에서 HTML을 보내주기 때문이다. 하지만 TTI는 느릴 수 있다. JavaScript 번들을 다운로드하고 Hydration해야 하기 때문이다.
CSR(Client-Side Rendering)은 정반대다. FCP가 느리다. HTML이 거의 비어 있고, JavaScript가 실행된 후에야 콘텐츠를 렌더링하기 때문이다. 하지만 TTI는 FCP와 거의 동시다. 렌더링과 동시에 이벤트 리스너가 붙기 때문이다.
| 방식 | FCP | TTI | 특징 |
|---|---|---|---|
| CSR | 느림 | 빠름 | JavaScript 실행 후 렌더링 |
| SSR | 빠름 | 느림 | HTML 먼저 보여주고 Hydration 대기 |
| Streaming SSR | 매우 빠름 | 점진적 | 준비된 부분부터 표시 |
| Islands | 빠름 | 매우 빠름 | 필요한 곳만 Interactive |
이상적인 시나리오는 FCP와 TTI가 모두 빠른 것이다. 하지만 현실에서는 트레이드오프가 있다. 그래서 React 18의 Selective Hydration과 Astro의 Islands Architecture 같은 최적화 기법이 등장한 것이다.
Next.js에서 Hydration 에러는 대체로 콘솔에 명확하게 표시된다:
Unhandled Runtime Error
Error: Hydration failed because the initial UI does not match what was rendered on the server.
그 아래 어떤 태그가 문제인지도 알려준다. 하지만 원인을 찾기는 까다롭다. 내가 자주 겪은 패턴은 이렇다:
1. 브라우저 확장 프로그램이 DOM을 수정하는 경우광고 차단기나 번역 확장 프로그램이 HTML을 조작하면 Hydration Mismatch가 발생한다. 이건 우리가 제어할 수 없다. Sentry 같은 에러 트래킹 도구로 빈도를 확인하고, 심각하지 않으면 무시한다.
2. 서버와 클라이언트에서 다른 값을 렌더링하는 경우위에서 본 Math.random()이나 Date 같은 경우다. 해결 방법은 useEffect로 클라이언트에서만 실행하도록 하는 것이다.
<p> 안에 <div>를 넣거나, <ul> 안에 <li> 없이 다른 태그를 넣는 경우다. 브라우저는 이를 자동으로 수정하는데, 이 과정에서 서버 HTML과 클라이언트 HTML이 달라진다.
// ❌ 잘못된 HTML
<p>
<div>This is wrong</div>
</p>
// ✅ 올바른 HTML
<div>
<div>This is correct</div>
</div>
디버깅 팁은 React DevTools의 Profiler를 사용하는 것이다. Hydration 시간을 측정할 수 있다. 또한 Next.js는 __NEXT_DATA__ 스크립트 태그에 서버에서 렌더링한 props를 담아서 보낸다. 이걸 확인하면 서버와 클라이언트의 데이터가 동일한지 검증할 수 있다.
제가 3년간 겪은 에러 패턴을 정리했습니다. 이 중에 범인이 있을 겁니다.
Text content did not match. Server: "2024-05-01" Client: "05/01/2024"Date.toLocaleString() 포맷이 다름.date-fns 등으로 포맷을 고정하거나, 클라이언트에서만 렌더링.Expected server HTML to contain a matching <p> in <div>.<p> 태그 안에 <div>를 넣음. (HTML 스펙 위반). 브라우저가 자동으로 </p><div>...</div>로 닫아버려서 구조가 바뀜.<div> 안에 <div>를 쓰거나, 시멘틱 태그(section, article)를 사용.ReferenceError: window is not defineduseState 초기값에서 window.localStorage 등을 접근.useEffect 안에서 접근하거나, typeof window !== 'undefined' 체크.Math.random(), uuid() 등을 렌더링 중에 호출. 서버와 클라이언트가 다른 값을 만듦.useState와 useEffect로 클라이언트에서만 생성하거나, 서버에서 값을 prop으로 내려줌.React 18의 Concurrent Features는 Hydration 성능을 획기적으로 개선했습니다.
이 덕분에 TBT(Total Blocking Time)가 획기적으로 줄어들었습니다. 우리가 아무것도 안 해도, React 18로 업그레이드하는 것만으로 사이트가 빨라지는 이유입니다.
최근 Qwik 같은 프레임워크가 Resumability라는 개념을 들고 나왔습니다. Hydration과 어떻게 다를까요?
비유하자면, Hydration은 "부팅된 컴퓨터를 끄고, 다시 부팅하는 것"이고, Resumability는 "절전 모드에서 깨어나는 것"입니다. JS 번들 크기가 거의 0에 수렴하기 때문에 초기 로딩 속도가 압도적입니다. 미래에는 Hydration 없는 세상이 올지도 모릅니다.
Hydration은 SSR의 숙명이다. 서버에서 HTML을 보내주는 순간, 그 HTML에 생명을 불어넣는 과정이 필요하다. 처음에는 단순히 "React가 알아서 하겠지"라고 생각했지만, 실제로는 FCP와 TTI 사이의 간극, Hydration Mismatch 에러, JavaScript 번들 크기, 사용자 경험까지 고려해야 할 요소가 많다.
React 18의 Selective Hydration과 Streaming SSR은 이 간극을 줄이려는 시도다. Astro의 Islands Architecture는 아예 Hydration을 최소화하려는 접근이다. Next.js의 React Server Components는 Hydration이 필요 없는 컴포넌트를 만들어서 근본적으로 문제를 회피한다.
결국 정답은 없다. 프로젝트의 특성에 따라 SSR이 필요하면 Hydration을 최적화하고, 정적 콘텐츠가 많으면 Islands Architecture를 고려하고, 복잡한 인터랙션이 많으면 CSR을 선택할 수도 있다. 중요한 건 "왜 고스트 버튼이 생기는가"를 이해하는 것이다. 그래야 사용자 경험을 개선할 수 있다.
When I first built a Next.js project, something strange happened. The page loaded instantly, but when I clicked a button, nothing happened. About a second later, suddenly everything worked. No console errors. I added a loading spinner, but even the spinner was frozen in the server-rendered state. Something fundamental was wrong with my mental model.
The problem was that I didn't understand how SSR actually works. I thought the server sends HTML and that's it. But that HTML is just a shell. There's a button tag, but no onClick handler attached. There's an input field, but it's not connected to state. It's like dried squid.
The process of bringing that dry HTML to life is called Hydration. The metaphor makes sense: dried squid is hard and twisted. Soak it in water and it becomes soft and elastic again. HTML works the same way. Server-rendered HTML is rigid text, but when JavaScript runs in the browser and attaches event listeners and state, it becomes an interactive application.
Here's how Next.js works:
The gap between step 3 and 4 is the "ghost button" period. The screen is visible but nothing works. Users get frustrated. Minimizing this gap is the core of modern frontend optimization.
Hydration assumes the server-rendered HTML and client-rendered result are identical. React tries to reuse the server HTML. If the structure differs, you get an error.
// ❌ Hydration Mismatch
function MyComponent() {
const randomNumber = Math.random();
return <div>{randomNumber}</div>;
}
This code generates a different random number on the server and client. React detects this and logs a warning:
Warning: Text content did not match. Server: "0.1234" Client: "0.5678"
A more common case is rendering dates or times:
// ❌ Server (UTC) and client (local timezone) may differ
function ServerTime() {
return <div>{new Date().toString()}</div>;
}
Or trying to use browser-only APIs on the server:
// ❌ window doesn't exist on the server
function BadComponent() {
const width = window.innerWidth; // ReferenceError
return <div>{width}</div>;
}
Code that only runs on the client goes inside useEffect, which only executes in the browser.
// ✅ Fixes Hydration Mismatch
function SafeComponent() {
const [windowWidth, setWindowWidth] = useState(null);
useEffect(() => {
// Only runs on client
setWindowWidth(window.innerWidth);
}, []);
if (windowWidth === null) {
// Server and client first render produce identical HTML
return <div>Loading...</div>;
}
return <div>Width: {windowWidth}px</div>;
}
The key is that the server and client first render produce identical results. The server renders "Loading...", and the client also renders "Loading..." on the first pass. After hydration completes, useEffect runs and updates to the actual value.
Legacy React hydrated everything at once. On large pages with heavy JavaScript bundles, users waited a long time. React 18 introduced Selective Hydration to fix this.
The key is <Suspense>. Components wrapped in Suspense are hydrated later. The browser hydrates important parts first, less important parts later.
import { Suspense } from 'react';
function Page() {
return (
<div>
<Header />
<MainContent />
<Suspense fallback={<div>Loading comments...</div>}>
<Comments />
</Suspense>
</div>
);
}
In this structure, React hydrates <Header> and <MainContent> first. <Comments> waits until its code loads. If the user clicks the Comments area while waiting, React immediately prioritizes hydrating that component. This is called "user interaction-based priority adjustment".
Alongside Selective Hydration came Streaming SSR. Traditional SSR waits for all components to render before sending HTML. One slow database query blocks the entire page.
Streaming SSR sends ready parts first. Slow parts are wrapped in <Suspense> with a fallback rendered first. When the database query completes, that section's HTML is streamed separately.
// Streaming SSR in Next.js 13 App Router
export default async function Page() {
return (
<div>
<Header />
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>
</div>
);
}
async function SlowComponent() {
const data = await fetch('...'); // Slow query
return <div>{data}</div>;
}
The browser receives and renders Header first, showing a Skeleton for SlowComponent. Later, when SlowComponent's HTML arrives from the server, React inserts it into the DOM and hydrates it. This approach improves TTFB (Time to First Byte) and FCP (First Contentful Paint).
Hydration's fundamental problem is trying to make every component interactive. But most pages are static. Blog content, images, text don't need clicks. Only a few components like buttons or forms need to be interactive.
Frameworks like Astro adopted Islands Architecture. By default, all components are server-rendered and send no JavaScript. Only components needing interactivity are hydrated like "islands."
---
// Astro example
import Counter from './Counter.jsx';
---
<div>
<h1>Welcome to my blog</h1>
<p>This is static content.</p>
<!-- Only this Counter is hydrated -->
<Counter client:load />
</div>
client:load tells the framework "hydrate only this component in the browser." Everything else stays as static HTML. The advantage is a dramatically smaller JavaScript bundle. Only necessary components are included.
Astro offers various hydration strategies:
client:load: Hydrate immediately on page loadclient:idle: Hydrate when browser is idleclient:visible: Hydrate when visible (uses Intersection Observer)client:media: Hydrate when media query matchesThis granular control exists because Astro adopted Partial Hydration as a core design principle.
React Server Components (RSC) introduced in Next.js 13 App Router eliminate hydration entirely. Server components only run on the server and only send rendered HTML to the browser. No JavaScript code is sent, so there's nothing to hydrate.
// app/page.jsx (Server Component)
async function Page() {
const data = await db.query('...');
return (
<div>
<h1>{data.title}</h1>
<ClientButton /> {/* Only this button is a Client Component */}
</div>
);
}
// components/ClientButton.jsx (Client Component)
'use client';
export default function ClientButton() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
In this structure, the Page component only runs on the server. data.title is rendered on the server and delivered as HTML to the browser. Only ClientButton gets hydrated. This dramatically reduces JavaScript bundle size.
The difference between traditional SSR and RSC:
Two metrics matter for understanding hydration:
SSR's advantage is fast FCP. The server sends HTML. But TTI can be slow. You need to download the JavaScript bundle and hydrate.
CSR (Client-Side Rendering) is the opposite. Slow FCP. The HTML is nearly empty, and content only renders after JavaScript executes. But TTI is almost simultaneous with FCP. Event listeners attach during rendering.
| Approach | FCP | TTI | Characteristics |
|---|---|---|---|
| CSR | Slow | Fast | Renders after JS execution |
| SSR | Fast | Slow | Shows HTML first, waits for hydration |
| Streaming SSR | Very Fast | Progressive | Shows ready parts first |
| Islands | Fast | Very Fast | Interactive only where needed |
The ideal scenario is both FCP and TTI being fast. But reality involves tradeoffs. That's why optimization techniques like React 18's Selective Hydration and Astro's Islands Architecture emerged.
Next.js usually shows hydration errors clearly in the console:
Unhandled Runtime Error
Error: Hydration failed because the initial UI does not match what was rendered on the server.
It tells you which tag is problematic. But finding the root cause is tricky. Common patterns I've encountered:
1. Browser extensions modifying the DOMAd blockers or translation extensions manipulating HTML cause hydration mismatches. We can't control this. Check frequency with error tracking tools like Sentry, and ignore if not severe.
2. Server and client rendering different valuesCases like Math.random() or Date mentioned earlier. The solution is using useEffect to execute only on the client.
Putting a <div> inside a <p>, or putting non-<li> tags inside a <ul>. Browsers auto-correct this, making server and client HTML differ.
// ❌ Invalid HTML
<p>
<div>This is wrong</div>
</p>
// ✅ Valid HTML
<div>
<div>This is correct</div>
</div>
A debugging tip is using React DevTools' Profiler to measure hydration time. Also, Next.js includes server-rendered props in the __NEXT_DATA__ script tag. Checking this helps verify server and client data match.
Here are the error patterns I've battled for 3 years. Your culprit is likely here.
Text content did not match. Server: "2024-05-01" Client: "05/01/2024"Date.toLocaleString() locale differences between Node.js and Browser.date-fns, or render only on the client via useEffect.Expected server HTML to contain a matching <p> in <div>.<div> inside a <p>. This violates HTML spec. The browser auto-corrects it to </p><div>...</div>, altering the DOM structure.<div> inside <div>, or use semantic tags (section, article). Never put block elements inside <p>.ReferenceError: window is not definedwindow.localStorage or document at the top level of a component or in useState initial value.useEffect or check if (typeof window !== 'undefined').Math.random() or uuid() during render. Server and Client generate different numbers.useEffect on the client, or pass the value as a prop from the server.React 18's Concurrent Features revolutionized hydration performance.
Thanks to this, TBT (Total Blocking Time) dropped significantly. This is why upgrading to React 18 makes your site feel faster without changing a single line of your code.
Recently, frameworks like Qwik introduced Resumability. How is it different from Hydration?
Think of it this way:
With Resumability, the initial JS bundle size is near zero (O(1)), regardless of app complexity. This "Zero-Hydration" approach might be the future of web performance.