Published on

Next.js Hydration failed 경고 7가지 원인·해결

Authors

서버에서 렌더링된 HTML과 브라우저에서 React가 다시 그린 결과가 조금이라도 다르면 Next.js는 Hydration failed 또는 Text content does not match server-rendered HTML 경고를 띄웁니다. 이 경고는 단순 콘솔 노이즈가 아니라, 사용자에게는 깜빡임(flash), 이벤트 바인딩 지연, 레이아웃 점프, 심하면 클릭이 먹지 않는 현상으로 이어질 수 있습니다.

특히 Next.js App Router는 서버 컴포넌트와 클라이언트 컴포넌트가 섞이면서 “어디서 HTML이 확정되는지”가 더 중요해졌습니다. 이 글에서는 실무에서 가장 많이 터지는 7가지 원인을 재현 코드와 함께 정리하고, 각각의 안전한 해결 패턴을 제시합니다.

관련해서 성능 관점의 렌더 병목 진단은 Next.js INP 폭증? React 렌더 병목 7단계 진단도 같이 보면 좋습니다.

Hydration 경고를 빠르게 판별하는 체크리스트

아래 중 하나라도 해당하면 “서버와 클라이언트 출력이 달라질 가능성”이 큽니다.

  • 렌더 중 Date.now()/Math.random()/crypto.randomUUID() 같은 비결정 값 사용
  • 브라우저 전용 값(window, document, localStorage)을 렌더 단계에서 사용
  • 서버는 en-US, 클라이언트는 ko-KR처럼 로케일/타임존이 다르게 포맷
  • CSS-in-JS/외부 스크립트가 초기 DOM을 바꿈
  • HTML 규칙 위반(예: p 안에 div)
  • 개발 환경에서만 재현(Strict Mode, HMR)되거나 특정 확장 프로그램에서만 재현

이제 원인별로 정리합니다.

1) 비결정 값 렌더링: Date.now() / Math.random() / UUID

증상

서버에서 만든 HTML의 텍스트와 클라이언트 첫 렌더 텍스트가 달라져 경고가 납니다.

문제 코드

// app/page.tsx (Server Component)
export default function Page() {
  return (
    <main>
      <p>빌드 시각: {Date.now()}</p>
      <p>랜덤: {Math.random()}</p>
    </main>
  )
}

해결 1: 서버에서 값을 확정해서 내려주기

서버 컴포넌트라면 “서버에서 확정된 값”을 렌더링하는 것은 괜찮습니다. 다만 Date.now()를 매 요청마다 다르게 찍으면, 캐시/재검증 전략과 충돌할 수 있습니다.

// app/page.tsx
export const dynamic = 'force-dynamic'

export default async function Page() {
  const now = new Date().toISOString()
  return <p>요청 시각: {now}</p>
}

해결 2: 클라이언트에서만 표시(placeholder 사용)

UI적으로 “초기 HTML과 무관한 값”이라면, 서버에서는 고정된 placeholder를 렌더하고 클라이언트에서 useEffect로 채웁니다.

'use client'

import { useEffect, useState } from 'react'

export function ClientNow() {
  const [now, setNow] = useState<string>('')

  useEffect(() => {
    setNow(new Date().toISOString())
  }, [])

  return <span>{now || '로딩 중...'}</span>
}

2) 로케일/타임존 차이로 인한 날짜·숫자 포맷 불일치

증상

서버는 UTC로 렌더, 클라이언트는 로컬 타임존으로 렌더되어 텍스트가 달라집니다. 특히 toLocaleString()이 흔한 원인입니다.

문제 코드

export default function Price({ amount }: { amount: number }) {
  return <p>{amount.toLocaleString()}</p>
}

Node.js 서버의 기본 로케일과 브라우저 로케일이 다르면 출력이 달라질 수 있습니다.

해결 1: 포맷을 서버/클라이언트에서 동일하게 고정

Intl.NumberFormat에 로케일과 옵션을 명시해 “결정적”으로 만듭니다.

const nf = new Intl.NumberFormat('ko-KR', {
  maximumFractionDigits: 0,
})

