Published on

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

Authors

서버 사이드 렌더링(SSR) 또는 SSG로 만들어진 HTML을 브라우저에서 React가 “같은 트리”로 다시 붙이는 과정이 hydration입니다. 이때 서버가 만든 마크업과 클라이언트가 첫 렌더에서 만든 마크업이 조금이라도 다르면 Next.js/React는 Hydration failed because the initial UI does not match what was rendered on the server 같은 경고를 띄우고, 경우에 따라 해당 서브트리를 클라이언트에서 재렌더해버립니다.

Hydration mismatch는 단순 경고로 끝나기도 하지만, 실제로는 레이아웃 점프(CLS), 이벤트 핸들러 미부착, 상태 꼬임, SEO/성능 저하로 이어질 수 있습니다. 아래는 현업에서 가장 자주 나오는 원인 9가지와 “고치는 방법”을 원인별로 정리한 글입니다.

> 참고로, 이런 류의 문제는 재현이 어렵고 로그가 애매해서 “진단 체크리스트”가 중요합니다. 같은 결의 접근법은 systemd 서비스 재시작 루프 10분 진단 가이드 같은 글에서도 통합니다.

Hydration mismatch 빠른 진단 체크

1) 증상 위치 좁히기

  • 경고 메시지에 표시되는 컴포넌트 스택을 확인
  • 의심 컴포넌트에 console.log('server?', typeof window === 'undefined') 같은 표식 추가
  • React DevTools로 해당 컴포넌트의 첫 렌더 props/state 확인

2) “서버/클라 첫 렌더”가 달라질 만한 요소 찾기

  • 시간, 랜덤, 로케일, 타임존, 사용자 설정(테마), 브라우저 API, 스토리지, 미디어쿼리, 확률적 데이터

3) 해결 전략 3가지 중 하나를 선택

  1. 서버와 클라가 동일한 결정을 하게 만들기(권장)
  2. 클라에서만 렌더(dynamic import ssr:false 등)
  3. 초기 렌더를 “중립값”으로 고정하고 useEffect로 후처리

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

서버 렌더 시점과 클라이언트 렌더 시점은 다릅니다. 심지어 몇 ms 차이도 문자열이 바뀌면 mismatch가 납니다.

문제 코드

export function BuildTime() {
  return <p>now: {new Date().toISOString()}</p>;
}

해결 1: 서버에서 값을 고정해 내려주기

App Router라면 서버 컴포넌트에서 값을 만들고 클라이언트 컴포넌트에 prop으로 전달합니다.

// app/page.tsx (Server Component)
import { BuildTimeClient } from './build-time-client';

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

// app/build-time-client.tsx (Client Component)
'use client';

export function BuildTimeClient({ now }: { now: string }) {
  return <p>now: {now}</p>;
}

해결 2: 초기 렌더는 고정값, 이후 effect로 갱신

'use client';
import { useEffect, useState } from 'react';

export function Clock() {
  const [now, setNow] = useState<string>(''); // 서버/클라 동일(빈 값)

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

  return <p>now: {now}</p>;
}

2) Math.random() / UUID 등 비결정적 값

서버와 클라이언트가 서로 다른 랜덤 값을 만들면 즉시 mismatch가 납니다.

문제 코드

export function Banner() {
  const id = Math.random().toString(16).slice(2);
  return <div id={id}>banner</div>;
}

해결: React 18의 useId() 사용(SSR 안전)

'use client';
import { useId } from 'react';

export function Banner() {
  const id = useId();
  return <div id={id}>banner</div>;
}

> useId()는 서버/클라가 같은 트리를 기준으로 안정적인 ID를 생성하도록 설계되어 hydration에 안전합니다.


3) 로케일/타임존 차이로 인한 toLocaleString() 불일치

서버는 보통 UTC/영문 로케일, 클라이언트는 사용자의 로케일/타임존입니다. 같은 날짜라도 문자열이 달라집니다.

문제 코드

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

해결 1: 포맷을 서버에서 고정(권장)

// 서버에서 Intl 옵션을 고정해 문자열로 내려줌
export function Price({ formatted }: { formatted: string }) {
  return <span>{formatted}</span>;
}

// 예: 서버
const formatted = new Intl.NumberFormat('ko-KR').format(value);

해결 2: 클라이언트에서만 포맷(초기 렌더 중립)

