Published on

Next.js Hydration mismatch 원인 7가지와 해결법

Authors

서버 렌더링(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 분리)가 비용상 불가능할 때

실전 디버깅 루틴(추천)

  1. 문제 컴포넌트를 최소 단위로 격리: 의심 영역을 주석 처리하며 mismatch가 사라지는 지점 찾기
  2. 서버/클라이언트 값 비교 로그: 서버 컴포넌트/클라이언트 컴포넌트 각각에서 값이 달라지는지 확인
  3. 비결정적 값 제거: 렌더 단계에서 Date/Random/Locale 포맷 제거
  4. CSR 전환 여부 결정: DOM 의존 위젯은 dynamic(..., { ssr: false })
  5. 데이터 흐름 단일화: SSR 결과를 클라이언트 초기 상태로 재사용

환경/번들 차이로 특정 모듈이 서버/클라이언트에서 다르게 로드되면 원인이 더 복잡해질 수 있습니다. 특히 모듈 시스템 혼용은 런타임 동작을 바꿀 수 있으니, 관련 이슈가 보이면 Node.js ESM/CJS 충돌로 ERR_REQUIRE_ESM 해결하기도 함께 체크하는 편이 좋습니다.

마무리

Hydration mismatch는 “React가 까다롭다”기보다, SSR과 CSR의 세계가 다르다는 사실이 UI에 드러난 결과입니다. 해결의 핵심은 다음 두 가지로 요약됩니다.

  • 초기 렌더는 결정적(deterministic)이어야 한다: 시간/랜덤/로케일/환경 분기를 렌더 단계에서 제거
  • SSR/CSR 경계를 명확히 설계한다: 브라우저 전용 위젯은 CSR로 분리하고, 데이터는 SSR 결과를 초기 상태로 재사용

위 7가지 원인 중 어디에 속하는지 분류만 잘해도, 대부분의 hydration 문제는 빠르게 수습할 수 있습니다.