ReactDOMClient.hydrateRoot사용 시, hydration 오류
회사에서 프로젝트를 리팩토링하던 중이었습니다. 화면은 잘 나오는데 콘솔에는 hydration 오류가 떠 있었습니다. dangerouslySetInnerHTML을 쓴 컴포넌트였습니다.
"렌더링은 멀쩡히 되는데 왜 오류가 나지?"
한동안 그 질문에 붙들려 있다가, 결국 문제가 코드 몇 줄이 아니라 제가 API를 고른 기준에 있었다는 걸 알게 됐습니다. 어떻게 해결했는지 공유해보려고 합니다.
React는 서버가 그린 HTML을 믿고 시작한다
Hydration은 서버에서 생성된 HTML이 클라이언트로 전달된 뒤, React가 그 HTML 위에 JavaScript를 붙이는 과정입니다. 이때 중요한 전제가 하나 있습니다. 서버가 만들어낸 HTML 구조와 클라이언트에서 React가 처음으로 렌더링하는 결과가 완전히 일치해야 한다는 점입니다.
왜 그래야 할까요? hydrateRoot의 동작 방식 때문입니다. React는 hydration 단계에서 DOM을 새로 그리지 않습니다. 이미 서버가 만들어놓은 HTML 노드를 그대로 재사용하면서, 각 노드에 이벤트 리스너를 연결하고 fiber 트리를 구성하죠. 이 과정에서 React가 "내가 렌더링했으면 이렇게 생겨야 한다"고 계산한 결과와 실제 DOM이 다르면 경고를 발생시키고, 심한 경우 DOM을 다시 그립니다. 서버 HTML을 신뢰의 근거로 삼는 구조니까, 클라이언트에서 다른 결과가 나오면 React 입장에선 무엇을 기준으로 삼아야 할지 알 수 없게 되는 겁니다.
범인은 useEffect 안에 있었다
리팩토링 대상 컴포넌트는 이런 역할을 하고 있었습니다. Admin에서 HTML 문자열을 전달받고, 그 HTML 안에서 dataset.type이 특정 값으로 설정된 태그를 찾아 CSS를 클라이언트에서 수정한 뒤, dangerouslySetInnerHTML로 렌더링하는 구조였죠.
문제가 된 코드는 이 과정에서 ReactDOMClient.hydrateRoot를 쓰고 있었습니다.
useEffect(() => {
const target: NodeListOf<HTMLElement> =
document.querySelectorAll(".target_class_name");
target.forEach((dom) => {
if (dom.dataset.type === "특정타입") {
ReactDOMClient.hydrateRoot(dom, <CSSModifyComponent />);
}
});
}, []);
hydrateRoot는 새 HTML을 만들어주지 않는다
React 공식 문서는 hydrateRoot를 이렇게 설명합니다.
createRoot()와 동일하지만, HTML 컨텐츠가 ReactDOMServer로 렌더링된 컨테이너를 hydrate 할 때 사용합니다. React는 기존 마크업에 이벤트 리스너를 연결하려 시도할 것입니다.
핵심은 "이미 서버에서 만들어진 HTML에 이벤트 리스너를 연결한다"는 점입니다. hydrateRoot는 새로운 HTML을 생성하는 API가 아닙니다. 서버가 렌더링한 결과물이 이미 DOM에 있다고 가정하고, 그 위에 React를 올리는 역할입니다.
그런데 저희 상황에서는 Admin에서 받은 HTML을 클라이언트에서 CSS까지 수정해서 새로운 결과물을 만들어야 했습니다. 서버가 렌더링한 HTML이 아니라, 클라이언트가 처음부터 새로 렌더링해야 하는 상황이었던 거죠.
hydrateRoot에게 "여기 서버가 만든 HTML이 있어, 이벤트만 붙여줘"라고 말하면서 실제로는 전혀 다른 결과물을 넘겨주고 있었습니다. 오류가 날 수밖에 없었습니다.
createRoot로 바꾸자 조용해졌다
해결은 ReactDOMClient.createRoot로 바꾸는 거였습니다. createRoot는 지정한 DOM 노드를 React가 완전히 관리하는 루트로 만들고, 거기서부터 새롭게 렌더링을 시작합니다. 기존 HTML에 의존하지 않으니까요.
useEffect(() => {
const target: NodeListOf<HTMLElement> =
document.querySelectorAll(".target_class_name");
target.forEach((dom) => {
if (dom.dataset.type === "특정타입") {
const targetDom = ReactDOMClient.createRoot(dom);
targetDom.render(<CSSModifyComponent />);
}
});
}, []);
| 메서드 | 사용 시점 | 동작 방식 |
|---|---|---|
| hydrateRoot | 서버에서 렌더링된 HTML이 이미 있을 때 | 기존 HTML에 이벤트 리스너만 연결 |
| createRoot | 클라이언트에서 새로운 HTML을 만들 때 | 새로운 DOM을 생성하고 렌더링 |
두 API는 이름이 비슷해서 혼동하기 쉽지만, 전제하는 상황이 다릅니다. "서버가 이미 그려놓은 HTML이 있는가"를 기준으로 선택하면 됩니다. 이번 케이스처럼 클라이언트에서 처음부터 새로 렌더링해야 한다면 createRoot가 맞습니다.
같은 오류가 다른 얼굴로 찾아온다
이번 케이스는 hydrateRoot를 잘못 쓴 경우였지만, 실무에서 hydration 오류가 나는 상황은 훨씬 다양합니다. 제가 직접 겪거나 팀에서 자주 마주친 것들을 정리해봤습니다.
1. 서버에는 localStorage가 없다
// 오류 발생 패턴
function Header() {
const theme = localStorage.getItem('theme') ?? 'light'; // 서버에는 localStorage 없음
return <div className={theme}>...</div>;
}
서버 환경에는 localStorage가 존재하지 않습니다. 서버에서는 undefined 또는 에러가 발생하고, 클라이언트에서는 값이 있으니 결과가 달라지죠.
// 해결: useEffect로 클라이언트에서만 실행
function Header() {
const [theme, setTheme] = useState('light');
useEffect(() => {
setTheme(localStorage.getItem('theme') ?? 'light');
}, []);
return <div className={theme}>...</div>;
}
2. 서버와 클라이언트가 각자 주사위를 굴린다
// 오류 발생 패턴
function Card() {
const id = Math.random(); // 서버와 클라이언트에서 값이 다름
return <div id={`card-${id}`}>...</div>;
}
서버 렌더링 시점과 클라이언트 hydration 시점에 각각 Math.random()이 실행돼서 다른 값을 만듭니다. Date.now()도 같은 이유로 문제가 되고요.
해결책은 값을 서버에서 결정해서 내려보내거나, 클라이언트 전용 컴포넌트("use client")로 분리하는 겁니다.
3. 서버는 언제나 데스크탑이라고 대답한다
// 오류 발생 패턴
function Banner() {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return isMobile ? <MobileBanner /> : <DesktopBanner />;
}
서버에서는 typeof window === 'undefined'라 isMobile이 false가 되고, 클라이언트에서는 실제 viewport에 따라 달라집니다. 모바일 기기에서 접근하면 서버는 DesktopBanner를, 클라이언트는 MobileBanner를 그려 hydration이 깨지는 거죠.
// 해결: 초기값을 서버 결과와 일치시키기
function Banner() {
const [isMobile, setIsMobile] = useState(false); // 서버와 동일하게 false로 시작
useEffect(() => {
setIsMobile(window.innerWidth < 768);
}, []);
return isMobile ? <MobileBanner /> : <DesktopBanner />;
}
4. 내가 제어할 수 없는 손이 DOM을 건드린다
이건 개발자가 제어할 수 없는 영역입니다. 광고 차단기나 번역 익스텐션이 서버 HTML을 수정한 뒤 hydration이 일어나면 React는 자신이 예상한 DOM과 다른 상황을 만나게 됩니다. 실제로 Grammarly 확장 프로그램이 <body> 태그에 속성을 추가해서 hydration 경고가 쏟아지는 경우도 흔합니다.
이런 경우엔 suppressHydrationWarning이 유용합니다.
<body suppressHydrationWarning={true}>
{children}
</body>
이 prop은 해당 요소와 직계 자식에 한해서만 hydration 불일치 경고를 억제합니다. 서버와 클라이언트의 불일치를 "해결"하는 게 아니라 경고를 무시하는 거니까, 실제로 내용이 다를 수 있는 상황에서는 써서는 안 됩니다. 외부 요인으로 인한 DOM 조작처럼 어쩔 수 없는 경우에만 쓰는 게 맞습니다.
마치며
hydration 오류는 처음엔 추상적으로 느껴지지만, 결국 "서버가 그린 HTML"과 "클라이언트 React가 그리려는 결과"가 다를 때 생기는 문제입니다. 오류 메시지를 보면 어떤 노드가 불일치하는지 알 수 있고, 거기서 거꾸로 "왜 서버와 클라이언트 결과가 달라졌나"를 찾아가면 됩니다.
이번에 hydrateRoot와 createRoot의 차이를 제대로 이해하게 됐고, 덕분에 hydration이 어떤 전제 위에서 동작하는지도 더 명확해졌습니다. 비슷한 경고를 만난다면 코드를 고치기 전에 "지금 이 DOM은 누가 그린 것인가"부터 물어보면 좋겠습니다. 답이 정해지면 고를 API도 자연히 정해질지도 모르겠습니다.