ReactDOMClient.hydrateRoot사용 시, hydration 오류
2023-08-10회사에서 프로젝트를 리팩토링하던 중, dangerouslySetInnerHTML을 사용한 컴포넌트에서 hydration 오류가 발생했어요. 어떻게 해결했는지 공유해보려고 해요.
Hydration이 왜 까다로운가
Hydration은 서버에서 생성된 HTML이 클라이언트로 전달된 뒤, React가 그 HTML 위에 JavaScript를 붙이는 과정이에요. 이때 중요한 전제가 하나 있어요. 서버가 만들어낸 HTML 구조와 클라이언트에서 React가 처음으로 렌더링하는 결과가 완전히 일치해야 한다는 점이에요.
왜 그래야 할까요? hydrateRoot의 동작 방식 때문이에요. React는 hydration 단계에서 DOM을 새로 그리지 않아요. 이미 서버가 만들어놓은 HTML 노드를 그대로 재사용하면서, 각 노드에 이벤트 리스너를 연결하고 fiber 트리를 구성하죠. 이 과정에서 React가 "내가 렌더링했으면 이렇게 생겨야 한다"고 계산한 결과와 실제 DOM이 다르면 경고를 발생시키고, 심한 경우 DOM을 다시 그려요. 서버 HTML을 신뢰의 근거로 삼는 구조니까, 클라이언트에서 다른 결과가 나오면 React 입장에선 무엇을 기준으로 삼아야 할지 알 수 없게 되는 거예요.
문제가 된 코드
리팩토링 대상 컴포넌트는 이런 역할을 하고 있었어요. 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가 문제였나
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가 맞아요.
흔히 마주치는 Hydration 오류 패턴들
이번 케이스는 hydrateRoot를 잘못 쓴 경우였지만, 실무에서 hydration 오류가 나는 상황은 훨씬 다양해요. 제가 직접 겪거나 팀에서 자주 마주친 것들을 정리해봤어요.
1. localStorage / sessionStorage 직접 참조
// 오류 발생 패턴
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. Date.now() / Math.random()
// 오류 발생 패턴
function Card() {
const id = Math.random(); // 서버와 클라이언트에서 값이 다름
return <div id={`card-${id}`}>...</div>;
}
서버 렌더링 시점과 클라이언트 hydration 시점에 각각 Math.random()이 실행돼서 다른 값을 만들어요. Date.now()도 같은 이유로 문제가 되고요.
해결책은 값을 서버에서 결정해서 내려보내거나, 클라이언트 전용 컴포넌트("use client")로 분리하는 거예요.
3. typeof window 분기
// 오류 발생 패턴
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이 어떤 전제 위에서 동작하는지도 더 명확해졌어요.