React 하이드레이션 에러, 겪은 원인 5가지 정리했다
무엇이 문제였나
블로그를 다시 지으면서 모든 페이지를 빌드 때 HTML로 미리 그려 두게 바꿨다. 브라우저는 이 HTML을 먼저 보여 주고, JS가 도착하면 React가 hydrateRoot로 이미 있는 화면을 이어받는다.
이어받으려면 조건이 하나 있다. 브라우저에서 처음 그린 결과가 미리 그린 HTML과 똑같아야 한다. 조금이라도 다르면 React는 콘솔에 하이드레이션 불일치(hydration mismatch) 경고를 띄우고, 심하면 그 부분을 통째로 다시 그린다. 화면이 한 번 깜빡이고, 미리 그려 둔 의미가 줄어든다.
미리 그리기를 붙이고 나서 이 경고를 다섯 가지 경로로 만났다.
1. ?page=2로 들어오면 목록이 다르다
증상: /blog?page=2 주소로 바로 들어오면 경고가 뜨고 목록이 깜빡였다.
원인: 정적 호스팅은 쿼리 문자열을 보지 않는다. /blog?page=2로 들어와도 서버는 /blog용으로 미리 그린 HTML, 즉 1쪽 목록을 준다. 그런데 브라우저의 React는 주소를 읽고 2쪽 목록을 그린다. 처음부터 다를 수밖에 없다.
해결: 주소에 쿼리가 붙어 있으면 이어받기를 포기하고 새로 그린다. 검색(?q=), 태그(?tag=)도 같은 이유로 여기에 걸린다.
// src/index.jsx
const root = document.getElementById("root");
if (root.hasChildNodes() && !window.location.search) {
hydrateRoot(root, app);
} else {
root.textContent = "";
createRoot(root).render(app);
}쿼리가 붙은 주소는 대부분 사이트 안에서 이동하며 생기므로, 첫 진입만 새로 그려도 체감 차이는 거의 없다.
2. 다크 모드 버튼의 아이콘이 다르다
증상: 다크 모드를 켜 둔 상태로 새로고침하면 테마 버튼에서 경고가 났다.
원인: 버튼 컴포넌트가 처음 그릴 때 localStorage를 읽어 해 아이콘을 그릴지 달 아이콘을 그릴지 정했다. 빌드 환경에는 localStorage가 없으니 미리 그린 HTML은 늘 "라이트"였고, 브라우저는 "다크"로 그렸다.
해결: 처음 그리는 값은 서버와 똑같이 고정하고, 실제 값은 화면이 붙은 뒤(useEffect)에 맞춘다.
const [dark, setDark] = useState(false); // 서버와 같은 값으로 시작
useEffect(() => {
setDark(document.documentElement.classList.contains("dark"));
}, []);페이지 색 자체는 <head>의 작은 인라인 스크립트가 JS 번들보다 먼저 dark 클래스를 붙여 두기 때문에, 화면 전체가 깜빡이지는 않는다. 바뀌는 건 버튼 아이콘 하나뿐이다.
3. 공유 버튼이 있다가 없다
증상: 휴대폰에서 글 상세를 열면 공유 버튼 영역에서 경고가 났다.
원인: "시스템 공유" 버튼은 navigator.share가 있을 때만 보여 주는데, 이 검사를 그리는 도중에 했다. 빌드 환경에는 navigator가 없다.
해결: 다크 모드와 같은 방식이다. canShare를 false로 시작하고 useEffect에서 켠다. 공유할 주소도 그릴 때가 아니라 누를 때 window.location에서 읽도록 바꿨다.
브라우저에만 있는 값(
window,localStorage,navigator, 화면 크기)을 그리는 도중에 읽으면 하이드레이션이 어긋난다. 이런 값은useEffect뒤로 미루는 게 원칙이다.
4. 날짜가 하루 다르다
이번 사례 중 가장 찾기 어려웠다.
증상: 일부 글에서만, 날짜 부분에서 경고가 났다.
원인: 날짜를 new Date(value)로 만든 뒤 getDate()로 꺼내고 있었다. getDate()는 실행하는 곳의 시간대 기준이다.
- 빌드 서버는 UTC다.
2026-06-10T08:30:00+09:00은 UTC로6월 9일 23:30이라2026.06.09가 된다. - 한국의 브라우저는 KST 기준이라
2026.06.10이 된다.
그래서 한국 시간으로 오전 9시 전에 쓴 글만 날짜가 하루 어긋났다.
해결: 날짜를 시간대로 해석하지 않는다. 머리말에 적힌 YYYY-MM-DD를 문자열 그대로 꺼낸다. 어디서 실행해도 결과가 같다.
export function formatDate(value) {
const m = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) return `${m[1]}.${m[2]}.${m[3]}`;
// 형식이 다를 때만 Date 로 해석
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`;
}5. 목차 링크로 들어오면 맨 위로 튄다
엄밀히 말하면 하이드레이션 불일치는 아니다. 하지만 미리 그리기를 붙이자마자 드러난 문제라 같이 적는다.
증상: /blog/tech/글#소제목처럼 해시가 붙은 주소로 들어오면, 잠깐 소제목 위치로 갔다가 맨 위로 튀어 올라갔다.
원인: 예전에는 본문을 JS가 나중에 불러왔기 때문에, 브라우저가 해시 위치를 찾을 때 해당 소제목이 아직 없었다. 그래서 글 상세는 "페이지에 들어오면 맨 위로" 스크롤하는 useEffect만 가지고 있었다. 그런데 이제는 본문이 HTML에 이미 있어서 브라우저가 소제목으로 먼저 스크롤한다. 그 뒤에 scrollTo(0, 0)이 실행되면서 위치가 지워졌다.
해결: 주소에 해시가 있으면 맨 위로 올리지 않는다.
useEffect(() => {
if (!window.location.hash) window.scrollTo(0, 0);
}, [post?.markdownPath]);덤: 스타일 없는 화면이 번쩍인다
Vite는 CSS를 페이지 청크별로 나눈다. 미리 그린 HTML은 JS보다 먼저 보이는데, 그 페이지의 CSS는 해당 JS 청크가 올 때 붙는다. 그래서 짧게 스타일 없는 본문이 보였다(FOUC). 블로그 CSS는 크지 않아서 build.cssCodeSplit: false로 한 파일에 합쳐 해결했다.
정리
| 증상 | 원인 | 해결 |
|---|---|---|
?page=2 목록 불일치 | 정적 HTML은 쿼리를 모름 | 쿼리가 있으면 새로 그림 |
| 테마 아이콘 불일치 | 그리는 중 localStorage 읽음 | 고정값으로 시작, useEffect에서 맞춤 |
| 공유 버튼 불일치 | 그리는 중 navigator 읽음 | 같은 방식, 주소는 클릭 때 읽음 |
| 날짜 하루 차이 | 빌드(UTC)와 브라우저(KST)의 시간대 차이 | 문자열에서 날짜를 그대로 꺼냄 |
| 해시 링크가 튐 | 본문이 먼저 있어 스크롤 순서가 바뀜 | 해시가 있으면 맨 위로 안 올림 |
다섯 가지 모두 원인은 하나로 모인다. "처음 그릴 때 서버와 브라우저가 다른 값을 보고 있었다." 미리 그리기를 붙인다면 컴포넌트마다 이 질문을 던져 보면 된다.
이 값은 빌드 서버에서도 똑같이 나오는가?
아니라면 그 값은 useEffect 뒤로 보내거나, 어디서나 같은 결과가 나오게 바꾸면 된다.