'use client';
import { useEffect, useState } from 'react';

export function PriceClient({ value }: { value: number }) {
  const [text, setText] = useState('');
  useEffect(() => {
    setText(new Intl.NumberFormat(navigator.language).format(value));
  }, [value]);
  return <span>{text}</span>;
}

4) 브라우저 전용 API(window, document, localStorage)를 렌더 단계에서 사용

서버에는 window가 없습니다. 보통은 크래시로 끝나지만, 조건부 렌더로 우회하면 mismatch로 이어지기도 합니다.

문제 코드

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

해결: useEffect에서만 접근 + 초기값 고정

'use client';
import { useEffect, useState } from 'react';

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

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

  return <span>{theme}</span>;
}

5) 미디어쿼리/뷰포트 기반 조건 렌더 (matchMedia, innerWidth)

서버는 화면 크기를 모릅니다. 서버에서 “데스크톱”으로 렌더했는데 모바일에서 열면 mismatch가 발생합니다.

문제 코드

'use client';
export function Nav() {
  const isMobile = window.innerWidth < 768;
  return isMobile ? <MobileNav /> : <DesktopNav />;
}

해결 1: CSS로 해결(가급적 렌더 분기하지 않기)

  • 동일한 DOM을 렌더하고 CSS로 보이기/숨기기

해결 2: 초기 렌더는 공통, effect로 확정

'use client';
import { useEffect, useState } from 'react';

export function Nav() {
  const [isMobile, setIsMobile] = useState<boolean | null>(null);

  useEffect(() => {
    const mq = window.matchMedia('(max-width: 767px)');
    const apply = () => setIsMobile(mq.matches);
    apply();
    mq.addEventListener('change', apply);
    return () => mq.removeEventListener('change', apply);
  }, []);

  // 서버/클라 첫 렌더 동일하게 만들기
  if (isMobile === null) return <DesktopNav />; // 또는 skeleton
  return isMobile ? <MobileNav /> : <DesktopNav />;
}

6) 서버/클라 데이터 불일치(캐시, revalidate, 프리패치 타이밍)

서버 렌더에서 받은 데이터와 클라이언트가 부팅 직후 다시 fetch한 데이터가 다르면 mismatch가 납니다. 특히 다음 패턴에서 잘 터집니다.

  • SSR HTML에는 A 데이터
  • 클라이언트 첫 렌더에서 SWR/React Query가 즉시 B 데이터로 교체
  • 컴포넌트가 A 기준 마크업과 B 기준 마크업이 달라짐

문제 예시(SWR)

'use client';
import useSWR from 'swr';

export function Profile() {
  const { data } = useSWR('/api/me', (u) => fetch(u).then(r => r.json()));
  return <p>{data?.name}</p>;
}

해결: 서버 데이터로 클라이언트 캐시를 “동기화”

  • App Router: 서버에서 fetch한 결과를 prop으로 내려주고, 클라이언트 캐시에 seed
// app/page.tsx (Server)
import { ProfileClient } from './profile-client';

export default async function Page() {
  const me = await fetch('https://example.com/api/me', { cache: 'no-store' }).then(r => r.json());
  return <ProfileClient initialMe={me} />;
}

// app/profile-client.tsx (Client)
'use client';
import useSWR from 'swr';

export function ProfileClient({ initialMe }: { initialMe: any }) {
  const { data } = useSWR('/api/me', (u) => fetch(u).then(r => r.json()), {
    fallbackData: initialMe,
    revalidateOnMount: false,
  });
  return <p>{data?.name}</p>;
}

> 데이터 계층에서의 “초기 상태 고정”은 레이트리밋/재시도 설계와도 연결됩니다. 트래픽이 튀는 환경이라면 OpenAI Responses API 429 레이트리밋 토큰버킷으로 끝내기처럼 호출 빈도를 제어하는 패턴도 함께 고려하세요.


7) dangerouslySetInnerHTML에 서버/클라에서 다른 HTML 주입

마크다운 렌더링, CMS 콘텐츠, 광고 스니펫, 실험 플래그(A/B) 등으로 서버와 클라의 innerHTML이 달라지면 mismatch가 큽니다.

문제 코드

