- Published on
Next.js 14 Hydration failed 경고 10분 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트/SSR로 만든 HTML을 브라우저가 "그대로" 이어받아(=hydrate) React 이벤트를 붙이는 과정에서, 서버가 만든 마크업과 클라이언트의 첫 렌더 결과가 1글자라도 다르면 Next.js는 Hydration failed 경고를 띄웁니다.
Next.js 14(App Router)에서는 서버 컴포넌트가 기본이라 SSR 비중이 커졌고, 그만큼 클라이언트 전용 값(window, localStorage, 시간, 랜덤, 뷰포트 등) 을 렌더에 섞는 순간 경고가 자주 터집니다. 이 글은 “원인 분류 → 10분 내 해결”을 목표로, 가장 흔한 패턴과 고치는 코드를 빠르게 제공합니다.
> 운영 환경에서 증상이 재현이 어렵다면, 먼저 배포/인프라 레벨의 다른 장애(예: 502, 리소스 압박)와 구분하세요. 특히 인그레스/ALB 문제는 화면이 빈 상태로 보여 hydration 이슈처럼 오해되기도 합니다: EKS Ingress 502인데 Pod 로그가 비면? ALB/NLB 헬스체크부터
1) 10분 진단 플로우(체크리스트)
1-1. 콘솔 메시지에서 “불일치 지점” 먼저 찾기
브라우저 콘솔에는 대개 아래 힌트가 함께 나옵니다.
Text content does not match server-rendered HTMLExpected server HTML to contain a matching <...> in <...>Hydration failed because the initial UI does not match what was rendered on the server
이 메시지에 등장하는 태그/컴포넌트 근처가 범인일 확률이 높습니다.
1-2. “서버에서만 가능한 값”이 렌더에 섞였는지 확인
다음 중 하나라도 JSX 렌더 과정에 들어가면 거의 확정입니다.
Date.now(),new Date(),toLocaleString()Math.random()window,document,navigator,locationlocalStorage,sessionStorageIntl.*로케일 의존 포맷- 뷰포트 기반 분기(
innerWidth) - API 응답이 서버/클라에서 다르게 캐싱/재검증되는 경우
1-3. “서버/클라 렌더 경로가 다르다”를 의심
- 조건부 렌더가 서버와 클라이언트에서 다른 조건을 참조
- 쿠키/헤더 기반 분기가 서버에서만 적용
useEffect로 상태를 바꾸면서 초기 렌더가 달라짐
1-4. 일단 빨리 잡는 임시 처방(원인 확정용)
문제 컴포넌트를 아래 중 하나로 감싸면 경고가 사라지는지 확인합니다.
dynamic(() => import(...), { ssr: false })suppressHydrationWarning
사라진다면 “SSR/서버 렌더와 클라 렌더가 다르다”는 게 확정이고, 이후 근본 수정으로 넘어가면 됩니다.
2) 가장 흔한 원인 TOP 7과 해결 코드
2-1. 날짜/시간/로케일 포맷이 서버와 다름
서버는 UTC/기본 로케일, 브라우저는 사용자 로케일/타임존이라 문자열이 달라집니다.
잘못된 예
// Server Component 또는 SSR에서 실행될 수 있음
export default function Page() {
return <p>{new Date().toLocaleString()}</p>;
}
해결 1) 서버에서 포맷을 고정(타임존/로케일 명시)
export default function Page() {
const now = new Date();
const text = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
dateStyle: 'medium',
timeStyle: 'short',
}).format(now);
return <p>{text}</p>;
}
해결 2) 클라이언트에서만 렌더(초기엔 placeholder)
'use client';
import { useEffect, useState } from 'react';
export function ClientTime() {
const [text, setText] = useState<string>('');
useEffect(() => {
setText(new Date().toLocaleString());
}, []);
return <p>{text || '...'}</p>;
}
2-2. Math.random() / 랜덤 ID / nanoid를 렌더에 사용
서버와 클라에서 랜덤 값이 달라져 불일치가 납니다.
잘못된 예
export default function Badge() {
return <div id={`badge-${Math.random()}`}>New</div>;
}
해결) React 18의 useId() 사용(SSR 안전)
'use client';
import { useId } from 'react';
export default function Badge() {
const id = useId();
return <div id={`badge-${id}`}>New</div>;
}
> 서버 컴포넌트에서 안정적인 ID가 필요하면, 데이터의 고유 키(예: DB id)를 사용하세요.
2-3. window/localStorage 값을 바로 렌더에 사용
서버에는 window가 없고, 클라에서만 값이 존재합니다.
잘못된 예
'use client';
export default function ThemeText() {
const theme = localStorage.getItem('theme');
return <p>{theme}</p>;
}
해결) 초기값을 서버와 동일하게 두고, useEffect에서 동기화
'use client';
import { useEffect, useState } from 'react';
export default function ThemeText() {
const [theme, setTheme] = useState<string>('light'); // 서버/클라 초기 일치
useEffect(() => {
const t = localStorage.getItem('theme');
if (t) setTheme(t);
}, []);
return <p>{theme}</p>;
}
대안) 아예 클라이언트 전용 컴포넌트로 분리 + ssr 끄기
import dynamic from 'next/dynamic';
const ThemeText = dynamic(() => import('./ThemeTextClient'), { ssr: false });
export default function Page() {
return <ThemeText />;
}
2-4. 조건부 렌더가 서버/클라에서 다르게 평가됨
대표적으로 “로그인 여부”, “쿠키”, “미디어쿼리” 기반 분기입니다.
해결) 서버에서 결정 가능한 값은 서버에서 결정해서 prop으로 내려주기
- 서버 컴포넌트에서
cookies()/headers()로 판단 - 그 결과만 클라이언트 컴포넌트에 전달
// app/page.tsx (Server Component)
import { cookies } from 'next/headers';
import ClientHeader from './ClientHeader';
export default function Page() {
const isLoggedIn = cookies().has('session');
return <ClientHeader isLoggedIn={isLoggedIn} />;
}
// app/ClientHeader.tsx
'use client';
export default function ClientHeader({ isLoggedIn }: { isLoggedIn: boolean }) {
return <header>{isLoggedIn ? 'Welcome' : 'Sign in'}</header>;
}
2-5. useEffect로 첫 렌더 직후 DOM 구조가 바뀜
예: 첫 렌더에선 A를 그렸다가 effect에서 상태 변경으로 B로 바뀌면, 서버 HTML과 클라 첫 렌더가 달라질 수 있습니다.
해결) effect로 바뀌는 UI는 “초기 렌더”를 서버와 동일하게 맞추기
'use client';
import { useEffect, useState } from 'react';
export default function WidthGate() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// 서버/클라 첫 렌더는 동일하게
if (!mounted) return <div style={{ height: 40 }} />;
return <div>Client-only layout</div>;
}
2-6. 브라우저 확장(Adblock/번역기)이 DOM을 바꿔서 발생
이 경우 코드가 멀쩡해도 특정 사용자에게만 발생합니다.
대응
- 시크릿 모드/확장 비활성화로 재현 여부 확인
- 문제가 되는 영역을
suppressHydrationWarning로 완화(최후 수단)
export default function MaybeMutated() {
return (
<div suppressHydrationWarning>
{/* 확장 프로그램이 텍스트를 바꿀 수 있는 영역 */}
Terms...
</div>
);
}
> suppressHydrationWarning은 “경고 숨김”이지 “불일치 해결”이 아닙니다. UI/접근성 문제가 생길 수 있어 최소 범위에만 쓰세요.
2-7. 서버 캐시/재검증 전략 때문에 서버와 클라 데이터가 다름
App Router에서 fetch는 기본 캐시 동작이 개입합니다. 서버에서 렌더한 데이터와, 클라이언트에서 다시 가져온 데이터가 달라지면 불일치가 날 수 있습니다.
해결) 의도에 맞게 캐시 정책을 명시
// 서버 컴포넌트에서
const res = await fetch('https://api.example.com/items', {
cache: 'no-store',
});
const items = await res.json();
또는 ISR 성격이면:
const res = await fetch('https://api.example.com/items', {
next: { revalidate: 60 },
});
3) “10분 해결”을 위한 실전 디버깅 팁
3-1. 문제 컴포넌트를 빠르게 격리하는 방법
컴포넌트를 반씩 주석 처리/대체하며 범위를 줄이거나, 가장 의심되는 컴포넌트부터 클라이언트 전용 렌더로 강제해 보세요.
import dynamic from 'next/dynamic';
const Suspect = dynamic(() => import('./Suspect'), { ssr: false });
export default function Page() {
return <Suspect />;
}
- 이걸로 경고가 사라지면: Suspect 내부에 SSR 비결정 값이 섞여 있음
- 그대로면: 상위/하위 다른 컴포넌트 또는 외부 DOM 변형(확장 등) 가능
3-2. 개발 모드만 뜨는지, 프로덕션에서도 뜨는지 구분
- 개발 모드(Strict Mode)에서 effect가 두 번 실행되는 등으로 더 잘 드러납니다.
next build && next start로 로컬 프로덕션 실행 후 확인하면 “진짜 사용자 영향”을 더 정확히 판단할 수 있습니다.
3-3. 경고를 방치하면 생기는 실제 문제
- 이벤트 바인딩이 꼬여 클릭/입력 불가
- 레이아웃 점프(특히 조건부 렌더)
- SEO/크롤러 관점에서 초기 HTML과 실제 UI 불일치
4) 추천 해결 전략(우선순위)
4-1. 1순위: 서버/클라 모두 결정 가능한 값만 초기 렌더에 사용
- 시간/랜덤/뷰포트 기반 렌더는 피하거나
- 서버에서 고정 포맷/고정 seed로 만들거나
- 클라이언트 마운트 후 렌더로 미루기
4-2. 2순위: 클라이언트 전용 컴포넌트로 분리
- 브라우저 API를 쓰는 UI는
use client+dynamic(..., { ssr: false })로 격리 - 대신 초기 로딩 placeholder를 디자인해 UX를 유지
4-3. 3순위: suppressHydrationWarning은 최소 범위에만
- 외부 DOM 변형 가능성이 높은 텍스트 영역 등
- 데이터 불일치가 사용자 기능에 영향을 주는 영역에는 지양
5) 자주 쓰는 “정답 템플릿” 모음
5-1. 마운트 이후에만 렌더하는 Hook
'use client';
import { useEffect, useState } from 'react';
export function useMounted() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}
'use client';
import { useMounted } from './useMounted';
export default function ClientOnly({ children }: { children: React.ReactNode }) {
const mounted = useMounted();
if (!mounted) return null;
return <>{children}</>;
}
5-2. 서버에서만 결정하고 클라엔 결과만 전달
// app/layout.tsx (Server)
import { headers } from 'next/headers';
import ClientEnvBanner from './ClientEnvBanner';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const ua = headers().get('user-agent') ?? '';
const isBot = /bot|crawler|spider/i.test(ua);
return (
<html>
<body>
<ClientEnvBanner isBot={isBot} />
{children}
</body>
</html>
);
}
6) 마무리: 가장 빠른 결론
Hydration failed는 대부분 아래 둘 중 하나입니다.
- 시간/랜덤/로컬스토리지/브라우저 API를 초기 렌더에 섞었다
- 조건부 렌더가 서버와 클라에서 다른 기준으로 평가된다
10분 안에 끝내려면:
- 의심 컴포넌트를
dynamic(..., { ssr:false })로 격리해 원인을 확정하고 - 초기 렌더를 “서버와 동일”하게 맞춘 뒤
useEffect로 클라이언트 값을 반영하거나 - 서버에서 결정을 내려 prop으로 전달하세요.
운영에서 리소스 문제로 렌더가 깨지며 증상이 비슷하게 보일 때도 있습니다. 대규모 트래픽/메모리 압박 상황이라면 OOM/eviction 같은 인프라 이슈도 함께 점검해 두면 삽질 시간을 크게 줄일 수 있습니다: Kubernetes OOMKilled 진단과 메모리 누수 추적 실전