export function Price({ amount }: { amount: number }) {
  return <p>{nf.format(amount)}</p>
}

해결 2: 날짜는 ISO로 내려주고 클라이언트에서만 로컬 포맷

SEO나 초기 페인트에 꼭 필요하지 않다면 클라이언트에서만 로컬화합니다.

'use client'

import { useMemo } from 'react'

export function LocalDate({ iso }: { iso: string }) {
  const text = useMemo(() => {
    return new Date(iso).toLocaleString('ko-KR')
  }, [iso])

  return <time dateTime={iso}>{text}</time>
}

3) 브라우저 전용 API를 렌더 단계에서 사용

증상

서버 렌더에서는 값이 없거나 다른 값으로 렌더되고, 클라이언트에서 window.innerWidth 같은 값으로 다시 렌더되며 불일치가 발생합니다.

문제 코드

'use client'

export default function WidthBadge() {
  return <p>width: {window.innerWidth}</p>
}

해결: 렌더에서는 안전한 초기값, useEffect에서 채우기

'use client'

import { useEffect, useState } from 'react'

export default function WidthBadge() {
  const [w, setW] = useState<number | null>(null)

  useEffect(() => {
    const onResize = () => setW(window.innerWidth)
    onResize()
    window.addEventListener('resize', onResize)
    return () => window.removeEventListener('resize', onResize)
  }, [])

  return <p>width: {w ?? '측정 중...'}</p>
}

추가로, “초기 렌더에서 아예 클라이언트 컴포넌트를 렌더하지 않기”도 선택지입니다.

import dynamic from 'next/dynamic'

const WidthBadge = dynamic(() => import('./WidthBadge'), { ssr: false })

export default function Page() {
  return <WidthBadge />
}

4) 조건부 렌더링이 서버/클라이언트에서 다르게 평가됨

증상

typeof window !== 'undefined' 같은 분기, 또는 쿠키/스토리지 기반 분기가 서버와 클라이언트에서 다른 트리를 만들면 hydration이 깨집니다.

문제 코드

export default function Banner() {
  const isClient = typeof window !== 'undefined'
  return <div>{isClient ? '클라이언트 배너' : '서버 배너'}</div>
}

서버는 항상 서버 배너, 클라이언트는 첫 렌더에서 클라이언트 배너가 되어 불일치입니다.

해결 1: 동일한 초기 UI를 렌더하고, 이후에만 변경

'use client'

import { useEffect, useState } from 'react'

export default function Banner() {
  const [mounted, setMounted] = useState(false)
  useEffect(() => setMounted(true), [])

  return <div>{mounted ? '클라이언트 배너' : '서버와 동일한 초기 배너'}</div>
}

해결 2: 서버에서 필요한 값을 먼저 결정(쿠키/헤더 기반)

App Router에서는 cookies() 등을 사용해 서버에서 분기 값을 확정하고, 클라이언트에는 props로 전달하는 방식이 안정적입니다.

// app/page.tsx
import { cookies } from 'next/headers'

export default function Page() {
  const c = cookies()
  const variant = c.get('variant')?.value ?? 'A'
  return <div>variant: {variant}</div>
}

캐시/재검증이 섞이면서 “서버가 생각한 값이 계속 바뀌는” 문제가 있다면, Next.js App Router 캐시 꼬임·재검증 버그 해결에서 정리한 점검 포인트도 함께 확인하는 편이 좋습니다.

5) 잘못된 HTML 중첩/마크업 규칙 위반

증상

React는 클라이언트에서 DOM을 “정상화”하거나 브라우저가 자동으로 태그를 보정하면서 서버 HTML과 구조가 달라집니다. 이 경우 hydration 경고가 자주 납니다.

문제 코드 예시

export default function BadMarkup() {
  return (
    <p>
      <div>이런 중첩은 유효하지 않습니다</div>
    </p>
  )
}

해결

유효한 HTML 구조로 바꾸고, 특히 텍스트 컨테이너(p, span) 안에 블록 요소(div)가 들어가지 않게 합니다.

export default function GoodMarkup() {
  return (
    <div>
      <p>설명 텍스트</p>
      <div>블록 컨텐츠</div>
    </div>
  )
}

