🔍 검색 키워드: 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로 분리하세요.