Hydration 이해하기

튜토리얼 — Hydration 이해하기
지금까지 useHydrated라는 훅을 가끔 만났습니다. "hydration이 끝나면 true가 된다"고만 짧게 넘어갔죠. 이번 절은 그 hydration이 정확히 무엇인지, 왜 깨질 수 있는지, 어떻게 다뤄야 하는지를 처음부터 끝까지 봅니다.
시작하기 전에 — 왜 이게 어렵게 느껴질까?
React만 알 때는 useState, useEffect, 컴포넌트만 있으면 끝이었습니다. 그런데 Next.js로 오면 "서버", "use client", "SSR", "hydration" 같은 단어들이 자꾸 등장하죠. 헷갈리는 이유는 — 컴포넌트가 한 번이 아니라 두 번 실행된다는 사실 때문입니다. 한 번은 서버에서, 한 번은 브라우저에서. 이 한 가지만 정확히 잡으면 나머지는 자연스럽게 풀립니다.
두 가지 화면 그리기 방식
옛날 모델 — Client-Side Rendering (CSR)
리액트만 단독으로 쓰던 시절의 방식입니다.
서버: "여기 빈 HTML 가져가시오" ← <div id="root"></div> 뿐 ↓ 브라우저: JS 다운로드 (수 백 KB) … ↓ 브라우저: React 실행 → 컴포넌트들 렌더링 → DOM 만들기 ↓ 사용자: 드디어 화면이 보임 ← 1~3초 걸릴 수 있음
문제 둘. ① 사용자가 빈 화면을 한참 봅니다(느린 첫 페인트). ② 검색 엔진은 보통 JS를 실행해 주지 않으므로 "내용이 없는 페이지네" 결론 — SEO에 안 좋습니다.
Next.js 모델 — SSR + Hydration
서버: React 컴포넌트를 미리 실행해서 완성된 HTML 을 만듦 ↓ 브라우저: 완성된 HTML 도착 → 즉시 화면이 보임 ✅ (빠른 첫 페인트) ↓ 브라우저: JS 다운로드 (병렬) ↓ 브라우저: React 가 컴포넌트들을 "다시" 실행 ← 이게 hydration ↓ 브라우저: HTML 노드들과 React 컴포넌트들을 연결, 이벤트 리스너 부착 ↓ 사용자: 클릭 / 입력에 반응하는 진짜 React 앱
여기서 단어 두 개가 생깁니다.
- SSR (Server-Side Rendering) — 서버가 React 를 실행해 HTML 을 만드는 단계.
- Hydration (수화) — 클라이언트가 그 HTML 위에 React 를 "다시 살려서" 인터랙티브하게 만드는 단계.
비유: HTML 은 그림입니다. 보이긴 하지만 클릭해도 반응이 없죠. Hydration 은 그 그림에 생명을 불어넣는 과정입니다. 마른 그림에 물(hydro)을 주는 거예요.
"use client"가 붙은 컴포넌트도 여전히 서버에서 한 번 렌더링됩니다. "use client"는 "이 컴포넌트는 클라이언트에서 추가로 살아 움직인다"는 뜻이지, "서버에서는 안 그린다"가 아닙니다. 이 오해를 풀어야 합니다.
그래서 hydration이 어떻게 깨지나?
문제는 — hydration이 성공하려면 클라이언트의 첫 렌더 결과가 서버 HTML과 완전히 같아야 합니다.
서버: <p>현재 시각: 14:32:08</p> ← 서버 시각으로 렌더링 클라이언트 첫 렌더: <p>현재 시각: 14:32:09</p> ← 브라우저 시각으로 렌더링 비교 → 다름!
다르면 React가 "어, 서버랑 다른데?" 라며 콘솔에 경고를 띄우고, 그 부분을 클라이언트 결과로 다시 그립니다. 사용자에겐 순간적으로 14:32:08 → 14:32:09로 점프하는 깜빡임이 보일 수 있어요.
왜 React가 같길 요구할까? 성능 때문입니다. Hydration 의 핵심은 "이미 그려진 HTML 을 재활용한다" 예요. 매번 client 결과로 덮어쓴다면 SSR 한 의미가 사라집니다. 그래서 첫 렌더는 무조건 일치해야 한다는 약속이 있습니다.
어떤 값들이 mismatch 를 일으키나?
| 종류 | 예시 | 왜 다른가 |
|---|---|---|
| 시각 | new Date(), Date.now() | 서버 시각 ≠ 클라이언트 시각 |
| 랜덤 | Math.random() | 둘이 같을 리 없음 |
| 브라우저 객체 | window.*, navigator.*, document.* | 서버엔 존재하지 않음 |
| 영속 저장소 | localStorage, sessionStorage | 서버엔 존재하지 않음 |
| 사용자 환경 | 다크 모드, 언어, 타임존 | 서버는 사용자별 설정을 모름 |
| 브라우저 확장 | Grammarly 등이 attr 추가 | 우리 코드 밖 (소수) |
마지막 한 줄(확장)을 제외하면 전부 우리가 만든 코드의 책임입니다.
세 가지 해결 패턴
대부분의 mismatch는 다음 셋 중 하나로 풉니다.
패턴 1 — 안전한 초기값 + useEffect
"처음엔 서버와 같은 안전한 값으로 시작하고, 클라이언트에서만 진짜 값을 채워라."
const [time, setTime] = useState("--:--:--"); // 서버에도 클라이언트에도 같은 값 useEffect(() => { setTime(new Date().toLocaleTimeString("ko-KR")); // 클라이언트에서만 실행됨 }, []);
서버 HTML: --:--:--. 클라이언트 첫 렌더: --:--:-- → 일치! 그 다음 useEffect가 실행되며 진짜 시각으로 갱신. 이 갱신은 hydration이 끝난 뒤라서 mismatch가 아닙니다.
언제 쓰나 — 값을 useEffect에서 한 번 계산해 채울 수 있을 때. 시계, 일회성 랜덤, 클라이언트 측 계산 등.
패턴 2 — useHydrated 플래그
"hydration이 끝났다는 신호를 받기 전엔 동적 부분을 안 그린다."
const hydrated = useHydrated(); if (!hydrated) return <Placeholder />; return <RealContent />;
useHydrated 자체는 단순합니다.
function useHydrated(): boolean { const [hydrated, setHydrated] = useState(false); useEffect(() => setHydrated(true), []); return hydrated; }
- 서버:
hydrated === false→ Placeholder - 클라이언트 첫 렌더: 같은 false → Placeholder → 일치!
- useEffect가 실행되며
setHydrated(true)→ 진짜 내용 렌더링
언제 쓰나 — Zustand persist처럼 우리가 직접 useEffect를 안 쓰는 도구가 백그라운드에서 값을 채우는 경우. 도구가 알아서 hydration 후 값을 채워 주므로 컴포넌트 안에서 useEffect를 쓸 수 없거든요. 그때는 외부 신호인 useHydrated가 필요합니다. 영속 카트 배지, 즐겨찾기 수, 로그인 상태 등에 자주 등장.
패턴 3 — next/dynamic + ssr: false
"이 컴포넌트는 서버에서 아예 렌더하지 마라."
import dynamic from "next/dynamic"; const Map = dynamic(() => import("./Map"), { ssr: false });
서버는 이 컴포넌트를 건너뜁니다. HTML 에는 빈 자리만 있다가 클라이언트에서 처음으로 렌더링됩니다. 그래서 그 안에서 window·navigator·캔버스 API를 마음 놓고 쓸 수 있어요.
언제 쓰나 — SSR 자체가 불가능한 컴포넌트. 캔버스 차트, 지도 라이브러리, window.innerWidth를 render body에서 직접 쓰는 경우, "서버에는 없는" 외부 라이브러리에 의존하는 경우 등.
패턴 3는 비용도 있습니다. 그 컴포넌트는 SSR 의 첫 페인트 혜택을 못 받아요. 그래서 정말 SSR이 불가능할 때만 — 다른 방법이 다 안 될 때 — 씁니다.
함께 해보기
네 카드를 한 페이지에 늘어놓고 비교해 봅니다.
/hydration 페이지 ├─ 🐛 BadTime — 일부러 깨뜨린 나쁜 예 ├─ ✅ GoodTime — 패턴 1 ├─ ✅ WelcomeBanner — 패턴 2 (localStorage 카운터) └─ ✅ WindowInfo — 패턴 3 (window 를 직접 만짐)
1단계 — 일부러 깨뜨려 보기 (BadTime)
components/hydration/BadTime.tsx 파일을 만듭니다.
"use client"; // 🐛 일부러 잘못 짠 예 — 절대 따라하지 마세요. // new Date() 가 서버 렌더 시각과 클라이언트 렌더 시각이 달라 mismatch 가 일어납니다. export default function BadTime() { const now = new Date().toLocaleTimeString("ko-KR"); return ( <div className="rounded border border-red-300 bg-red-50 p-3"> <p className="text-sm text-red-700">🐛 잘못된 예 — 일부러 깨뜨렸어요</p> <p data-testid="bad-time" className="text-lg"> 현재 시각: {now} </p> <p className="text-xs text-gray-600"> F12 콘솔을 열면 hydration 경고가 떠 있어요. </p> </div> ); }
new Date()를 render body에서 그대로 씁니다. 서버 시각과 클라이언트 시각이 다른 한 — 거의 항상 — mismatch가 납니다.
2단계 — 패턴 1 적용 (GoodTime)
components/hydration/GoodTime.tsx 파일을 만듭니다.
"use client"; import { useState, useEffect } from "react"; export default function GoodTime() { const [time, setTime] = useState("--:--:--"); // 서버에도 클라이언트에도 같은 값 useEffect(() => { const update = () => setTime(new Date().toLocaleTimeString("ko-KR")); update(); const id = setInterval(update, 1000); return () => clearInterval(id); }, []); return ( <div className="rounded border border-green-300 bg-green-50 p-3"> <p className="text-sm text-green-700"> ✅ 패턴 1 — useState(초기값) + useEffect </p> <p data-testid="good-time" className="text-lg"> 현재 시각: {time} </p> </div> ); }
서버 HTML: 현재 시각: --:--:--. 클라이언트 첫 렌더: 같음 → 일치. useEffect가 실행되며 진짜 시각을 채우고, 1초마다 갱신합니다. 깜빡임도 경고도 없어요.
3단계 — 패턴 2 적용 (WelcomeBanner)
components/hydration/WelcomeBanner.tsx 파일을 만듭니다. localStorage로 방문 횟수를 세서 메시지를 다르게 보여 줍니다.
"use client"; import { useState, useEffect } from "react"; import { useHydrated } from "@/hooks/useHydrated"; const VISIT_KEY = "hydration-tutorial-visits"; export default function WelcomeBanner() { const hydrated = useHydrated(); const [visits, setVisits] = useState(0); useEffect(() => { const current = Number(localStorage.getItem(VISIT_KEY) ?? "0"); const next = current + 1; localStorage.setItem(VISIT_KEY, String(next)); setVisits(next); }, []); if (!hydrated) { return ( <div className="rounded border border-blue-300 bg-blue-50 p-3"> <p className="text-sm text-blue-700">✅ 패턴 2 — useHydrated</p> <p data-testid="welcome">잠시만요…</p> </div> ); } return ( <div className="rounded border border-blue-300 bg-blue-50 p-3"> <p className="text-sm text-blue-700">✅ 패턴 2 — useHydrated</p> <p data-testid="welcome"> {visits === 1 ? "안녕하세요! 처음 오셨네요 🎉" : `다시 오셨네요! (${visits}번째 방문) 👋`} </p> </div> ); }
서버: 잠시만요…. 클라이언트 첫 렌더: 같음 → 일치. useEffect가 실행되며 localStorage를 읽어 진짜 메시지로. 새로고침마다 카운트가 올라갑니다.
사실 이 예제는 패턴 1만 써도 풉니다 (visits === 0 이 placeholder 신호 역할을 함). 그래도
useHydrated로 명시하면 "hydration이 끝났다" 는 의도가 코드에 드러나요. Zustand persist 같은 값으로는 신호를 못 만드는 경우엔 패턴 2가 유일한 답입니다.
4단계 — 패턴 3 적용 (WindowInfo)
components/hydration/WindowInfo.tsx — window를 render body에서 직접 만지는 컴포넌트.
"use client"; // 이 컴포넌트는 window 를 render body 에서 직접 만진다. // SSR 환경엔 window 가 없어 폭발한다 — 그래서 ssr: false 로 import 해야 한다. export default function WindowInfo() { const width = window.innerWidth; const height = window.innerHeight; const language = navigator.language; return ( <div className="rounded border border-purple-300 bg-purple-50 p-3"> <p className="text-sm text-purple-700"> ✅ 패턴 3 — next/dynamic + ssr: false </p> <p data-testid="window-info" className="text-lg"> 창 크기: {width} × {height} </p> <p className="text-sm text-gray-600">언어: {language}</p> </div> ); }
그리고 next/dynamic으로 SSR을 끄고 가져오는 래퍼.
components/hydration/WindowInfoLoader.tsx:
"use client"; import dynamic from "next/dynamic"; const WindowInfo = dynamic(() => import("./WindowInfo"), { ssr: false, loading: () => <p>창 정보 불러오는 중…</p>, }); export default function WindowInfoLoader() { return <WindowInfo />; }
이 한 줄({ ssr: false })이 WindowInfo를 서버 렌더링에서 제외합니다. 서버 HTML엔 loading placeholder만 들어가고, 클라이언트에서 처음으로 진짜 컴포넌트가 렌더됩니다. 안에서 window를 마음껏 써도 폭발하지 않아요.
5단계 — 페이지 조립
app/hydration/page.tsx:
import BadTime from "@/components/hydration/BadTime"; import GoodTime from "@/components/hydration/GoodTime"; import WelcomeBanner from "@/components/hydration/WelcomeBanner"; import WindowInfoLoader from "@/components/hydration/WindowInfoLoader"; export default function HydrationPage() { return ( <main className="mx-auto max-w-2xl space-y-3 p-6"> <h1 className="text-2xl font-bold">Hydration 이해하기</h1> <p className="text-sm text-gray-600"> 네 카드 — 첫째는 일부러 깨뜨린 나쁜 예, 나머지 셋은 각각 다른 패턴의 해결책. </p> <BadTime /> <GoodTime /> <WelcomeBanner /> <WindowInfoLoader /> </main> ); }
확인 ✅ — /hydration 을 열고 콘솔(F12)을 봅니다.
- 🐛 BadTime — 시각이 보이지만 콘솔에 hydration 경고가 떠 있습니다. 가끔 첫 렌더에서 시각이 1초 점프하는 깜빡임도 보입니다.
- ✅ GoodTime —
--:--:--가 잠깐 보였다가 진짜 시각으로 바뀌고, 1초마다 갱신됩니다. 경고 없음. - ✅ WelcomeBanner — 새로고침할 때마다 방문 카운트가 올라갑니다. 경고 없음.
- ✅ WindowInfo — 짧게 "창 정보 불러오는 중…" 이 뜬 뒤 실제 창 크기가 표시됩니다. 경고 없음.
어떤 패턴을 언제 — 결정 표
| 상황 | 추천 패턴 |
|---|---|
| 시각, 랜덤, 일회성 계산값 | 패턴 1 — useState(초기값) + useEffect |
| 영속 데이터 (localStorage·Zustand persist) | 패턴 2 — useHydrated |
window·navigator·캔버스·외부 클라이언트 전용 라이브러리 | 패턴 3 — next/dynamic + ssr: false |
| 정말 어쩔 수 없는 한 줄짜리 시각 표시 | suppressHydrationWarning (다음 단락) |
suppressHydrationWarning — 정말 마지막 수단
React에는 "이 요소의 자식이 mismatch 나도 경고하지 마라" 라는 한 줄 옵션이 있습니다.
<time suppressHydrationWarning>{new Date().toLocaleTimeString()}</time>
표면적으로는 경고를 없애 줍니다. 그런데 — mismatch 자체를 막아 주는 게 아니라 경고만 끄는 거예요. 화면은 여전히 한 번 점프할 수 있고, 그 자리의 의미 있는 SEO 효과도 사라집니다.
그러므로 — 자식이 정말 "한 줄짜리 시각 표시" 같은 정말 사소한 경우, 그리고 다른 패턴으로 풀기 어려운 경우에만 마지막 수단으로 씁니다. 평소엔 패턴 1·2·3로 풉니다.
정리
- 컴포넌트는 두 번 실행됩니다 — 서버에서 한 번(SSR), 클라이언트에서 한 번(hydration).
- Hydration 의 약속: 클라이언트의 첫 렌더 결과가 서버 HTML 과 같아야 한다.
- 환경마다 달라지는 값(시각·랜덤·
window·localStorage)은 그 약속을 깬다 → mismatch. - 해결책 셋 — 안전한 초기값 + useEffect /
useHydrated플래그 /next/dynamicssr: false.
세 패턴의 공통 원리: "서버 HTML과 같은 모습을 일단 보여 주고, hydration이 끝난 뒤에 진짜 값으로 바꾼다." 이 한 줄을 손에 익히면 hydration 으로 더는 다칠 일이 없어요.
연습 거리
- 랜덤 인사말 배너 —
Math.random()으로 인사말 5개 중 하나를 골라 표시. 먼저 일부러 render body에서 random을 부르고 mismatch를 확인, 그 다음 패턴 1로 고치기. - 다크 모드 감지 배지 —
window.matchMedia("(prefers-color-scheme: dark)")결과를 표시. 패턴 1 또는 패턴 2 중 어느 게 더 자연스러운지 시도해 보고 비교. - Zustand persist 시나리오 — 작은 카운터 store에 persist를 입힌 뒤,
useHydrated없이 그 값을 화면에 그려 보세요. 새로고침할 때 깜빡임이 어떻게 나는지 직접 보고, 패턴 2를 입혀 깨끗해지는 걸 확인. suppressHydrationWarning실험 — BadTime 의<p>에suppressHydrationWarning을 한 번 붙여 보고, 콘솔 경고가 어떻게 바뀌는지(또는 안 바뀌는지) 관찰.

댓글
댓글을 작성하려면 이 필요합니다.