Next.js Hydration Mismatch, 실무에서 반복해서 만난 3가지 케이스
Hydration Mismatch란
Next.js는 서버에서 먼저 HTML을 렌더링해 응답하고, 브라우저에서 같은 컴포넌트 트리를 다시 렌더링해 이벤트 리스너를 붙이는 과정(hydration)을 거친다.
이때 서버가 만든 HTML과 클라이언트가 만든 HTML이 조금이라도 다르면 React가 'Hydration failed / Text content does not match' 같은 경고를 던진다. 원인은 대부분 '서버와 클라이언트가 같은 입력으로 다른 결과를 계산하는 코드'다.
케이스 1 — 배열 인덱스로 계산한 우선순위 값
이미지 리스트를 렌더링하면서 배열의 index를 기준으로 loading priority(우선 로드 여부)를 계산한 적이 있다. 문제는 서버 렌더링 시점의 배열 순서와 클라이언트에서 필터링·정렬이 끝난 뒤의 배열 순서가 달라질 수 있다는 것이었다.
같은 인덱스(0번)라도 서버에서는 A 이미지, 클라이언트에서는 B 이미지가 그 자리를 차지하면서 priority 속성값이 실제 렌더링 결과와 어긋났고, 이게 hydration mismatch로 이어졌다.
해결은 단순했다 — index에 의존하는 우선순위 계산을 걷어내고, 데이터 자체에 있는 고정된 식별자를 기준으로 우선순위를 판단하도록 바꿨다. 서버와 클라이언트가 같은 입력에 대해 항상 같은 결과를 내도록 만드는 것이 핵심이다.
케이스 2 — Suspense/use()에서 useEffect 기반으로 전환
React의 use() 훅으로 비동기 데이터를 컴포넌트 렌더링 중에 바로 읽어오는 방식을 시도한 적이 있다. 서버에서는 데이터가 준비된 상태로 렌더링되지만, 클라이언트에서 같은 컴포넌트가 다시 실행될 때 타이밍이 어긋나면서 렌더링 결과가 달라지는 경우가 있었다.
특히 요청마다 상태가 달라질 수 있는 데이터(사용자별 상태, 실시간성 데이터)를 다룰 때 이 문제가 두드러졌다. 결국 해당 데이터는 서버 렌더링에서 완전히 제외하고, 클라이언트에서 useEffect로 마운트 이후에만 가져오도록 바꿔서 서버/클라이언트 렌더링 결과 자체를 분리했다.
정적으로 결정되는 데이터가 아니라면, 무리해서 서버에서까지 렌더링하려 하지 말고 클라이언트 전용으로 명확히 분리하는 편이 디버깅 비용을 줄인다.
케이스 3 — 서드파티 위젯이 DOM을 직접 건드릴 때
로그인 폼에 Cloudflare Turnstile(봇 방지) 위젯을 붙이면서도 비슷한 문제를 겪었다. Turnstile 스크립트는 자신이 지정된 컨테이너 안에 iframe을 직접 주입하는데, React가 같은 영역을 재조정(reconciliation)하려는 시점과 겹치면 렌더링 타이밍이 꼬였다.
원인은 'React가 관리하는 DOM 영역을 외부 스크립트가 직접 조작'하는 데 있었다. 위젯 렌더링을 React 컴포넌트 생명주기와 명시적으로 동기화하고(마운트 이후에만 스크립트를 초기화), 위젯이 차지하는 영역을 React가 다시 그리지 않도록 분리한 뒤 안정됐다.
공통적으로 점검하는 체크리스트
세 케이스 모두 원인은 달랐지만 점검 방법은 비슷했다. 서버와 클라이언트 렌더링 결과가 달라질 수 있는 지점을 하나씩 지운다는 생각으로 다음을 확인한다.
① 배열 순서·인덱스에 의존하는 값이 있는가, ② Date.now()·Math.random()처럼 호출 시점마다 달라지는 값을 렌더링에 직접 쓰고 있는가, ③ typeof window 분기 없이 브라우저 전용 API에 접근하는 코드가 있는가, ④ 외부 스크립트(위젯, 광고, 분석 도구)가 React가 관리하는 DOM 영역을 직접 조작하는가.
네 가지 중 하나에 해당하면, 그 부분만 클라이언트 전용으로 분리하거나 값 계산 기준을 고정하는 것만으로 대부분 해결됐다.