- Published on
Next.js Hydration failed 원인 7가지와 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 렌더링(SSR) 또는 SSG로 생성된 HTML을 브라우저가 받아서 React가 이벤트 핸들러를 붙이고 상태를 이어받는 과정을 Hydration이라고 합니다. Next.js에서 Hydration failed because the initial UI does not match what was rendered on the server(또는 유사한 경고/에러)는 서버가 만든 마크업과 클라이언트의 첫 렌더 마크업이 불일치할 때 발생합니다.
문제는 “콘솔에 경고가 하나 뜬다” 수준을 넘어, 특정 컴포넌트가 통째로 다시 렌더링되며 레이아웃이 튀거나(FOUC/CLS), 이벤트가 잠깐 먹지 않거나, 심하면 화면이 깨지는 형태로 나타난다는 점입니다. 아래는 현장에서 가장 자주 만나는 원인 7가지와 해결책입니다.
> 참고: 타입/데이터 계약이 흔들리면 클라이언트와 서버의 렌더 분기가 달라져 hydration 이슈로 이어지기도 합니다. 객체 검증을 타입 안전하게 유지하는 방법은 TS 5.x satisfies로 타입 안전 유지하며 객체 검증도 함께 참고하면 좋습니다.
1) window, document, localStorage 등 브라우저 전용 API를 렌더 단계에서 사용
SSR에서는 window가 존재하지 않습니다. Next.js는 서버에서 먼저 렌더링하므로, 렌더 함수(컴포넌트 본문)에서 브라우저 API를 읽으면 서버/클라이언트 결과가 달라지거나, 서버에서 크래시 후 fallback 렌더가 발생해 hydration 불일치로 이어집니다.
흔한 패턴(문제)
// ❌ 서버 렌더 시 window가 없고, 클라이언트에서는 값이 존재
export default function Header() {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return <div data-theme={isDark ? 'dark' : 'light'}>...</div>;
}
해결 1: useEffect로 클라이언트에서만 읽고 상태로 반영
import { useEffect, useState } from 'react';
export default function Header() {
const [isDark, setIsDark] = useState(false); // 서버와 클라이언트 첫 렌더를 동일하게
useEffect(() => {
setIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
}, []);
return <div data-theme={isDark ? 'dark' : 'light'}>...</div>;
}
해결 2: 클라이언트 전용 컴포넌트로 분리 + 동적 import
import dynamic from 'next/dynamic';
const ClientOnlyHeader = dynamic(() => import('./ClientOnlyHeader'), {
ssr: false,
});
export default function Page() {
return <ClientOnlyHeader />;
}
2) 시간/난수/비결정적 값(Date.now(), Math.random())을 렌더에 사용
서버 렌더 시점과 클라이언트 렌더 시점은 다릅니다. 따라서 렌더 결과에 시간이 포함되거나 난수를 직접 출력하면 100% 불일치가 발생합니다.
흔한 패턴(문제)
export default function Banner() {
return (
<p>
빌드 시각: {new Date().toISOString()} / nonce: {Math.random()}
</p>
);
}
해결: 서버에서 값을 확정해 props로 내리거나, 클라이언트에서만 표시
App Router 기준 예시(서버 컴포넌트에서 확정):
// app/page.tsx (Server Component)
import ClientBanner from './ClientBanner';
export default function Page() {
const buildTime = new Date().toISOString();
return <ClientBanner buildTime={buildTime} />;
}
// app/ClientBanner.tsx (Client Component)
'use client';
export default function ClientBanner({ buildTime }: { buildTime: string }) {
return <p>빌드 시각: {buildTime}</p>;
}
또는 “실시간 시각”이 목적이라면 서버/클라이언트 첫 렌더를 동일하게 만들고 useEffect로 갱신합니다.
3) 서버와 클라이언트의 로케일/타임존 차이(숫자·통화·날짜 포맷)
toLocaleString()은 런타임 환경의 로케일/타임존 영향을 강하게 받습니다. 서버는 UTC, 클라이언트는 KST 같은 조합이면 렌더 문자열이 달라집니다.
흔한 패턴(문제)
export default function Price({ value }: { value: number }) {
return <span>{value.toLocaleString()}</span>;
}
해결 1: Intl.NumberFormat에 locale을 고정
const krw = new Intl.NumberFormat('ko-KR');
export default function Price({ value }: { value: number }) {
return <span>{krw.format(value)}</span>;
}
해결 2: 날짜/시간은 서버에서 포맷을 확정하거나, 타임존을 명시
const dtf = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
export function CreatedAt({ iso }: { iso: string }) {
return <time>{dtf.format(new Date(iso))}</time>;
}
4) 조건부 렌더링이 서버/클라이언트에서 다르게 타는 경우(UA 기반 분기, 권한/로그인 상태)
대표적으로 다음이 원인입니다.
- 서버에서는 쿠키를 못 읽거나(또는 읽는 방식이 다름) 로그인 상태를
false로 렌더 - 클라이언트는
localStorage/쿠키를 읽어true로 렌더 - 결과적으로 버튼/메뉴 DOM 구조가 달라짐
흔한 패턴(문제)
export default function Nav() {
const token = localStorage.getItem('token'); // 서버에서 불가
return token ? <button>Logout</button> : <button>Login</button>;
}
해결 1: 서버에서 인증 상태를 결정(쿠키 기반)해 동일한 UI로 시작
App Router에서 쿠키를 서버에서 읽기:
// app/page.tsx (Server Component)
import { cookies } from 'next/headers';
import NavClient from './NavClient';
export default function Page() {
const token = cookies().get('token')?.value ?? null;
return <NavClient initialAuthed={Boolean(token)} />;
}
// app/NavClient.tsx
'use client';
import { useState } from 'react';
export default function NavClient({ initialAuthed }: { initialAuthed: boolean }) {
const [authed] = useState(initialAuthed);
return authed ? <button>Logout</button> : <button>Login</button>;
}
해결 2: “첫 렌더는 동일”하게 하고, 클라이언트에서만 바뀌게 설계
예: 서버/클라이언트 모두에서 동일한 스켈레톤을 렌더한 뒤 useEffect에서 교체.
5) DOM 구조를 바꾸는 잘못된 HTML(중첩 규칙 위반) 또는 외부 스크립트가 DOM을 선조작
HTML 파서가 자동으로 태그를 보정하면 React가 예상하는 트리와 달라집니다. 특히 다음이 흔합니다.
<p>안에<div>를 넣는 등 잘못된 중첩<table>내부에<div>를 넣는 등 규칙 위반- 브라우저 확장 프로그램/외부 위젯 스크립트가 hydration 전에 DOM을 수정
흔한 패턴(문제)
export default function Article() {
return (
<p>
요약
<div>상세 블록</div> {/* ❌ p 안에 div */}
</p>
);
}
해결: 시맨틱을 지키고 구조를 단순화
export default function Article() {
return (
<div>
<p>요약</p>
<div>상세 블록</div>
</div>
);
}
외부 스크립트(채팅 위젯, A/B 테스트 태그 등)가 원인이라면:
next/script로 로딩 시점을afterInteractive로 늦추거나- 해당 위젯이 붙는 영역을 클라이언트 전용으로 분리(
dynamic(..., { ssr:false })) - 또는 위젯이 DOM을 직접 건드리지 않도록 설정(가능한 경우)
6) CSS-in-JS/스타일 생성 순서 문제(클래스명 불일치) 또는 테마 초기화 깜빡임
Emotion, styled-components, MUI 등은 SSR 설정이 맞지 않으면 서버와 클라이언트의 클래스명이 달라져 hydration 경고가 발생할 수 있습니다. 또한 다크모드처럼 “초기 테마 결정”이 서버/클라이언트에서 다르면 class/data-theme가 달라집니다.
체크 포인트
- 라이브러리의 Next.js SSR 가이드를 정확히 적용했는가? (캐시/프리픽스/스타일 수집)
- App Router에서
useServerInsertedHTML(또는 공식 예제)로 스타일을 주입하고 있는가? - 테마는 서버에서 쿠키로 결정하거나, 첫 렌더는 동일하고 클라이언트에서만 전환하는가?
테마의 대표 해결 패턴(쿠키로 서버에서 확정)
// app/layout.tsx (Server Component)
import { cookies } from 'next/headers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const theme = cookies().get('theme')?.value ?? 'light';
return (
<html data-theme={theme}>
<body>{children}</body>
</html>
);
}
7) 데이터 패칭/캐싱 불일치로 “초기 데이터”가 달라짐(SSR vs CSR)
서버는 A라는 데이터를 렌더했는데, 클라이언트가 hydration 직후 B를 받아서 첫 렌더 결과가 달라지는 경우입니다. 특히 다음 조합에서 자주 발생합니다.
- 서버:
fetch(..., { cache: 'no-store' })혹은 사용자별 데이터 - 클라이언트: SWR/React Query가 다른 키/헤더로 재요청
- 서버/클라이언트 중 한쪽만 에러/빈 배열 처리 로직이 다름
해결 1: 서버에서 사용한 데이터를 클라이언트 캐시에 “주입”
React Query 예시(개념):
// 서버에서 prefetch -> dehydratedState 전달
// 클라이언트에서 Hydrate로 동일 데이터로 시작
(프로젝트에 따라 구현이 길어지므로 핵심만 적었습니다. 포인트는 서버가 렌더한 데이터 스냅샷을 클라이언트 캐시에 동일하게 넣어 첫 렌더를 맞추는 것입니다.)
해결 2: 서버/클라이언트의 분기 로직을 동일하게
예: 서버에서 null이면 Loading을 렌더했는데, 클라이언트는 []이면 Empty를 렌더하는 식의 미묘한 차이가 hydration을 깨뜨립니다. 데이터 스키마를 명확히 하고, null | [] | undefined 케이스를 통일하세요.
이때 타입/런타임 검증을 함께 가져가면 “서버에서는 문자열, 클라이언트에서는 숫자” 같은 미스매치를 초기에 잡을 수 있습니다. 관련해서는 TS 5.x satisfies로 타입 안전 유지하며 객체 검증에서 소개한 패턴이 도움이 됩니다.
디버깅 체크리스트: 어디서 불일치가 생겼는지 빠르게 찾는 법
1) 문제 컴포넌트를 최소 단위로 격리
- 페이지 전체에서 발생하면 원인 찾기가 어렵습니다.
- 의심 컴포넌트를 하나씩 주석 처리하며 범위를 줄이세요.
2) “첫 렌더에서 달라질 수 있는 값”을 찾기
다음 키워드가 보이면 1차 의심 대상입니다.
Date,Math.random,crypto.randomUUIDwindow,document,navigator,localStorage,sessionStoragetoLocaleString,Intl.*(로케일/타임존)matchMedia, viewport 기반 분기- 인증/권한 분기(쿠키 vs 로컬)
3) 임시로 suppressHydrationWarning을 “진단용”으로만 사용
특정 텍스트 노드가 어쩔 수 없이 달라지는지 확인할 때만 제한적으로 씁니다.
export default function DebugText({ value }: { value: string }) {
return <span suppressHydrationWarning>{value}</span>;
}
- 이 옵션은 근본 해결이 아니라 경고를 숨기는 기능입니다.
- 구조 자체가 달라지는 문제에는 효과가 없고, 오히려 버그를 가릴 수 있습니다.
결론: Hydration failed를 없애는 핵심 원칙
- 서버와 클라이언트의 “첫 렌더 입력”을 동일하게 만든다.
- 브라우저 전용 값은 렌더 단계가 아니라 effect 단계에서 읽는다.
- 시간/난수/로케일/타임존은 결정적으로 고정하거나 서버에서 확정한다.
- 인증/권한/기기 분기는 가능하면 서버에서 결정하고 props로 전달한다.
- 스타일/테마/외부 스크립트처럼 DOM을 흔드는 요소는 SSR 설정과 로딩 시점을 재점검한다.
Hydration 에러는 “Next.js가 이상하다”가 아니라, 대부분 같은 컴포넌트가 서버와 클라이언트에서 서로 다른 UI를 그리도록 만든 입력/환경 차이에서 시작합니다. 위 7가지를 체크하면 대다수 케이스는 빠르게 정리됩니다.
추가로, 운영 환경에서만 재현되는 경우(특정 국가/타임존, 특정 쿠키, 특정 캐시 정책 등)라면 원인 범위를 좁히는 접근이 중요합니다. 인프라/환경 차이로 인한 재현 이슈를 다루는 글로는 EKS Pod만 외부 API 403 - NAT IP·WAF로 해결처럼 “환경 차이로만 터지는 문제를 추적하는 방식”도 참고할 만합니다.