Next.js 프로젝트를 띄웠는데 콘솔에 이런 에러가 뜨는 순간 멘탈이 흔들린다.
Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Text content did not match. Server: "2026-06-21" Client: "2026-06-20"
에러 메시지는 있는데 어디서 터지는지 안 보인다. 서버에서는 잘 렌더링되는 것 같은데 클라이언트에서 뭔가 다르다는 것이다. 이 글에서 원인별로 정리해서 어디서 문제인지 빠르게 찾을 수 있도록 쓴다.
하이드레이션(Hydration)이 뭔지 먼저
Next.js는 서버에서 HTML을 미리 렌더링하고, 클라이언트에서 그 HTML 위에 React가 붙는다(hydrate). 이때 서버에서 만든 HTML과 클라이언트가 첫 렌더링하는 결과물이 완전히 일치해야 한다. 다르면 React가 신뢰를 잃고 에러를 던진다.
원인 1: 브라우저 전용 API를 렌더 중에 접근
가장 흔한 케이스다. window, document, localStorage 같은 객체는 서버(Node.js)에 없다.
// ❌ 잘못된 코드 — 서버에서 window가 undefined라 에러
function MyComponent() {
const width = window.innerWidth; // 서버에서 undefined
return <div>너비: {width}px</div>;
}
// ✅ 해결 — useEffect 안에서만 접근
import { useState, useEffect } from 'react';
function MyComponent() {
const [width, setWidth] = useState(0);
useEffect(() => {
setWidth(window.innerWidth);
}, []);
return <div>너비: {width}px</div>;
}
또는 컴포넌트 전체를 클라이언트 전용으로 만들어야 한다면:
// ✅ dynamic import + ssr: false
import dynamic from 'next/dynamic';
const ClientOnlyComponent = dynamic(() => import('./ClientOnlyComponent'), {
ssr: false,
});
원인 2: 날짜/시간 값 불일치
두 번째로 흔하고, 찾기 까다롭다. 서버는 UTC로 렌더링하고 클라이언트는 로컬 타임존으로 렌더링한다.
// ❌ 문제 코드
function PostDate() {
const today = new Date().toLocaleDateString('ko-KR');
return <span>{today}</span>;
// 서버(UTC): "2026. 6. 21."
// 클라이언트(KST +9): "2026. 6. 22."
}
// ✅ 해결 1 — 서버에서 데이터로 날짜를 받아 props로 전달
function PostDate({ date }: { date: string }) {
return <span>{date}</span>;
}
// 서버 컴포넌트에서:
export default function Page() {
const date = new Date().toISOString().split('T')[0]; // "2026-06-21" (UTC 기준 고정)
return <PostDate date={date} />;
}
// ✅ 해결 2 — 클라이언트 전용 렌더링
function PostDate() {
const [date, setDate] = useState('');
useEffect(() => {
setDate(new Date().toLocaleDateString('ko-KR'));
}, []);
if (!date) return null;
return <span>{date}</span>;
}
원인 3: Math.random() / Date.now() 같은 불확정 값
서버와 클라이언트에서 각각 한 번씩 실행되므로 결과가 달라진다.
// ❌ 문제 코드
function Avatar() {
const id = Math.floor(Math.random() * 1000); // 서버 42, 클라이언트 731
return <img src={`/avatar/${id}.jpg`} />;
}
// ✅ 해결 — userId 기반의 결정론적 값 사용
function Avatar({ userId }: { userId: number }) {
return <img src={`/avatar/${userId % 100}.jpg`} />;
}
원인 4: 인증 상태 불일치 (Clerk, NextAuth 등)
인증 라이브러리 쓸 때 자주 발생한다. 서버는 쿠키 없이 렌더링하고, 클라이언트는 로그인 상태를 감지해서 다른 UI를 그린다.
// ✅ 해결 - 인증 상태에 따라 분기되는 UI는 클라이언트 전용으로
'use client';
import { useUser } from '@clerk/nextjs';
export default function Header() {
const { isLoaded, isSignedIn } = useUser();
if (!isLoaded) return null; // 로드 전엔 아무것도 렌더링 안 함
return isSignedIn ? <UserButton /> : <SignInButton />;
}
원인 5: Partial Prerendering(PPR) + Suspense 경계 없음 (Next.js 15+)
Next.js 15 이상에서 PPR(Partial Prerendering)을 쓸 때, 동적 데이터 영역을 <Suspense>로 감싸지 않으면 미스매치가 난다.
// ✅ 해결 — 동적 컴포넌트를 Suspense로 래핑
import { Suspense } from 'react';
export default function Page() {
return (
<Suspense fallback={<div>로딩 중...</div>}>
<DynamicContent />
</Suspense>
);
}
async function DynamicContent() {
const data = await fetchDynamicData();
return <div>{data.value}</div>;
}
원인 6: 빌드 캐시 오염
코드는 맞는데 .next 캐시가 오래된 경우다. 주로 use client 경계 변경 후 발생한다.
# 캐시 완전 삭제 후 재시작
rm -rf .next
npm run dev
# 프로덕션 빌드라면
rm -rf .next
npm run build
npm start
빠른 진단 체크리스트
| 증상 | 의심 원인 | 해결 |
|---|---|---|
window is not defined |
브라우저 API 직접 접근 | useEffect 안에서 접근 |
| 날짜/숫자 값 불일치 | new Date(), Math.random() 렌더 중 사용 | useEffect 또는 서버 props 전달 |
| 인증 UI 불일치 | 인증 상태 SSR | isLoaded 체크 후 렌더 |
| 코드 변경 후 갑자기 발생 | 빌드 캐시 오염 | rm -rf .next 후 재시작 |
| PPR 활성화 후 발생 | Suspense 경계 없음 | 동적 컴포넌트 Suspense 래핑 |
정리
Hydration Error는 "서버에서 만든 HTML과 클라이언트 첫 렌더가 다르다"는 한 가지 원인에서 파생된다. 원인 찾는 순서:
- 브라우저 전용 API(
window,document,localStorage) 렌더 중 직접 접근 여부 확인 new Date(),Math.random()같은 비결정론적 값 렌더 중 사용 여부 확인.next캐시 삭제 후 재시작- 인증 라이브러리 상태 로드 완료 전 렌더 여부 확인
- PPR 설정과 Suspense 경계 검토
대부분은 1~3번에서 해결된다.
댓글 없음:
댓글 쓰기