또한 테이블, 리스트, 폼 요소의 자식 규칙 위반도 빈번합니다. 예를 들어 ul의 직접 자식은 반드시 li여야 합니다.

6) CSS-in-JS/스타일 주입 순서 문제로 DOM이 달라짐

증상

서버 렌더 결과와 클라이언트 스타일 재수화 과정에서 클래스명/스타일 태그 삽입 순서가 달라지면 경고가 발생하거나, 레이아웃이 흔들립니다. Emotion, styled-components, MUI 같은 조합에서 설정이 어긋났을 때 자주 보입니다.

해결 방향

  • App Router 사용 시 라이브러리별 공식 가이드에 따라 “서버에서 스타일을 수집하고 동일한 순서로 주입”되게 구성
  • use client 경계가 불필요하게 넓어져 스타일 생성 타이밍이 달라지지 않게 컴포넌트 구조를 정리
  • 동일 컴포넌트를 서버/클라이언트에서 서로 다른 스타일 분기(예: prefers-color-scheme)로 렌더하지 않기

라이브러리별 설정이 길어질 수 있어 여기서는 핵심만 적지만, 결론은 “서버와 클라이언트가 동일한 클래스/스타일 트리를 만들도록” 설정을 맞추는 것입니다.

7) 외부 스크립트/브라우저 확장 프로그램이 초기 DOM을 변경

증상

서버 HTML이 브라우저에 도착한 뒤, hydration 전에 제3자 스크립트(AB 테스트, 광고, 챗 위젯)나 확장 프로그램이 DOM을 수정하면 React는 “내가 기대한 DOM이 아니다”라고 경고합니다.

대표 케이스

  • 마케팅 태그가 특정 노드에 임의의 span을 삽입
  • 번역 확장 프로그램이 텍스트 노드를 교체
  • document.write 류 동작으로 DOM 재구성

해결 1: Next.js next/script로 로딩 전략 제어

hydration 이전에 DOM을 건드리지 않도록 afterInteractive 또는 lazyOnload를 사용합니다.

import Script from 'next/script'

export default function Page() {
  return (
    <>
      <Script
        src="https://example.com/widget.js"
        strategy="afterInteractive"
      />
      <main>...</main>
    </>
  )
}

해결 2: 영향을 받는 영역을 격리

위젯이 특정 컨테이너만 조작하게 하고, React가 관리하는 영역과 분리합니다.

export default function WidgetSlot() {
  return <div id="external-widget-root" />
}

정말 필요할 때만 쓰는 suppressHydrationWarning

Next.js/React에는 특정 노드의 불일치를 “의도된 것”으로 무시하는 옵션이 있습니다. 하지만 이는 근본 해결이 아니라, 불일치를 숨기는 도구입니다. 날짜처럼 “서버와 클라이언트가 다를 수밖에 없는 텍스트”에만 제한적으로 쓰는 편이 안전합니다.

export default function Time() {
  const iso = new Date().toISOString()
  return (
    <time suppressHydrationWarning dateTime={iso}>
      {iso}
    </time>
  )
}

권장 패턴은 여전히 “서버와 클라이언트가 첫 렌더에서 동일한 결과를 만들도록 설계”하는 것입니다.

디버깅 팁: 어디서 불일치가 시작되는지 추적하기

  • 경고 메시지에 포함된 컴포넌트 스택을 기준으로, 해당 컴포넌트에서 위 7가지 패턴이 있는지 먼저 검색
  • 의심 구간을 임시로 정적 텍스트로 바꿔 hydration이 사라지는지 확인
  • 외부 스크립트는 모두 주석 처리 후 하나씩 복구
  • 개발 모드에서만 재현되면 Strict Mode 이중 렌더, HMR 영향도 고려

Hydration 자체는 “SSR을 쓰는 한 피할 수 없는 검증 단계”입니다. 중요한 건 불일치를 운 좋게 숨기는 것이 아니라, 첫 렌더의 결정성을 확보하는 것입니다. 이 원칙만 지키면 Hydration failed 경고의 대부분은 구조적으로 사라집니다.