- Published on
Next.js Hydration mismatch 원인 7가지와 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 렌더링(SSR/SSG) 기반의 Next.js 앱을 운영하다 보면 콘솔에 다음과 같은 경고를 한 번쯤 보게 됩니다.
Warning: Text content did not match. Server: "..." Client: "..."Hydration failed because the initial UI does not match what was rendered on the server.
Hydration mismatch는 서버가 만든 HTML과 브라우저(클라이언트)에서 React가 첫 렌더링으로 만든 결과가 달라서 발생합니다. 이때 React는 “서버 HTML을 그대로 재사용”하지 못하고, 일부를 버리고 다시 그리거나(클라이언트 리렌더), 심하면 이벤트 바인딩이 꼬여 UX/성능 문제가 생깁니다.
이 글에서는 Next.js(특히 App Router 기준)에서 자주 발생하는 Hydration mismatch 원인 7가지를 짚고, 각 케이스별로 정석적인 해결 전략을 코드와 함께 정리합니다.
> 참고: 원인 추적 과정에서 빌드/런타임 환경(ESM/CJS, 번들 차이) 문제로 증상이 뒤틀려 보일 때도 있습니다. Node 런타임 모듈 시스템 충돌이 의심되면 Node.js ESM/CJS 충돌로 ERR_REQUIRE_ESM 해결하기도 함께 점검하세요.
Hydration mismatch를 빠르게 진단하는 체크
증상을 보기 전에 “무엇이 다를 수 있는가”를 빠르게 좁히는 게 중요합니다.
- 서버에서 렌더된 값이 시간/랜덤/로케일에 따라 바뀌는가?
- 브라우저에서만 존재하는 API(
window,document,localStorage)를 SSR 중에 참조하는가? - 조건부 렌더링이 서버/클라이언트에서 다른 분기로 흐르는가?
- 외부 데이터가 SSR과 CSR에서 다른 시점/다른 소스로 들어오는가?
- DOM 구조(태그 중첩, 리스트 key)가 React 기대와 다른가?
이제 대표 원인 7가지를 보겠습니다.
1) 시간/랜덤/비결정적 값(Date, Math.random, uuid)
왜 발생하나
서버 렌더 시점과 클라이언트 렌더 시점은 다릅니다. 따라서 new Date()나 Math.random()을 JSX 렌더 단계에서 그대로 쓰면 서버 HTML과 클라이언트 첫 렌더 결과가 달라집니다.
잘못된 예
// app/page.tsx (Server Component)
export default function Page() {
return (
<main>
<p>Now: {new Date().toISOString()}</p>
<p>Nonce: {Math.random()}</p>
</main>
);
}
해결 패턴 A: 서버에서 값을 고정해 내려주기
서버에서 한 번 계산한 값을 props로 내려주면, 클라이언트도 동일한 초기 HTML을 갖게 됩니다.
// app/page.tsx
export default function Page() {
const now = new Date().toISOString();
const nonce = "fixed-on-server"; // 랜덤이 필요하면 서버에서 생성 후 고정
return (
<main>
<p>Now: {now}</p>
<p>Nonce: {nonce}</p>
</main>
);
}
해결 패턴 B: 클라이언트에서만 표시(의도적으로 SSR 제외)
정말로 “클라이언트 시점”이 중요한 UI라면 클라이언트 컴포넌트에서 useEffect로 채웁니다.
// app/components/ClientNow.tsx
"use client";
import { useEffect, useState } from "react";
export function ClientNow() {
const [now, setNow] = useState<string>("");
useEffect(() => {
setNow(new Date().toISOString());
}, []);
return <p>Now: {now}</p>;
}
2) Locale/Timezone/Intl 포맷 차이
왜 발생하나
서버는 보통 UTC 또는 서버 로케일로 실행되고, 브라우저는 사용자 로케일/타임존을 사용합니다. toLocaleString, Intl.NumberFormat 결과가 달라져 mismatch가 납니다.
잘못된 예
export default function Price() {
const price = 123456.78;
return <div>{price.toLocaleString()}</div>; // 서버/클라이언트 로케일 차이
}
해결 패턴 A: 포맷을 서버 기준으로 고정
로케일을 명시하고, 타임존도 명시합니다.
export default function Price() {
const price = 123456.78;
const formatted = new Intl.NumberFormat("ko-KR").format(price);
return <div>{formatted}</div>;
}
날짜도 마찬가지입니다.
const formatted = new Intl.DateTimeFormat("ko-KR", {
timeZone: "Asia/Seoul",
dateStyle: "medium",
timeStyle: "short",
}).format(new Date("2026-01-01T00:00:00Z"));
해결 패턴 B: 사용자 로케일 기반 UI는 CSR로 전환
사용자 환경이 핵심이라면, 초기 SSR에는 플레이스홀더를 두고 클라이언트에서 교체합니다.
3) 브라우저 전용 API(window/document/localStorage) 참조
왜 발생하나
서버에서는 window/document가 없습니다. 이를 렌더 단계에서 참조하면 SSR이 깨지거나, 조건 분기 때문에 서버/클라이언트 결과가 달라집니다.
잘못된 예
"use client";
export default function ThemeLabel() {
const theme = localStorage.getItem("theme"); // 렌더 중 접근
return <div>Theme: {theme}</div>;
}
해결: 초기값을 고정하고, effect에서 동기화
"use client";
import { useEffect, useState } from "react";
export default function ThemeLabel() {
const [theme, setTheme] = useState<string>("light");
useEffect(() => {
const t = window.localStorage.getItem("theme");
if (t) setTheme(t);
}, []);
return <div>Theme: {theme}</div>;
}
추가로, 깜빡임(FOUC)을 줄이려면 쿠키/헤더 기반으로 서버에서 테마를 결정하거나, next/script로 hydration 이전에 클래스를 주입하는 방식도 고려합니다.
4) 조건부 렌더링 분기(서버/클라이언트에서 다른 조건)
왜 발생하나
다음과 같은 분기는 서버와 클라이언트에서 결과가 달라질 수 있습니다.
typeof window !== "undefined"matchMedia,navigator.userAgent, 화면 크기 기반 분기process.env.NEXT_PUBLIC_*와 런타임 환경 차이
잘못된 예
export default function Banner() {
const isClient = typeof window !== "undefined";
return <div>{isClient ? "Client" : "Server"}</div>; // 텍스트가 달라짐
}
해결 패턴 A: 서버/클라이언트 동일한 초기 UI 유지
초기 렌더는 동일하게 만들고, 클라이언트에서만 추가 UI를 붙입니다.
"use client";
import { useEffect, useState } from "react";
export default function Banner() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return <div>{mounted ? "Client" : "Client"}</div>; // 초기 HTML 동일
}
위 예시는 극단적으로 보이지만 핵심은 초기 HTML을 같게 만드는 것입니다. 보통은 mounted 이후에만 특정 컴포넌트를 렌더합니다.
{mounted && <ClientOnlyWidget />}
해결 패턴 B: 아예 CSR 컴포넌트로 분리
아래 6)에서 설명할 dynamic(..., { ssr: false })가 자주 쓰입니다.
5) 외부 데이터/상태 불일치(SSR 데이터 vs CSR 재요청)
왜 발생하나
서버에서 렌더할 때는 A 데이터였는데, 클라이언트가 hydration 직후 B 데이터를 다시 가져와 렌더하면 텍스트/리스트가 달라집니다. 특히 다음 조합에서 자주 발생합니다.
- 서버 컴포넌트에서
fetch로 데이터 렌더 + 클라이언트에서 SWR/React Query로 즉시 refetch - 캐시 설정(
no-store,revalidate) 불일치
해결 패턴 A: 서버에서 받은 초기 데이터를 클라이언트 캐시에 주입
React Query 예시(개념 코드):
// app/page.tsx (Server)
import ClientList from "./ClientList";
export default async function Page() {
const items = await fetch("https://example.com/api/items", {
cache: "no-store",
}).then((r) => r.json());
return <ClientList initialItems={items} />;
}
// app/ClientList.tsx
"use client";
import { useState } from "react";
type Props = { initialItems: Array<{ id: string; name: string }> };
export default function ClientList({ initialItems }: Props) {
// hydration 시점에는 서버와 동일한 initialItems로 렌더
const [items] = useState(initialItems);
return (
<ul>
{items.map((it) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
);
}
해결 패턴 B: SSR과 CSR의 캐시/재검증 정책을 맞추기
App Router에서는 fetch 캐시 옵션과 revalidate를 명확히 설계하세요.
- 항상 최신이 필요하면 서버도
no-store, 클라이언트도 즉시 refetch 대신 사용자 액션 기반으로. - 일정 주기면 서버
revalidate와 클라이언트 staleTime을 유사하게.
6) DOM 의존 라이브러리(차트, 에디터, 지도, 광고) SSR 렌더 불가
왜 발생하나
차트/에디터/지도 라이브러리는 내부에서 DOM 측정(getBoundingClientRect)이나 window를 사용합니다. SSR에서 마크업을 “흉내” 내더라도 클라이언트 첫 렌더 결과가 달라 mismatch가 납니다.
해결: dynamic import로 SSR 제외
// app/page.tsx
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("./Chart"), {
ssr: false,
loading: () => <div style={{ height: 240 }}>Loading chart...</div>,
});
export default function Page() {
return (
<main>
<h1>Dashboard</h1>
<Chart />
</main>
);
}
이 패턴은 “서버에서 해당 영역을 렌더하지 않는다”는 선택입니다. SEO가 중요한 콘텐츠라면, 서버에서 대체 가능한 정적 요약을 렌더하고 차트만 클라이언트에서 강화(enhancement)하는 구조가 좋습니다.
7) 잘못된 key / 리스트 순서 변화 / invalid HTML(태그 중첩)
왜 발생하나
React hydration은 서버 DOM 구조를 기준으로 이벤트를 연결합니다. 그런데 다음 문제가 있으면 구조가 어긋납니다.
key를 index로 사용 + 서버/클라이언트에서 정렬/필터 결과가 달라짐Date.now()같은 값으로 key 생성- HTML 스펙 위반(예:
<p>안에<div>)
잘못된 예: index key + 정렬 변경
export default function List({ items }: { items: string[] }) {
const sorted = [...items].sort();
return (
<ul>
{sorted.map((v, i) => (
<li key={i}>{v}</li>
))}
</ul>
);
}
해결: 안정적인 key 사용 + 정렬 기준 고정
type Item = { id: string; name: string };
export default function List({ items }: { items: Item[] }) {
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name, "en"));
return (
<ul>
{sorted.map((v) => (
<li key={v.id}>{v.name}</li>
))}
</ul>
);
}
invalid HTML도 꼭 점검
예를 들어 다음은 브라우저가 DOM을 자동 수정하면서 구조가 바뀌어 mismatch가 날 수 있습니다.
// 잘못된 중첩 예
return (
<p>
<div>Block inside p</div>
</p>
);
이런 경우는 단순히 올바른 마크업으로 수정하는 것이 가장 빠릅니다.
(보너스) suppressHydrationWarning는 언제 쓰나
Next.js/React에는 특정 노드에 대해 mismatch 경고를 숨기는 옵션이 있습니다.
export default function Page() {
return (
<span suppressHydrationWarning>
{new Date().toISOString()}
</span>
);
}
하지만 이건 경고를 숨길 뿐 근본 원인을 해결하지 않습니다. 다음 조건을 만족할 때만 제한적으로 쓰는 게 안전합니다.
- 값이 의도적으로 서버/클라이언트에서 달라도 UX 문제가 없고
- 해당 노드가 상호작용/레이아웃에 큰 영향을 주지 않으며
- 더 나은 구조(서버 고정값 전달, CSR 분리)가 비용상 불가능할 때
실전 디버깅 루틴(추천)
- 문제 컴포넌트를 최소 단위로 격리: 의심 영역을 주석 처리하며 mismatch가 사라지는 지점 찾기
- 서버/클라이언트 값 비교 로그: 서버 컴포넌트/클라이언트 컴포넌트 각각에서 값이 달라지는지 확인
- 비결정적 값 제거: 렌더 단계에서 Date/Random/Locale 포맷 제거
- CSR 전환 여부 결정: DOM 의존 위젯은
dynamic(..., { ssr: false }) - 데이터 흐름 단일화: SSR 결과를 클라이언트 초기 상태로 재사용
환경/번들 차이로 특정 모듈이 서버/클라이언트에서 다르게 로드되면 원인이 더 복잡해질 수 있습니다. 특히 모듈 시스템 혼용은 런타임 동작을 바꿀 수 있으니, 관련 이슈가 보이면 Node.js ESM/CJS 충돌로 ERR_REQUIRE_ESM 해결하기도 함께 체크하는 편이 좋습니다.
마무리
Hydration mismatch는 “React가 까다롭다”기보다, SSR과 CSR의 세계가 다르다는 사실이 UI에 드러난 결과입니다. 해결의 핵심은 다음 두 가지로 요약됩니다.
- 초기 렌더는 결정적(deterministic)이어야 한다: 시간/랜덤/로케일/환경 분기를 렌더 단계에서 제거
- SSR/CSR 경계를 명확히 설계한다: 브라우저 전용 위젯은 CSR로 분리하고, 데이터는 SSR 결과를 초기 상태로 재사용
위 7가지 원인 중 어디에 속하는지 분류만 잘해도, 대부분의 hydration 문제는 빠르게 수습할 수 있습니다.