첫 렌더가 document, matchMedia, location.hash, localStorage, 쿠키에 의존한다면 값 하나만 어긋나도 곧바로 hydration mismatch다. 이 API들은 SSR 중에는 없고, 클라이언트의 첫 렌더에서는 이미 진짜 값이기 때문이다. 그런데도 tsc, ESLint, Node 환경 유닛 테스트는 전부 통과한다. 셋 중 어느 것도 hydration 렌더를 하지 않으니까. 정직한 해법은 둘이다. 마운트 후에 seed 하거나(간단하지만 한 프레임 깜빡인다), 값을 서버에서 읽거나(깜빡임은 없지만 쿠키 왕복이 필요하다).
증상
Admin 사이드바. 아이콘만 남는 레일로 접을 수 있고, 그 선택은 유지된다. 다른 admin 페이지로 이동하면 레일이 한 박자 펼쳐졌다가 탁 닫힌다 — 게다가 콘솔에는 hydration warning.
전부 초록불이었다. tsc --noEmit: exit 0. eslint: exit 0. 유닛 테스트 327개 통과. 이 버그는 오직 내 눈으로, 브라우저에서, 두 번째 페이지 로드에서만 보였다.
내가 가정했던 것
"클라이언트 전용 API"란 "useEffect가 돌기 전까지는 쓸 수 없다"는 뜻이라고 가정했다. 그래서 렌더 도중에 document.cookie를 그냥 읽는 게 그럭저럭 안전해 보였다. 서버는 typeof document === "undefined" 분기를 타고, 클라이언트는 진짜 분기를 타고, 그 차이는 React가 알아서 정리해 줄 거라고.
그건 아주 구체적인 방식으로 틀렸고, 바로 그 구체적인 방식이 이 글의 전부다.
실제 동작 방식
서버에서 렌더된 HTML이 도착한다. 그다음 React는 hydration 렌더를 한다 — 컴포넌트 트리를 클라이언트에서 다시 실행하고, 그 출력이 받은 HTML과 정확히 일치하기를 기대한다. 이 렌더는 진짜 브라우저에서 돈다. document가 있다. window.matchMedia가 있다. location.hash에 값이 들어 있다. localStorage를 읽을 수 있다.
그러니까 "서버 분기"라고 생각하고 쓴 코드가 실행되는 건 SSR 때 한 번뿐이다. 반드시 일치해야 하는 바로 그 첫 클라이언트 렌더에서는 진짜 값이 나온다. 진짜 값이 서버 기본값과 다르면 트리가 갈라진다. React는 경고를 띄우고 텍스트를 기워 넣거나, 서버 HTML을 버리고 가장 가까운 Suspense 경계부터 클라이언트에서 다시 렌더한다 — 경계가 없으면 루트 전체를. 눈에 보이는 깜빡임이 바로 그 교정이다.
쿠키 배너의 순진한 버전은 이렇게 생겼고, 타입 체크는 완벽하게 통과한다.
// DON'T
const [consent] = useState(() =>
typeof document === "undefined" ? "ssr" : readCookie("vs-cookie-consent"),
);
if (consent !== "none") return null;서버: "ssr" → 아무것도 렌더하지 않는다. 클라이언트 첫 렌더: "none" → 고정 배너를 렌더한다. 첫 방문마다 mismatch, 확정이다.
해법 (a): 마운트 후에 seed 하기
서버도 똑같이 만들어낼 수 있는 값에서 시작하고, effect에서 바로잡는다. levain-template/src/components/sections/auth/AccountDashboard.tsx에서 가져왔다.
// Start on "orders" so the server render and the first client render agree
// (window.location.hash is unavailable on the server). A `#favorites` (etc.)
// deep-link is honoured just after mount via the effect below, which keeps
// hydration in sync at the cost of one imperceptible tab switch.
const [tab, setTab] = useState<Tab>("orders");
useEffect(() => {
const hash = window.location.hash.replace("#", "");
const valid: Tab[] = ["profile", "orders", "addresses", "favorites"];
if ((valid as string[]).includes(hash)) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional client-only post-mount seed to keep SSR/CSR hydration in sync
setTab(hash as Tab);
}
}, []);disable 주석을 눈여겨보자. eslint-config-next 16.2.10(eslint-plugin-react-hooks 7을 끌고 온다)에서는 react-hooks/set-state-in-effect가 정확히 이 모양을 잡아낸다. 일반적인 경우라면 낭비되는 렌더이기 때문이다. 여기서는 의도한 것이라 이유를 설명하는 주석을 붙였다.
linter와 실랑이하고 싶지 않다면, useSyncExternalStore가 같은 발상을 프리미티브로 만든 것이다 — getServerSnapshot은 말 그대로 "SSR 과 hydration 렌더에서 쓸 값"이다.
// booking-template/src/lib/useIsMobile.ts
function subscribe(onChange: () => void): () => void {
const mql = window.matchMedia(MOBILE_QUERY);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
function getSnapshot(): boolean {
return window.matchMedia(MOBILE_QUERY).matches;
}
function getServerSnapshot(): boolean {
return false;
}
export function useIsMobile(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}React는 hydration 패스에서 getServerSnapshot을 쓰고, 그 직후에 getSnapshot으로 갈아탄다. 모양은 같고, lint와 싸울 일도 없고, 변경 구독까지 공짜로 딸려온다. booking-template/src/components/landing/CookieBanner.tsx의 쿠키 배너는 boolean 대신 sentinel 값을 쓴다.
function readConsent(): string {
if (typeof document === "undefined") return "ssr";
const hit = document.cookie
.split("; ")
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`));
return hit ? (hit.split("=")[1] ?? "none") : "none";
}
const getServerSnapshot = () => "ssr";"ssr"은 아무것도 렌더하지 않으므로 hydration 렌더가 서버의 것과 바이트 단위로 동일해진다. 배너는 한 tick 뒤에 나타난다. 동의 바 정도면 그래도 괜찮다.
해법 (b): 서버에서 읽기
사이드바에서는 그걸로 안 됐다 — 이동할 때마다 레일이 눈에 띄게 펼쳐졌다 접히면 고장 난 것처럼 보인다. 그래서 값을 서버가 읽을 수 있는 쿠키로 옮긴다.
// booking-template/src/lib/admin/collapsed.ts
export const ADMIN_COLLAPSED_COOKIE = "vs-admin-collapsed";
export async function getAdminCollapsed(): Promise<boolean> {
const { cookies } = await import("next/headers");
return (await cookies()).get(ADMIN_COLLAPSED_COOKIE)?.value === "1";
}admin 레이아웃이 이걸 호출해서 값을 context provider로 넘기고, 사이드바는 거기서 자기 state를 seed 한다.
// booking-template/src/components/admin/AdminSidebar.tsx
const [collapsed, setCollapsed] = useState(useInitialCollapsed());이제 SSR과 hydration이 둘 다 true를 계산한다. mismatch 없음, 깜빡임 없음. 같은 이음매로 테마도 처리한다. locale 레이아웃이 cookie jar에서 vs-theme / vs-mode를 읽어 data-theme / data-mode를 <html>에 바로 찍기 때문에, 처음 그려지는 프레임부터 이미 올바른 테마다.
트레이드오프는 실재하니 솔직하게 적어둔다. (a)는 국소적이다 — 파일 하나, 서버 변경 없음, 대신 깜빡일 수 있다. (b)는 절대 깜빡이지 않지만 값이 쿠키에 있어야 하고, 그건 곧 클라이언트 쓰기, 반영을 위한 이동이나 새로고침, 그리고 해당 라우트의 동적 렌더를 뜻한다. 깜빡임이 레이아웃을 밀어내거나 브랜드에서 눈에 띈다면 (b)를, 나머지 전부에는 (a)를 쓰자.
사람들이 잊는 세 번째 선택지가 있다. 아예 JS에서 분기하지 않는 것. landing의 Reveal은 숨김 상태를 html.has-js로 게이팅한 CSS에 두고, prefers-reduced-motion이 미디어 쿼리에서 그걸 끄게 한다 — useReducedMotion 호출이 없으니 어긋날 것도 없다.
CI가 초록불이었던 이유
CI에서 hydrate 하는 게 하나도 없기 때문이다.
booking-template/vitest.config.ts에는 environment: "node"와 include: ["tests/**/*.test.ts"]가 적혀 있다 — .ts만, .tsx는 없다. 그 327개 테스트가 돌리는 건 순수 헬퍼들이다. 예약 가능 시간 계산, 쿠폰 규칙, i18n 키 커버리지. 트리를 두 번 마운트하는 일이 없으니 hydration mismatch를 관측할 수 없다. tsc 눈에는 두 분기 모두 타입이 멀쩡하다. ESLint 눈에는 합법적인 typeof 가드다.
초록불 CI가 알려준 건 코드가 잘 형성되어 있다는 것뿐이다. 기능이 동작하는지에 대해서는 아무 말도 하지 않았다.
체크리스트
아래는 전부 SSR 중에는 undefined이거나 아예 없고, 클라이언트의 첫 렌더에서는 진짜다. 이 중 하나가 무엇을 return할지 결정한다면, 멈추고 (a)나 (b)를 골라라.
document—document.cookie포함window,window.location.search,window.location.hashlocalStorage/sessionStoragewindow.matchMedia(...)와 그 위에 얹힌 모든 것,useReducedMotion포함navigator(language,userAgent,onLine)- 렌더 출력으로 흘러 들어가는 경우의
Date.now()/new Date() Math.random()과 시드 없는 모든 id
렌더 본문과 useState 초기화 함수 안에서 이것들을 grep 해보자. 그 grep으로 내 코드에서만 하루 오후에 네 건을 찾았다.
이게 booking 템플릿에 실제로 넣어 파는 패턴이고, 라이브 데모는 booking.violettadev.com에 있다.