금요일

Next.js Hydration Mismatch 에러 해결

🔍 검색 키워드: Next.js hydration 불일치, hydration mismatch 에러, Next.js 서버사이드 렌더링, suppressHydrationWarning

Next.js Hydration Mismatch 에러 해결

증상

Warning: Expected server HTML to contain a matching <div> in <div>.
This means the server HTML, the browser's DOM, and the virtual DOM are out of sync.

또는:

Text content did not match. Server: "2026-07-30 14:32" Client: "2026-07-30 14:33"

개발 환경에서 콘솔에 경고가 나타나고, 프로덕션에서 UI가 깜빡거리거나 스타일이 제대로 적용되지 않습니다.

원인

Next.js는 서버에서 HTML을 생성한 후 클라이언트에서 해당 DOM에 이벤트 리스너를 연결(hydration)합니다. 서버와 클라이언트의 렌더링 결과가 다르면 hydration mismatch 발생:

// ❌ 서버와 클라이언트 결과가 다른 경우

export default function Clock() { const now = new Date(); // 서버와 클라이언트 시간 다름 return <div>{now.toLocaleString()}</div>; }

// 서버 렌더링: "2026-07-30 08:00:00 UTC" // 클라이언트 렌더링: "2026-07-30 14:00:00 KST" // → Mismatch!

흔한 원인들: - 시간/날짜 함수 사용 (Date, new Date()) - 난수 생성 (Math.random()) - localStorage/sessionStorage 접근 - 조건부 렌더링이 서버/클라이언트마다 다름 - CSS-in-JS의 서버 스타일과 클라이언트 스타일 불일치

해결방법

1. useEffect 활용 (권장)

클라이언트에서만 렌더링하는 콘텐츠는 마운트 후 렌더링:
import { useState, useEffect } from 'react';

export default function Clock() { const [time, setTime] = useState(null);

useEffect(() => { setTime(new Date().toLocaleString()); }, []); // 클라이언트에서만 실행

// 서버 렌더링: null 또는 placeholder 반환 if (!time) return <div>Loading...</div>; return <div>{time}</div>; }

2. suppressHydrationWarning (간단한 경우)

export default function Counter() {
  return (
    <div suppressHydrationWarning>
      {Math.random()}
    </div>
  );
}

// 또는 특정 속성만 억제 export default function Div() { return ( <div suppressHydrationWarning={true}> Content </div> ); }

3. Dynamic Import with ssr: false

// components/ClientOnlyComponent.tsx
export default function ClientOnly() {
  return <div>{Math.random()}</div>;
}

// pages/index.tsx import dynamic from 'next/dynamic';

const ClientOnlyComponent = dynamic( () => import('@/components/ClientOnlyComponent'), { ssr: false } );

export default function Home() { return ( <div> <h1>Server Content</h1> <ClientOnlyComponent /> {/ 클라이언트에서만 렌더링 /} </div> ); }

4. useLayoutEffect 또는 useId (안정적 ID)

import { useId } from 'react';

export default function Modal() { const id = useId(); // 서버와 클라이언트에서 동일한 ID 생성

return ( <div id={id} data-modal={id}> Modal with stable ID </div> ); }

5. 실전 예제: Dark Mode Toggle

'use client'; // App Router의 경우

import { useState, useEffect } from 'react';

export default function ThemeProvider() { const [isDark, setIsDark] = useState(false); const [isMounted, setIsMounted] = useState(false);

useEffect(() => { // 로컬스토리지에서 테마 읽기 (클라이언트에서만) const saved = localStorage.getItem('theme') === 'dark'; setIsDark(saved); setIsMounted(true); }, []);

// 마운트 전: 기본값 표시 (서버 렌더링과 일치) if (!isMounted) { return <div className="light">Loading...</div>; }

// 마운트 후: 실제 테마 적용 return ( <div className={isDark ? 'dark' : 'light'}> <button onClick={() => setIsDark(!isDark)}> Toggle Theme </button> </div> ); }

6. 조건부 렌더링 안정화

// ❌ hydration mismatch 위험
const isClient = typeof window !== 'undefined';

export default function Component() { return isClient ? <ClientComponent /> : <ServerComponent />; }

// ✅ useEffect 사용 import { useEffect, useState } from 'react';

export default function Component() { const [isClient, setIsClient] = useState(false);

useEffect(() => { setIsClient(true); }, []);

if (!isClient) return <ServerComponent />; return <ClientComponent />; }

정리표

원인 증상 해결책
시간/날짜 함수 "Expected server HTML..." 경고 useEffect에서만 실행
localStorage 접근 서버/클라이언트 값 다름 마운트 후 접근
조건부 렌더링 UI 깜빡임 또는 깨짐 useLayoutEffect 또는 dynamic import
난수 생성 텍스트 불일치 경고 suppressHydrationWarning 또는 useId
CSS-in-JS 불일치 스타일 깜빡임 서버 스타일과 클라이언트 스타일 동기화
핵심: 서버에서 실행되는 코드와 클라이언트에서 실행되는 코드가 동일한 결과를 생성하도록 하거나, 클라이언트에서만 실행되는 로직을 useEffect로 분리하세요.

댓글 없음:

댓글 쓰기