2026.03.26 · 8분 읽기
Hydration Mismatch는 왜 생기는가: 비결정적 렌더링의 구조
Next.js에서 자주 보이는 Hydration 에러의 실제 원인을 Zustand persist, 조건부 렌더링, i18n 사례로 분석합니다.
이 에러는 왜 항상 애매할까?
Next.js를 쓰다 보면 한 번쯤은 마주친다.
Warning: Text content does not match server-rendered HTML.
Error: Hydration failed because the initial UI does not match what was rendered on the server.메시지 자체는 단순한데, 막상 원인을 찾으려고 하면 막막하다. Zustand persist 때문인지, 조건부 렌더링 문제인지, i18n이 범인인지, 아니면 그냥 Next.js 버그인지.
이 글은 "suppressHydrationWarning 추가하면 됩니다" 같은 얘기를 하려는 게 아니다. Hydration이 실제로 어떻게 동작하는지, 왜 저 에러가 터지는지를 구조적으로 짚어본다.
결론부터
Hydration mismatch는 React의 버그가 아니다. 서버와 클라이언트가 서로 다른 입력을 받았기 때문에 생기는 일이다.
Hydration이 뭔지부터 짚고 가자
내가 오해하는 부분
Hydration을 클라이언트에서 이벤트를 붙여서 다시 렌더링하는 것으로 이해하기 쉽다. 하지만 이건 잘못된 이해다.
Hydration의 정확한 정의
서버가 만들어 놓은 HTML에 React의 상태와 이벤트 시스템을 붙이는 과정
SSR을 쓰면 이런 순서로 진행된다.
- 서버가 HTML을 생성한다.
- 브라우저가 그 HTML을 먼저 화면에 그린다.
- React가 JS를 로드하면서 DOM을 처음부터 새로 만드는 게 아니라,
- 이미 있는 DOM을 그대로 재사용하려고 시도한다.
여기서 "재사용하려고 시도한다"는 부분이 핵심이다.
React가 내부적으로 하는 일
Hydration 과정에서 React는 이렇게 움직인다.
- 서버에서 내려온 HTML DOM을 읽는다.
- 클라이언트에서 컴포넌트를 한 번 실행해서 Virtual DOM을 만든다.
- 둘을 비교한다.
- 같으면 그냥 DOM을 재사용한다.
- 다르면 해당 subtree를 버리고 새로 마운트한다.
여기에 암묵적인 전제가 있다.
클라이언트의 첫 렌더 결과는 서버 렌더 결과와 무조건 같아야 한다.
이 전제가 깨지는 순간 Hydration mismatch가 발생한다.
"서버"가 구체적으로 어떤 환경인지 알아야 한다
Next.js App Router 기준으로, 서버란 브라우저 없이 React 컴포넌트를 실행해서 HTML을 만들어내는 환경이다. Node.js든 Edge Runtime이든 Vercel 서버든, 공통점은 하나다.
| API | 서버 | 클라이언트 |
|---|---|---|
| window | ❌ | ✅ |
| localStorage | ❌ | ✅ |
| navigator | ❌ | ✅ |
| Accept-Language 헤더 | ✅ | ❌ |
당연한 얘기처럼 보이지만, 실제로 어떤 게 없는지 까먹고 코드를 작성하는 경우가 많다.
문제의 핵심: 결정성
SSR 환경에서 가장 중요한 원칙을 하나만 고르라면 이거다.
같은 입력 → 같은 출력
Hydration은 서버와 클라이언트가 동일한 결과를 낸다고 가정한다. 그런데 현실적으로 아래 값들은 서버와 클라이언트에서 완전히 다르다.
| 항목 | 서버 | 클라이언트 |
|---|---|---|
| localStorage | ❌ | ✅ |
| navigator.language | ❌ | ✅ |
| Date.now() | 실행 시점 다름 | 실행 시점 다름 |
| Accept-Language 헤더 | ✅ | ❌ |
입력이 다르면 출력이 달라진다. 출력이 달라지면 Hydration이 깨진다. 이게 전부다.
Zustand persist + 조건부 렌더링이 왜 문제인가
문제 예시 코드
'use client';
const authed = useAuthStore((s) => s.authed);
return authed ? <App /> : <Login />;| 단계 | 상태 | 결과 |
|---|---|---|
| 서버 | localStorage 없음 → authed = false | <Login /> HTML 생성 |
| 클라이언트 첫 렌더 | persist가 authed = true 복원 | <App /> 렌더 |
서버는 <Login />을 만들었는데, 클라이언트 첫 렌더는 <App />이다. DOM 구조가 완전히 다르다. 당연히 mismatch가 터진다.
조건부 렌더링이 특히 위험한 이유
텍스트 수준의 차이라면 피해가 작다.
<div>{authed ? 'A' : 'B'}</div>그런데 컴포넌트 트리 자체가 달라지면 얘기가 다르다.
return authed ? <App /> : <Login />;React는 DOM을 재사용하려고 하지만, 컴포넌트 타입이 다르면 subtree를 통째로 버리고 새로 마운트한다. 그 순간 SSR로 얻을 수 있는 이점이 사라지고, Layout shift가 생길 수도 있다.
Layout shift: 이미 화면에 그려진 요소들의 위치가 갑자기 이동하는 현상
i18n이 섞이면 더 자주 터지는 이유
i18n 또한 자주 입력 불일치 문제를 일으킨다.
서버에서 언어를 결정하는 방법: 보통 Accept-Language 헤더를 본다.
클라이언트에서 언어를 결정하는 방법: navigator.language, localStorage, 사용자 설정값 등을 본다.
예를 들어 서버는 ko로 렌더링했는데 클라이언트가 en으로 초기화된다면:
<h1>{t('welcome')}</h1>
// 서버 결과: 환영합니다
// 클라이언트 첫 렌더: Welcome텍스트 mismatch 발생.
URL 없는 locale 전략의 함정
/ko, /en처럼 URL에 locale을 박아넣는 방식이 아니라, 서버는 헤더를 보고 클라는 localStorage를 보는 구조라면 결정 경로가 달라서 거의 항상 충돌한다.
SSR 환경에서 언어는 "상태"가 아니라 "입력"으로 취급해야 한다. 그 입력이 서버와 클라이언트에서 일치하지 않으면 Hydration은 반드시 깨진다.
App Router에서 유독 자주 보이는 이유
Pages Router는 CSR처럼 동작하는 케이스가 많았다. App Router는 다르다. 서버 컴포넌트가 기본이고, 서버에서 HTML이 먼저 확정된다.
SSR이 기본값이 되다 보니, 서버와 클라이언트 환경 차이가 이전보다 훨씬 직접적으로 드러난다. 예전엔 그냥 넘어가던 코드가 App Router에서는 경고를 뱉는 이유가 여기 있다.
그렇다고 'use client'를 남발하는 건 해결책이 아니다. 서버 컴포넌트의 이점을 포기하는 것이기 때문이다.
해결 방향
- 첫 렌더는 결정적이어야 한다. 무조건 같은 입력, 무조건 같은 출력.
- 브라우저 전용 API를 첫 렌더 입력으로 쓰지 않는다. localStorage, navigator, window는 hydration 이후에 사용해야 한다.
- persist 상태로 초기 분기하지 않는다. hydrated 플래그가 true가 된 다음에 분기해야 한다.
- 서버와 클라이언트의 입력을 통일한다. 쿠키를 쓰거나, URL 기반 locale을 사용하면 양쪽이 같은 값을 볼 수 있다.
결국 서버 환경에서 어떤 API를 쓸 수 없는지 먼저 파악하는 것이 출발점이다.
결론
Hydration mismatch는 React 버그 또는 직접적인 코드의 문제가 아니라 구조적인 문제이다.
서버와 클라이언트가 서로 다른 입력을 받아서 렌더링했기 때문에 생기는 구조적인 결과다.
저 에러가 보이면 "어떤 라이브러리를 잘못 썼나"보다 "서버와 클라이언트의 입력이 어디서 갈라지고 있나"를 먼저 보는 게 맞다.
Zustand, 조건부 렌더링, i18n은 증상이다. 원인은 항상 하나다.
비결정적 렌더링.
상태 관리를 어떻게 할지보다, 서버와 클라이언트가 같은 입력을 받도록 설계하는 게 먼저다.
자주 묻는 질문 (FAQ)
suppressHydrationWarning을 추가하면 해결되나요?
경고는 억제되지만 근본 원인은 그대로다. subtree가 여전히 다시 마운트될 수 있고, SSR의 이점도 사라진다. 임시방편이지 해결책이 아니다.
useEffect에서 localStorage를 읽으면 괜찮나요?
괜찮다. useEffect는 hydration이 완료된 이후에 실행되기 때문에 첫 렌더 결과에 영향을 주지 않는다. 다만 그 값으로 UI를 분기할 때는 hydrated 플래그를 함께 사용해야 한다.
Zustand persist를 쓰면서 SSR 분기를 안전하게 하려면?
persist의 onRehydrateStorage 또는 별도 hydrated 상태를 추가해서, 클라이언트 복원이 완료된 뒤에 분기 로직을 실행하면 된다. 첫 렌더는 항상 서버와 동일한 초기값으로 유지해야 한다.
'use client'를 붙이면 hydration mismatch가 없어지나요?
아니다. 'use client' 컴포넌트도 서버에서 초기 HTML을 만들기 때문에 동일한 문제가 생긴다. 클라이언트 전용으로 만들려면 dynamic import에 ssr: false 옵션을 써야 한다.