Published on

Next.js 14 Hydration mismatch 원인 10가지

Authors

서버 컴포넌트와 클라이언트 컴포넌트가 섞인 Next.js 14(App Router)에서는 HTML을 서버에서 미리 만들고, 브라우저에서 React가 그 HTML에 이벤트를 “붙이는” 과정(하이드레이션)을 거칩니다. 이때 서버가 만든 마크업과 클라이언트의 첫 렌더 결과가 조금이라도 다르면 Hydration failed because the initial UI does not match what was rendered on the server 류의 경고/에러가 발생합니다.

핵심은 간단합니다.

  • 서버 렌더는 “정적/결정적”이어야 합니다.
  • 클라이언트 첫 렌더도 서버 렌더와 동일해야 합니다.
  • 차이가 생길 수밖에 없는 값(시간, 랜덤, 브라우저 API 등)은 서버 렌더에 반영하지 않거나, 클라이언트 마운트 이후로 미루거나, 서버에서 동일한 값을 주입해야 합니다.

아래는 실무에서 자주 터지는 Hydration mismatch 원인 10가지와 해결 패턴입니다.

1) Date.now() / 현재 시간 기반 렌더

서버에서 렌더할 때의 시각과 클라이언트에서 렌더할 때의 시각은 다릅니다. 특히 “방금 전”, “n분 전” 같은 UI는 거의 확정적으로 mismatch를 유발합니다.

문제 코드

// app/page.tsx (Server Component)
export default function Page() {
  return <div>now: {Date.now()}</div>;
}

해결 1: 클라이언트에서 마운트 후 계산

'use client';

import { useEffect, useState } from 'react';

export function NowClient() {
  const [now, setNow] = useState<number | null>(null);

  useEffect(() => {
    setNow(Date.now());
  }, []);

  return <div>now: {now ?? '...'}</div>;
}

해결 2: 서버에서 값을 만들어 props로 고정

// app/page.tsx
import { NowFixed } from './now-fixed';

export default function Page() {
  const now = Date.now();
  return <NowFixed now={now} />;
}

// app/now-fixed.tsx
export function NowFixed({ now }: { now: number }) {
  return <div>now: {now}</div>;
}

2) Math.random() / 랜덤 값 렌더

서버와 클라이언트의 랜덤 시드는 다르므로 같은 컴포넌트를 렌더해도 결과가 달라집니다.

문제 코드

export default function Badge() {
  return <span>id: {Math.random()}</span>;
}

해결: 랜덤은 서버에서 1번만 생성해 전달

// Server Component
import crypto from 'crypto';

export default function Page() {
  const id = crypto.randomUUID();
  return <span>id: {id}</span>;
}

3) 브라우저 전용 API 사용: window, document, localStorage

서버에서는 window가 없고, 클라이언트 첫 렌더에서 localStorage 값을 읽어 UI를 바꾸면 서버 HTML과 달라집니다.

문제 코드

'use client';

export function ThemeLabel() {
  const theme = localStorage.getItem('theme');
  return <div>theme: {theme}</div>;
}

해결: 초기값은 서버와 동일하게, 마운트 후 동기화

'use client';

import { useEffect, useState } from 'react';

export function ThemeLabel() {
  const [theme, setTheme] = useState<string>('system');

  useEffect(() => {
    const t = localStorage.getItem('theme');
    if (t) setTheme(t);
  }, []);

  return <div>theme: {theme}</div>;
}

4) 조건부 렌더 기준이 서버/클라에서 다름 (미디어쿼리, matchMedia)

예: 모바일에서만 보이는 메뉴를 window.innerWidth로 판단하면 서버는 알 수 없고, 클라이언트는 즉시 바꿔 mismatch가 납니다.

해결: CSS로 처리하거나, 클라이언트에서만 렌더

  • 가능하면 반응형은 CSS로 해결하세요.
  • 꼭 JS가 필요하면 dynamic으로 SSR을 끄는 것도 방법입니다.
