적용 환경: React 18+, Next.js 13+ (Pages Router / App Router)
들어가며
Next.js로 개발하다 보면 언젠가 이런 에러 메시지를 만나게 됩니다.
Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Text content did not match. Server: "2025-02-04" Client: "2025-02-05"
이 에러의 까다로운 점은, 원인이 명확하지 않을 때 대응이 어렵다는 것입니다. suppressHydrationWarning을 붙이면 경고는 사라지지만 근본 원인은 그대로 남아 있고, useEffect로 감싸면 해결은 되지만 왜 그것이 해결인지는 모호합니다. 패턴을 외워서 대응하는 것과 원리를 이해하고 대응하는 것 사이에는 분명한 차이가 있습니다.
hydration mismatch가 발생하는 구조를 이해하면, 에러 메시지를 보는 것만으로 원인의 범위를 좁힐 수 있고, 코드를 작성하는 시점에 문제를 예방할 수도 있습니다. 이 글은 hydration의 동작 원리부터 시작해서, 실무에서 바로 적용할 수 있는 해결 패턴까지 다룹니다.
Hydration이란
SSR(Server-Side Rendering)에서는 서버가 먼저 React 컴포넌트를 실행해서 HTML 문자열을 생성합니다. 이 HTML이 브라우저로 전송되면, 사용자는 JavaScript가 로드되기 전에도 화면을 볼 수 있습니다. 이것이 SSR이 빠른 초기 로딩(FCP)을 제공하는 이유입니다.
하지만 이 시점의 HTML은 정적입니다. 버튼을 클릭해도, 입력 필드에 타이핑해도 아무런 반응이 없습니다. JavaScript 번들이 로드되면 React가 hydration을 시작합니다. 서버가 보낸 HTML을 처음부터 다시 만드는 것이 아니라, 기존 DOM 노드를 재사용하면서 이벤트 핸들러를 부착하고 인터랙티브한 상태로 전환하는 과정입니다.
컴포넌트 실행
HTML 문자열 생성 후 클라이언트에 전송
HTML 표시
정적 화면을 먼저 렌더링 (빠른 FCP)
JS 번들 로드
React 코드 및 컴포넌트 코드 다운로드
DOM 비교 & 연결
서버 HTML 재사용, 이벤트 핸들러 부착
컴포넌트 실행
HTML 문자열 생성 후 클라이언트에 전송
HTML 표시
정적 화면을 먼저 렌더링 (빠른 FCP)
JS 번들 로드
React 코드 및 컴포넌트 코드 다운로드
DOM 비교 & 연결
서버 HTML 재사용, 이벤트 핸들러 부착
핵심 전제: 서버와 클라이언트의 첫 렌더 결과가 동일해야 한다
이 과정이 가능하려면 하나의 전제가 필요합니다. 서버에서 렌더링한 HTML과 클라이언트에서 첫 번째로 렌더링한 결과가 정확히 일치해야 한다는 것입니다. React는 이 일치를 가정하고 기존 DOM을 그대로 재사용하므로, 만약 서버와 클라이언트의 출력이 다르다면 DOM 구조와 React의 Virtual DOM이 어긋나게 됩니다.
이것은 임의적인 제약이 아니라 성능과 정합성을 위한 설계입니다. 일치한다고 가정하면 React는 DOM을 처음부터 다시 생성할 필요 없이 이벤트 핸들러만 연결하면 되므로 빠르게 인터랙티브 상태에 도달할 수 있습니다. 반대로, 불일치 상태에서 hydration이 진행되면 이벤트 핸들러가 잘못된 DOM 노드에 연결될 수 있어 예측 불가능한 버그로 이어집니다.
React 공식 문서에서도 이 점을 분명히 경고합니다.
"React recovers from some hydration errors, but you must fix them like other bugs. In the best case, they'll lead to a slowdown; in the worst case, event handlers can get attached to the wrong elements."
React가 일부 hydration 에러에서 자동 복구를 시도하더라도, 이는 어디까지나 최선의 노력일 뿐입니다. 복구 과정에서 성능이 저하되고, 최악의 경우에는 사용자가 다른 요소를 클릭하는데 엉뚱한 핸들러가 실행될 수 있습니다. hydration mismatch는 경고가 아니라 반드시 수정해야 하는 버그입니다.
왜 불일치가 발생하는가
hydration mismatch의 근본 원인은 단순합니다. 서버와 클라이언트는 서로 다른 실행 환경이라는 것입니다. 서버는 Node.js에서, 클라이언트는 브라우저에서 동작하며, 이 두 환경에서 접근할 수 있는 API와 상태가 다릅니다.
| 서버 (Node.js) | 클라이언트 (브라우저) |
|---|---|
window / document 없음 | window / document 있음 |
localStorage / sessionStorage 없음 | localStorage / sessionStorage 있음 |
| 서버 타임존 (보통 UTC) | 사용자 로컬 타임존 |
| 요청 시점의 시간 | 렌더링 시점의 시간 |
| 고정된 환경 | 다양한 브라우저 / 디바이스 |
이 환경 차이에서 비롯되는 불일치 원인은 다양합니다. window.innerWidth나 navigator.userAgent 같은 브라우저 전용 API, new Date()의 시간/타임존 차이, Math.random()이나 crypto.randomUUID() 같은 매번 다른 결과를 생성하는 함수, localStorage에 저장된 사용자 설정값, 그리고 <p> 안에 <div>를 넣는 것처럼 브라우저가 자동으로 교정하는 잘못된 HTML 중첩까지, 서버와 클라이언트에서 다른 결과를 만들어내는 코드가 있다면 모두 hydration mismatch의 원인이 됩니다.
흔히 하는 실수 중 하나는 typeof window 체크로 분기 처리하는 것입니다.
// ❌ 분기 처리했지만 여전히 불일치
function ViewportWidth() {
const width = typeof window !== 'undefined' ? window.innerWidth : 0;
// 서버: 0, 클라이언트: 1920 → 불일치
return <p>화면 너비: {width}px</p>;
}서버에서는 window가 없으므로 0을 렌더링하고, 클라이언트에서는 window.innerWidth 값을 렌더링합니다. 서버와 클라이언트의 첫 렌더 결과가 다르므로 hydration mismatch가 발생합니다. 분기 처리 자체가 문제를 일으키는 셈입니다.
불일치의 공식
서버에서 컴포넌트를 실행한 결과와 클라이언트에서 첫 번째로 실행한 결과가 다르면, 그것이 텍스트든 DOM 구조든 hydration mismatch입니다. 해결의 원칙은 하나입니다: 서버와 클라이언트의 첫 렌더를 일치시키고, 클라이언트 전용 값은 hydration 이후에 반영하는 것입니다.
해결 패턴
useEffect 지연 렌더링
가장 기본적인 해결 패턴입니다. 서버와 클라이언트 모두 동일한 초기값(보통 null이나 기본값)으로 시작한 뒤, hydration이 완료된 후에 useEffect에서 실제 클라이언트 값을 세팅하는 방식입니다.
// ❌ 서버와 클라이언트의 첫 렌더 결과가 다름
function WindowWidth() {
const width = typeof window !== 'undefined'
? window.innerWidth
: 0;
return <div>Width: {width}</div>;
}useEffect는 서버에서 실행되지 않고, 클라이언트에서도 hydration이 완료된 후에 실행됩니다. 따라서 서버와 클라이언트의 첫 렌더 모두 null 상태에서 시작하여 hydration이 문제없이 진행되고, 그 이후에 클라이언트 전용 값이 반영됩니다.
이 패턴은 단순하고 직관적이지만, hydration 완료 후에 값이 바뀌면서 화면이 깜빡이거나 레이아웃 시프트가 발생할 수 있다는 점을 고려해야 합니다. null 상태일 때 스켈레톤 UI를 보여주면 사용자 경험을 개선할 수 있습니다.
이 원리를 일반화하면 useIsClient 같은 커스텀 훅으로 만들 수도 있습니다.
function useIsClient() {
const [isClient, setIsClient] = useState(false);
useEffect(() => { setIsClient(true); }, []);
return isClient;
}useSyncExternalStore
React 18에서 도입된 useSyncExternalStore는 외부 스토어를 구독하면서 hydration mismatch까지 방지할 수 있는 도구입니다. 브라우저 API 값을 구독하면서 실시간으로 반영해야 하는 경우에 적합합니다.
import { useSyncExternalStore } from 'react';
function subscribe(callback: () => void) {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
}
function useWindowWidth() {
return useSyncExternalStore(
subscribe,
() => window.innerWidth, // 클라이언트 값
() => null // 서버/hydration 시 값
);
}getServerSnapshot이 핵심
세 번째 인자 getServerSnapshot이 서버 렌더링과 hydration 초기에 사용됩니다. 이 값이 서버와 클라이언트의 첫 렌더를 일치시키므로 hydration mismatch가 발생하지 않으며, hydration이 완료된 후에는 두 번째 인자 getSnapshot이 실제 클라이언트 값을 반환합니다.
React 공식 문서에서도 getServerSnapshot의 역할을 명확히 설명하고 있습니다.
"A function that returns the initial snapshot of the data in the store. It will be used only during server rendering and during hydration of server-rendered content on the client."
useEffect 패턴과의 차이는 값이 변할 때 자동으로 반영된다는 점입니다. 윈도우 리사이즈처럼 지속적으로 변하는 값을 추적해야 한다면, useEffect + addEventListener로 직접 구현하는 것보다 useSyncExternalStore가 더 선언적이고 안전한 선택입니다. 한 가지 주의할 점은, subscribe 함수를 컴포넌트 외부에 선언하거나 useCallback으로 감싸야 매 렌더마다 재구독이 발생하지 않는다는 것입니다.
dynamic import (ssr: false)
컴포넌트 전체가 클라이언트에서만 동작해야 하는 경우, Next.js의 dynamic import로 서버 렌더링 자체를 건너뛸 수 있습니다. 특히 SSR을 지원하지 않는 서드파티 라이브러리를 사용할 때 유용합니다.
import dynamic from 'next/dynamic';
const ChartComponent = dynamic(
() => import('../components/Chart'),
{
ssr: false,
loading: () => <ChartSkeleton />,
}
);이 방식은 해당 컴포넌트를 서버에서 아예 렌더링하지 않으므로, 불일치 자체가 발생할 여지가 없습니다. 다만 서버에서 렌더링하지 않는다는 것은 해당 영역이 HTML에 포함되지 않아 SEO에 반영되지 않고, 초기 로딩 시 빈 영역으로 표시된다는 의미이기도 합니다. 차트, 에디터, 지도 같이 SSR이 불필요한 인터랙티브 컴포넌트에 사용하는 것이 적절합니다.
suppressHydrationWarning
React가 제공하는 escape hatch로, 특정 요소의 hydration 불일치 경고를 의도적으로 허용할 때 사용합니다.
<time dateTime={date.toISOString()} suppressHydrationWarning>
{date.toLocaleDateString()}
</time>타임스탬프처럼 서버와 클라이언트의 값이 불가피하게 다를 수밖에 없으면서, 그 차이가 기능에 영향을 주지 않는 경우에 한해 사용할 수 있습니다. 하지만 이 속성에는 명확한 제한이 있습니다.
suppressHydrationWarning의 제한사항
이 속성은 속성(attribute)과 텍스트 내용 불일치에 적용되고, 한 단계 깊이에서만 동작합니다. DOM 구조 불일치에는 효과가 없으며, React는 불일치한 텍스트를 패치하지 않고 서버 값을 유지합니다. 근본 원인을 해결하지 않고 경고만 숨기는 용도로 사용해서는 안 됩니다.
패턴 선택 가이드
상황에 따라 적절한 패턴은 다릅니다. 아래 표는 실무에서 자주 마주치는 케이스별로 권장하는 해결 패턴을 정리한 것입니다.
| 상황 | 권장 패턴 |
|---|---|
| 브라우저 API 값을 구독해야 할 때 | useSyncExternalStore |
| 클라이언트에서만 다른 값을 보여줄 때 | useEffect 지연 렌더링 |
| 서드파티 라이브러리가 SSR 미지원일 때 | dynamic import (ssr: false) |
| 타임스탬프 등 텍스트만 불가피하게 다를 때 | suppressHydrationWarning |
| 고유한 ID가 필요할 때 | useId() |
| 날짜/시간을 동일하게 렌더링해야 할 때 | 명시적 타임존 지정 (Intl.DateTimeFormat) |
디버깅 가이드
hydration mismatch 에러를 만났을 때, 체계적으로 원인을 추적하는 방법입니다.
에러 메시지에서 불일치 내용 확인
Server: "X" Client: "Y" 형태라면 텍스트 불일치, Expected server HTML to contain a matching <div> 형태라면 DOM 구조 불일치입니다. 에러 메시지가 어떤 값이 달라졌는지 알려주므로, 여기서 원인의 범위를 좁힐 수 있습니다.
해당 컴포넌트에서 환경 의존 코드 탐색
window, document, localStorage, Date, Math.random() 등 서버와 클라이언트에서 다른 값을 반환하는 코드를 찾습니다. typeof window !== 'undefined'로 분기 처리한 코드도 여전히 불일치를 만들 수 있으므로 주의해야 합니다.
의심 컴포넌트 격리
원인을 찾기 어렵다면, 의심되는 컴포넌트를 dynamic(() => import('./Component'), { ssr: false })로 감싸서 에러가 사라지는지 확인합니다. 에러가 사라지면 해당 컴포넌트 내부에 원인이 있는 것이므로, 범위를 좁혀 가며 정확한 코드를 찾으면 됩니다.
정리
핵심 정리
- Hydration은 서버 HTML에 이벤트를 연결하는 과정이며, 서버와 클라이언트의 첫 렌더 결과가 동일해야 한다는 전제 위에 동작합니다.
- 불일치의 근본 원인은 환경 차이입니다. 서버(Node.js)에 없는 브라우저 API, 시간/타임존 차이, 저장소 접근 등 환경에 의존하는 코드가 원인입니다.
- 해결의 원칙은 하나입니다. 서버와 클라이언트의 첫 렌더를 일치시키고, 클라이언트 전용 값은 hydration 이후에 반영합니다.
useEffect,useSyncExternalStore,dynamic import등 도구는 달라도 이 원칙은 동일합니다.