Published on

Next.js Hydration Mismatch 5가지 원인과 해결법

Authors

서버가 만든 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가지 원인별 “한 줄 처방”

  1. 랜덤/시간 값: 서버에서 값 확정해 props로 전달하거나, 마운트 이후에만 변경
  2. 로케일/타임존 포맷: 서버에서 문자열 포맷까지 확정(또는 ISO로 고정 전달)
  3. window/localStorage 기반 분기: 초기 렌더는 고정, useEffect에서만 읽기(또는 쿠키로 SSR 확정)
  4. 데이터 패칭 불일치: 서버 프리패치 결과를 클라이언트 캐시에 주입하고 캐시 키/정책 통일
  5. DOM 직접 수정 라이브러리: dynamic(..., { ssr:false })로 격리하거나, 꼭 필요한 부분만 suppressHydrationWarning

Hydration mismatch는 “증상”이고, 본질은 서버와 클라이언트가 같은 초기 UI를 만들도록 설계되지 않았다는 데 있습니다. 위 5가지 패턴으로 원인을 분류해보면, 대부분은 빠르게 재현/수정이 가능합니다.