export function Article({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

해결 포인트

  • 같은 입력 → 같은 출력이 되도록 서버/클라에서 렌더러/플러그인 버전과 옵션을 통일
  • 가능하면 서버에서만 HTML 생성 후 클라이언트는 그대로 표시
  • 클라이언트에서 추가로 DOM을 조작하는 라이브러리(하이라이트, 수식 렌더 등)는 useEffect에서 수행
'use client';
import { useEffect, useRef } from 'react';

export function ArticleClient({ html }: { html: string }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    // 예: highlight.js 같은 DOM 기반 후처리는 여기에서
    // hljs.highlightAll();
  }, []);

  return <div ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
}

8) DOM 구조를 바꾸는 잘못된 HTML(중첩 규칙 위반)

브라우저 파서는 잘못된 HTML을 자동으로 고칩니다. 서버가 만든 문자열 HTML과 브라우저가 실제로 구성한 DOM 트리가 달라져 hydration이 실패할 수 있습니다.

자주 터지는 예

  • <p> 안에 <div>
  • <table> 내부에 <div>
  • <li><ul>/<ol> 밖에 존재

문제 코드

export function BadMarkup() {
  return (
    <p>
      <div>block in p</div>
    </p>
  );
}

해결

  • 시맨틱/중첩 규칙을 지켜 마크업 수정
export function GoodMarkup() {
  return (
    <div>
      <p>text</p>
      <div>block</div>
    </div>
  );
}

9) 외부 스크립트/브라우저 확장/서드파티 위젯이 DOM을 선변경

Hydration은 “서버 HTML 그대로”를 전제로 합니다. 그런데 hydration 전에 DOM이 바뀌면 React가 붙을 대상이 달라져 mismatch가 납니다.

대표 케이스:

  • A/B 테스트 도구가 DOM을 즉시 변형
  • 광고/추천 위젯이 특정 노드를 삽입
  • 번역 확장/다크모드 확장 등이 텍스트/속성을 변경

해결 1: Next.js next/script로 로딩 시점 제어

import Script from 'next/script';

export default function Page() {
  return (
    <>
      <Script
        src="https://third-party.example/widget.js"
        strategy="afterInteractive" // hydration 이후 실행
      />
      <div id="widget-root" />
    </>
  );
}

해결 2: 위젯 영역을 클라이언트 전용으로 격리

import dynamic from 'next/dynamic';

const Widget = dynamic(() => import('./widget-client'), { ssr: false });

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

> 운영 환경에서는 “내 코드가 아닌 것”이 DOM을 바꿔서 생기는 이슈가 의외로 많습니다. 배포 파이프라인/권한/런타임 차이로 재현이 갈리는 문제라면 GitHub Actions OIDC로 AWS 키 없이 배포하기처럼 환경을 표준화하는 것도 디버깅 비용을 줄입니다.


실전 대응 전략: 원인별로 어떤 해결책을 고를까?

서버와 클라가 동일한 결과를 내게 만들기(최우선)

  • 시간/랜덤/포맷을 서버에서 고정
  • 데이터는 서버 결과를 클라 캐시에 seed
  • 동일한 렌더러/옵션으로 HTML 생성

클라이언트 전용 렌더로 격리하기(차선)

  • 광고, 위젯, 브라우저 API 의존 UI
  • dynamic(..., { ssr: false })로 경계를 만들면 mismatch가 “전염”되는 것을 막습니다.

suppressHydrationWarning은 최후의 수단

텍스트 노드 하나 정도가 의도적으로 다를 때만 제한적으로 씁니다.

export function MaybeDifferentText({ text }: { text: string }) {
  return <span suppressHydrationWarning>{text}</span>;
}
  • DOM 구조가 다르거나, 이벤트/상태가 중요한 컴포넌트에 남발하면 문제를 숨길 뿐입니다.

마무리: Hydration mismatch를 줄이는 설계 습관

  • 렌더 단계에서 비결정적 값(시간/랜덤/환경 의존)을 만들지 않는다.
  • “서버에서 그린 첫 화면”을 단일 소스 오브 트루스로 두고, 클라이언트는 그 상태에서 출발한다.
  • 브라우저 전용 로직은 useEffect로 미루거나, 아예 클라이언트 전용 컴포넌트로 격리한다.
  • 마크업은 HTML 규칙을 지키고, 서드파티 스크립트는 hydration 이후로 미룬다.

이 9가지를 체크하면 대부분의 Next.js hydration mismatch는 재현 가능해지고, 해결도 “운”이 아니라 “설계”로 접근할 수 있습니다.