Published on

Next.js 14 Hydration failed 경고 10분 해결법

Authors

서버 컴포넌트/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 HTML
  • Expected 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, location
  • localStorage, sessionStorage
  • Intl.* 로케일 의존 포맷
  • 뷰포트 기반 분기(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는 대부분 아래 둘 중 하나입니다.

  1. 시간/랜덤/로컬스토리지/브라우저 API를 초기 렌더에 섞었다
  2. 조건부 렌더가 서버와 클라에서 다른 기준으로 평가된다

10분 안에 끝내려면:

  • 의심 컴포넌트를 dynamic(..., { ssr:false })로 격리해 원인을 확정하고
  • 초기 렌더를 “서버와 동일”하게 맞춘 뒤 useEffect로 클라이언트 값을 반영하거나
  • 서버에서 결정을 내려 prop으로 전달하세요.

운영에서 리소스 문제로 렌더가 깨지며 증상이 비슷하게 보일 때도 있습니다. 대규모 트래픽/메모리 압박 상황이라면 OOM/eviction 같은 인프라 이슈도 함께 점검해 두면 삽질 시간을 크게 줄일 수 있습니다: Kubernetes OOMKilled 진단과 메모리 누수 추적 실전