import dynamic from 'next/dynamic';

const MobileOnlyMenu = dynamic(() => import('./mobile-only-menu'), {
  ssr: false,
});

export default function Page() {
  return <MobileOnlyMenu />;
}

5) 서버/클라 로케일 차이로 인한 Intl 포맷 불일치

서버는 en-US, 브라우저는 ko-KR 같은 상황에서 날짜/숫자 포맷이 달라집니다.

문제 코드

export default function Price({ value }: { value: number }) {
  return <div>{value.toLocaleString()}</div>;
}

해결 1: 로케일을 명시

export default function Price({ value }: { value: number }) {
  return <div>{value.toLocaleString('ko-KR')}</div>;
}

해결 2: 서버에서 포맷 문자열을 만들어 전달

export default function Page() {
  const formatted = new Intl.NumberFormat('ko-KR').format(1234567);
  return <div>{formatted}</div>;
}

6) 데이터 패칭 결과가 서버와 클라이언트에서 다름 (중복 fetch, 캐시 정책)

App Router에서 서버 컴포넌트는 fetch를 통해 데이터를 가져와 렌더합니다. 그런데 클라이언트에서도 동일 데이터를 다시 가져와 “초기 상태”를 다르게 만들면 mismatch가 납니다.

흔한 패턴

  • 서버는 캐시된 값, 클라 fetch는 최신 값
  • 서버는 인증 쿠키 포함, 클라는 미포함
  • 서버는 revalidate 설정, 클라는 SWR 기본 설정

해결: 초기 데이터는 서버에서 주입하고 클라에서 재사용

// app/page.tsx (Server)
import { ClientList } from './client-list';

export default async function Page() {
  const res = await fetch('https://api.example.com/items', { cache: 'no-store' });
  const items = await res.json();
  return <ClientList initialItems={items} />;
}

// app/client-list.tsx
'use client';

import { useState } from 'react';

export function ClientList({ initialItems }: { initialItems: any[] }) {
  const [items] = useState(initialItems);
  return (
    <ul>
      {items.map((it) => (
        <li key={it.id}>{it.name}</li>
      ))}
    </ul>
  );
}

7) useId() / 키 생성 방식이 서버-클라에서 달라짐

React의 useId()는 SSR을 고려해 설계됐지만, 컴포넌트 트리가 조건부로 바뀌거나 렌더 순서가 달라지면 ID가 어긋날 수 있습니다. 또한 key를 랜덤/시간 기반으로 만들면 리스트가 재정렬되어 mismatch가 납니다.

문제 코드: 불안정한 key

{items.map((it) => (
  <Row key={Math.random()} item={it} />
))}

해결: 안정적인 key 사용

{items.map((it) => (
  <Row key={it.id} item={it} />
))}

8) DOM을 직접 수정하는 라이브러리(차트, 에디터)와 SSR

차트/에디터 라이브러리 중에는 초기 렌더 시 DOM을 직접 만져서 서버 HTML과 달라지게 만드는 경우가 있습니다.

해결: 클라이언트 전용 렌더 + useEffect에서 초기화

'use client';

import { useEffect, useRef } from 'react';

export default function ChartWrapper() {
  const ref = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!ref.current) return;
    // 여기서 차트 라이브러리 초기화
    // initChart(ref.current)
  }, []);

  return <div ref={ref} />;
}

필요하면 앞의 4번처럼 dynamic(..., { ssr: false })로 SSR 자체를 끄는 것도 현실적인 해법입니다.

9) 잘못된 HTML 중첩/브라우저 자동 보정으로 DOM 구조가 바뀜

서버가 만든 HTML은 “문자열”이고, 브라우저는 이를 파싱하면서 잘못된 중첩을 자동으로 고칩니다. 그러면 React가 기대한 DOM 트리와 달라져 mismatch가 발생합니다.

