- Published on
Next.js Hydration Mismatch 5가지 원인과 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 만든 HTML(SSR/SSG)과 브라우저가 React로 “다시 그린 결과(클라이언트 렌더)”가 다르면 Next.js는 Hydration failed because the initial UI does not match what was rendered on the server 같은 경고를 띄웁니다. 이 문제는 단순히 콘솔 경고로 끝나지 않고, DOM 교체(re-render)로 인한 레이아웃 점프, 이벤트 바인딩 꼬임, SEO/성능 악화로 이어질 수 있습니다.
이 글은 “왜 서버와 클라이언트 결과가 달라졌는지”를 5가지 대표 원인으로 분류하고, 각 케이스를 안전하게 고치는 패턴을 코드 중심으로 정리합니다.
> 참고: Hydration mismatch는 종종 “눈에 보이는 버그”가 아니라 “성능/안정성 지표”로 먼저 나타납니다. 사용자 입력 지연이 커졌다면 Long Task를 함께 추적해보는 것도 도움이 됩니다: Chrome INP 점수 급락 원인 - Long Task 추적법
Hydration mismatch 빠른 진단 체크리스트
아래 질문에 “예”가 하나라도 있으면 mismatch 가능성이 큽니다.
- 서버 렌더 중
window,document,localStorage를 직접 읽는가? Math.random(),Date.now(),new Date()결과가 화면에 반영되는가?- 사용자 로케일/타임존에 따라 문자열이 달라지는가?
- 클라이언트에서만 데이터를 읽어 초기 UI가 바뀌는가? (예: 로그인 상태, 테마, A/B)
- 외부 라이브러리(차트, 마크다운, WYSIWYG)가 DOM을 직접 만지는가?
원인 1) 비결정적 값(Math.random/Date.now) 렌더링
서버에서 만든 HTML에는 Math.random() 결과 A가 들어갔는데, 브라우저에서 hydration 시점에 다시 렌더링하며 결과 B가 들어가면 즉시 mismatch가 납니다.
문제 예시
// app/random/page.tsx (Server Component)
export default function Page() {
const n = Math.random();
return <div>nonce: {n}</div>;
}
해결 패턴 A: 서버에서 결정한 값을 “고정”해서 내려보내기
서버에서 만든 값을 props로 내려 클라이언트에서도 동일 값이 사용되게 합니다.
// app/random/page.tsx
import RandomClient from "./random-client";
export default function Page() {
const nonce = crypto.randomUUID(); // 서버에서만 생성, 결정적
return <RandomClient nonce={nonce} />;
}
// app/random/random-client.tsx
"use client";
export default function RandomClient({ nonce }: { nonce: string }) {
return <div>nonce: {nonce}</div>;
}
해결 패턴 B: “클라이언트 이후”에만 바뀌게 하되, 초기 HTML은 고정
"use client";
import { useEffect, useState } from "react";
export default function NonceAfterMount() {
const [nonce, setNonce] = useState<string>("-"); // 서버/초기 동일
useEffect(() => {
setNonce(crypto.randomUUID());
}, []);
return <div>nonce: {nonce}</div>;
}
핵심은 서버 HTML과 첫 클라이언트 렌더가 동일해야 한다는 점입니다.
원인 2) 로케일/타임존/Intl 포맷 차이
서버는 UTC, 클라이언트는 KST 같은 식으로 타임존이 다르면 toLocaleString() 결과가 달라집니다. 특히 날짜/숫자 포맷은 mismatch 단골입니다.
문제 예시
export default function Page() {
const s = new Date().toLocaleString();
return <p>{s}</p>;
}
서버에서 렌더된 날짜 문자열과 브라우저에서 렌더된 문자열이 다르면 mismatch.
해결 패턴 A: 서버에서 문자열까지 포맷해서 내려주기
// Server Component
export default function Page() {
const now = new Date();
const formatted = new Intl.DateTimeFormat("ko-KR", {
timeZone: "Asia/Seoul",
dateStyle: "medium",
timeStyle: "short",
}).format(now);
return <p>{formatted}</p>;
}
서버/클라이언트 모두 동일 포맷(동일 timeZone)을 사용하거나, 아예 문자열을 서버에서 확정해버리면 안전합니다.
해결 패턴 B: 날짜는 ISO로 내려주고 클라이언트에서만 표현(초기 고정)
// page.tsx
import ClientDate from "./client-date";
export default function Page() {
return <ClientDate iso={new Date().toISOString()} />;
}
// client-date.tsx
"use client";
import { useMemo } from "react";
export default function ClientDate({ iso }: { iso: string }) {
// iso는 서버에서 고정된 값이므로 초기 mismatch 없음
const text = useMemo(() => {
const d = new Date(iso);
return d.toLocaleString();
}, [iso]);
return <p>{text}</p>;
}
원인 3) 클라이언트 전용 API(window/localStorage)로 초기 UI 분기
서버에는 localStorage가 없으니 기본값으로 렌더했는데, 클라이언트는 저장된 값을 읽고 다른 UI를 그리면 mismatch가 납니다. 대표적으로 다크모드/언어/로그인 배지가 여기에 해당합니다.
문제 예시: 테마 토글
"use client";
export default function ThemeBadge() {
const theme = localStorage.getItem("theme") ?? "light";
return <span>theme: {theme}</span>;
}
서버 렌더 시점에 이미 다른 값이 들어갈 수 있어 위험합니다.
해결 패턴 A: “마운트 이후”에만 localStorage를 읽기
"use client";
import { useEffect, useState } from "react";
export default function ThemeBadge() {
const [theme, setTheme] = useState("light"); // 서버/초기 고정
useEffect(() => {
setTheme(localStorage.getItem("theme") ?? "light");
}, []);
return <span>theme: {theme}</span>;
}
이 방식은 초기 화면이 잠깐 기본값으로 보일 수 있습니다(FOUC). 이를 줄이려면 다음 패턴을 고려합니다.
해결 패턴 B: 쿠키 기반으로 SSR 단계에서 테마를 확정
App Router에서 서버가 쿠키를 읽어 초기 HTML부터 맞추는 방식이 가장 깔끔합니다.
// app/page.tsx (Server Component)
import { cookies } from "next/headers";
export default function Page() {
const theme = cookies().get("theme")?.value ?? "light";
return <div data-theme={theme}>...</div>;
}
클라이언트는 동일 쿠키를 사용하거나, data-attribute를 읽어 초기 상태를 맞추면 됩니다.
원인 4) 데이터 패칭 타이밍 불일치(SSR 데이터 vs CSR 재요청)
서버에서 이미 데이터를 렌더했는데, 클라이언트가 hydration 직후 동일 데이터를 “다른 조건”으로 다시 가져오거나(헤더/쿠키/지역), 캐시 키가 달라 초기 UI가 바뀌면 mismatch가 납니다.
흔한 패턴
- 서버는
fetch(..., { cache: 'no-store' })로 최신을 렌더 - 클라이언트는 SWR/React Query가 캐시된 오래된 값을 먼저 보여줌
- 혹은 반대로 서버는 캐시된 값을 렌더, 클라이언트는 즉시 최신을 받아 UI 변경
해결 패턴 A: 서버에서 가져온 데이터를 클라이언트 캐시에 “주입(Hydrate)”
React Query 예시(개념은 SWR도 동일):
// app/users/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import UsersClient from "./users-client";
async function getUsers() {
const res = await fetch("https://api.example.com/users", { cache: "no-store" });
return res.json();
}
export default async function Page() {
const qc = new QueryClient();
await qc.prefetchQuery({ queryKey: ["users"], queryFn: getUsers });
return (
<HydrationBoundary state={dehydrate(qc)}>
<UsersClient />
</HydrationBoundary>
);
}
// app/users/users-client.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
async function getUsers() {
const res = await fetch("https://api.example.com/users", { cache: "no-store" });
return res.json();
}
export default function UsersClient() {
const { data } = useQuery({ queryKey: ["users"], queryFn: getUsers });
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
서버에서 프리패치한 결과를 클라이언트가 그대로 이어받아 “첫 렌더”를 동일하게 만듭니다.
해결 패턴 B: 서버/클라이언트의 캐시 정책과 키를 일치
queryKey에 지역/권한/필터(예:locale,userId)를 포함- 서버와 클라이언트 fetch 옵션(
cache,next: { revalidate })을 통일
이걸 놓치면 “같은 API를 호출하는데도 다른 화면”이 나오며 mismatch가 발생합니다.
원인 5) DOM을 직접 수정하는 서드파티 라이브러리/확장(차트, 에디터)
Hydration은 React가 “서버 HTML 위에 이벤트만 연결”하는 과정인데, 그 전에 DOM이 바뀌면(라이브러리가 DOM을 갈아끼우거나, 브라우저 확장 프로그램이 마크업을 주입) mismatch가 발생합니다.
전형적인 상황
- 차트 라이브러리가 mount 시 캔버스/DOM을 교체
- 마크다운 렌더러가 클라이언트에서만 플러그인을 적용
- 광고/분석 스크립트가 특정 노드 내부를 수정
해결 패턴 A: 해당 컴포넌트를 클라이언트 전용 + 동적 import로 분리
// app/dashboard/page.tsx
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("./chart"), { ssr: false });
export default function Page() {
return (
<section>
<h1>Dashboard</h1>
<Chart />
</section>
);
}
ssr: false는 “서버 HTML을 만들지 않겠다”는 의미라 mismatch 자체를 회피합니다. 대신 SEO가 필요한 콘텐츠에는 남용하면 안 됩니다.
해결 패턴 B: 불가피한 텍스트 차이는 suppressHydrationWarning으로 국소 처리
시간/카운터처럼 “어차피 클라이언트에서 바뀌는 값”이고, UI/SEO 영향이 크지 않은 경우에만 제한적으로 사용합니다.
export default function Clock({ initial }: { initial: string }) {
return (
<span suppressHydrationWarning>
{initial}
</span>
);
}
주의: 이 속성은 원인을 해결하는 게 아니라 경고를 숨기는 것입니다. DOM 교체로 인한 성능 문제는 남을 수 있습니다.
실전 디버깅: mismatch 원인 빠르게 찾는 법
1) 경고 메시지에서 “어느 노드가 다른지”부터 좁히기
Next.js/React 경고에는 종종
- 어떤 컴포넌트 트리에서 발생했는지
- 서버 값 vs 클라이언트 값 힌트 가 포함됩니다.
문제가 되는 컴포넌트를 찾았다면, 그 컴포넌트가 서버/클라이언트에서 같은 입력으로 같은 출력을 내는지 확인합니다.
2) 의심되는 코드를 “결정적(deterministic)”으로 바꾸기
- 랜덤/시간 값 제거
- 로케일/타임존 고정
- 클라이언트 전용 API는
useEffect이후로 이동 - 서버에서 확정 가능한 값은 서버에서 확정
3) 성능/사용자 체감까지 같이 확인
Hydration mismatch가 DOM 교체를 유발하면 메인 스레드 작업이 늘고 INP/LCP에 악영향이 날 수 있습니다. 실제로 체감 지연이 있다면 Long Task를 함께 추적해보세요: Chrome INP 점수 급락 원인 - Long Task 추적법
정리: 5가지 원인별 “한 줄 처방”
- 랜덤/시간 값: 서버에서 값 확정해 props로 전달하거나, 마운트 이후에만 변경
- 로케일/타임존 포맷: 서버에서 문자열 포맷까지 확정(또는 ISO로 고정 전달)
- window/localStorage 기반 분기: 초기 렌더는 고정,
useEffect에서만 읽기(또는 쿠키로 SSR 확정) - 데이터 패칭 불일치: 서버 프리패치 결과를 클라이언트 캐시에 주입하고 캐시 키/정책 통일
- DOM 직접 수정 라이브러리:
dynamic(..., { ssr:false })로 격리하거나, 꼭 필요한 부분만suppressHydrationWarning
Hydration mismatch는 “증상”이고, 본질은 서버와 클라이언트가 같은 초기 UI를 만들도록 설계되지 않았다는 데 있습니다. 위 5가지 패턴으로 원인을 분류해보면, 대부분은 빠르게 재현/수정이 가능합니다.