흔한 실수

  • p 태그 안에 div 넣기
  • ul 바로 아래에 div 두기
  • 테이블 구조 위반

문제 코드

export function Bad() {
  return (
    <p>
      <div>invalid</div>
    </p>
  );
}

해결: 올바른 마크업으로 수정

export function Good() {
  return (
    <div>
      <p>valid</p>
    </div>
  );
}

10) suppressHydrationWarning 오남용 또는 텍스트 노드만 바뀌는 영역 처리 미흡

suppressHydrationWarning은 “어쩔 수 없이 서버/클라가 달라지는 부분”을 무시하게 해주지만, 남용하면 실제 버그를 가립니다. 다만 광고 슬롯, A/B 테스트, 클라이언트에서만 확정되는 사용자 정보 같은 곳에는 유용합니다.

올바른 사용 예: 특정 텍스트만 예외 처리

export default function Page() {
  return (
    <span suppressHydrationWarning={true}>
      {new Date().toISOString()}
    </span>
  );
}

더 좋은 접근은 1번처럼 “마운트 이후에만 바뀌게” 만드는 것입니다. suppressHydrationWarning은 마지막 수단으로 두세요.


디버깅 체크리스트 (재현부터 원인 좁히기)

  1. 경고가 나는 컴포넌트 경계를 찾습니다. 보통 콘솔에 컴포넌트 스택이 함께 나옵니다.
  2. 해당 컴포넌트에서 아래를 검색합니다.
    • Date.now(), new Date(), Math.random()
    • window, document, localStorage, sessionStorage
    • toLocaleString, Intl.*
    • 조건부 렌더(특히 viewport 기반)
    • 불안정한 key
  3. 서버/클라에서 같은 데이터를 쓰는지 확인합니다.
    • 서버 fetchcache, revalidate 설정
    • 인증 쿠키/헤더 포함 여부
  4. DOM 자동 보정이 일어날만한 잘못된 HTML이 없는지 확인합니다.

성능 최적화 과정에서 렌더링 경계가 바뀌며 mismatch가 “갑자기” 생기는 경우도 많습니다. 이때는 레이아웃/렌더링 최적화 기법과 함께 점검하는 것이 좋습니다. 관련해서는 CSS contain·content-visibility로 렌더링 최적화도 같이 참고하면 원인 추적이 빨라집니다.

또한 클라이언트에서만 확정되는 값(프록시/캐시/네트워크 환경 차이로 데이터가 흔들리는 문제)이 섞여 있다면, 스트리밍/프록시 환경에서의 재현 체크도 도움이 됩니다. 비슷한 방식의 “환경 차이 재현” 관점은 FastAPI Uvicorn에서 SSE 웹소켓 LLM 스트리밍이 프록시 뒤에서 끊길 때 Cloudflare Nginx ALB 버퍼 타임아웃 gzip으로 EventSource failed 100% 재현 해결 체크리스트에서 다룬 접근을 응용할 수 있습니다.

마지막으로, 타입/빌드 설정 변경이 렌더 트리 분기를 만들면서(예: 조건부 import, 환경별 polyfill) mismatch가 드러나는 경우도 있습니다. 모듈 시스템 전환 이슈가 얽혀 있다면 Node.js ESM 전환 시 ERR_REQUIRE_ESM 해결 가이드처럼 “환경에 따라 실행 경로가 달라지는 지점”을 점검하는 습관이 도움이 됩니다.


결론: mismatch를 없애는 설계 원칙

  • 서버 렌더 결과는 결정적으로 만들고(시간/랜덤/브라우저 의존 제거)
  • 클라이언트 첫 렌더는 서버와 동일하게 맞춘 뒤
  • 변화가 필요한 값은 useEffect 이후에 반영하거나, 서버에서 값 자체를 주입하세요.

이 원칙만 지켜도 Next.js 14의 hydration mismatch는 대부분 “재현 가능하고 고칠 수 있는” 버그로 바뀝니다.