Next.js hydration failed 오류 해결: 원인과 해결 방법

2026.05.13·수정 2026.07.20·약 18분

Next.js hydration failed 오류 핵심 요약

Next.js hydration failed 오류는 서버가 보낸 HTML과 브라우저에서 React가 만든 첫 렌더 결과가 다를 때 발생합니다. 렌더 중 시간·난수·브라우저 API를 읽는 코드, 잘못된 HTML 중첩, 서로 다른 데이터나 locale, hydration 전에 DOM을 바꾸는 third-party 코드·확장 프로그램을 순서대로 확인해야 합니다. 해결의 기준은 경고를 숨기는 것이 아니라 첫 출력에 같은 값과 같은 요소 구조를 만드는 것입니다. 이 글은 2026년 7월 19일 확인한 Next.js와 React 공식 문서를 기준으로 재현·진단·수정·검증 절차를 정리합니다.

hydration failed가 뜻하는 것

Next.js 서버 출력과 React 첫 렌더 사이의 hydration mismatch 진단 흐름

Hydration은 서버에서 미리 만든 HTML에 React가 이벤트 처리기와 컴포넌트 로직을 연결해 상호작용 가능한 화면으로 바꾸는 과정입니다. 이때 React가 브라우저에서 처음 계산한 요소 트리와 이미 존재하는 DOM이 같아야 기존 HTML을 안전하게 재사용할 수 있습니다.

React 공식 문서는 hydrateRoot에 전달한 트리가 서버 출력과 동일해야 하며 mismatch를 버그로 보고 수정해야 한다고 설명합니다. 일부 오류는 React가 복구할 수 있지만, 속성 차이가 항상 패치된다는 보장은 없습니다. 성능 저하나 잘못된 요소에 이벤트 처리기가 연결되는 문제로 이어질 수 있으므로 “화면이 결국 보인다”는 이유로 넘기면 안 됩니다. 근거는 React hydrateRoot 공식 문서Next.js hydration error 공식 문서에서 확인할 수 있습니다.

App Router의 Client Component도 첫 방문에서는 HTML로 사전 렌더링될 수 있습니다. 따라서 파일에 'use client'를 붙였다는 사실만으로 서버·클라이언트 출력 비교가 사라지는 것은 아닙니다. Client Component의 첫 렌더 역시 결정적이어야 합니다.

가장 작은 코드로 재현하기

렌더 중 달라지는 값을 만들면 mismatch가 생깁니다

아래 예시는 렌더할 때마다 현재 시각을 새로 계산합니다. 서버가 HTML을 만든 시점과 브라우저가 hydration을 시작한 시점이 다르므로 텍스트가 달라질 수 있습니다.

export default function Clock() {
  return <time>{Date.now()}</time>
}

Math.random(), 새 UUID 생성, 정렬 기준이 고정되지 않은 배열도 같은 유형입니다. 렌더 함수가 같은 props와 state로 호출됐는데 매번 다른 JSX를 만들면 hydration뿐 아니라 일반 렌더링의 예측 가능성도 낮아집니다.

같은 스냅샷을 전달하면 첫 출력이 안정됩니다

시간이 데이터라면 한 번 만든 ISO 문자열을 서버와 클라이언트가 함께 사용합니다. 기존 원문의 CreatedAt 예제처럼 이미 결정된 값을 표시하면 첫 렌더가 같습니다.

function CreatedAt({ value }) {
  return <time dateTime={value}>{value}</time>
}

export default function Page() {
  const createdAt = '2026-05-13T09:00:00.000Z'
  return <CreatedAt value={createdAt} />
}

실제 데이터에서는 문자열을 하드코딩하라는 뜻이 아닙니다. 서버가 요청이나 데이터 조회 과정에서 정한 값을 직렬화해 Client Component의 prop으로 전달하고, 클라이언트의 첫 렌더가 그 스냅샷을 그대로 사용하도록 설계하라는 뜻입니다.

원인을 유형별로 구분하기

시간·난수·렌더마다 바뀌는 값

new Date(), Date.now(), Math.random()을 렌더 본문에서 호출하면 두 환경의 값이 달라질 수 있습니다. 서버에서 한 번 정한 값을 prop으로 전달하거나, 첫 화면에는 동일한 placeholder를 렌더한 뒤 hydration 이후에 갱신합니다. 난수가 정말 브라우저에서만 필요하다면 해당 UI를 client-only 경계로 분리하거나 적절한 fallback을 설계합니다. Next.js의 최신 prerender 동작에서 난수와 fallback의 관계는 Next.js Math.random Client Component 오류 문서를 참고할 수 있습니다.

브라우저 API와 렌더 중 환경 분기

window, document, localStorage, matchMedia는 서버에 없습니다. 다음처럼 렌더 본문에서 환경을 확인해 서로 다른 요소를 반환하면 서버는 “서버”, 브라우저 첫 렌더는 “클라이언트”를 만들 수 있습니다.

function EnvironmentLabel() {
  const isClient = typeof window !== 'undefined'
  return <span>{isClient ? '클라이언트' : '서버'}</span>
}

단순히 typeof window로 예외를 피하는 것과 동일한 HTML을 만드는 것은 다른 문제입니다. 브라우저 값이 UI에 필요하다면 서버와 클라이언트 첫 렌더에는 같은 fallback을 사용하고, Effect가 실행된 뒤 값을 읽습니다.

잘못된 HTML 중첩

브라우저는 유효하지 않은 HTML을 자체 규칙으로 고칩니다. React가 예상한 JSX 구조와 브라우저가 보정한 DOM이 달라지면 텍스트 값이 같아도 hydration이 실패할 수 있습니다. 특히 <p> 안의 <div>·<ul>, 중첩된 <a><button>을 확인합니다.

// 잘못된 중첩
<p>설명 <div>상세 내용</div></p>

// 수정
<div>
  <p>설명</p>
  <div>상세 내용</div>
</div>

데이터 스냅샷·locale·시간대 차이

서버는 조회 결과 A를 렌더했는데 클라이언트 캐시가 첫 렌더부터 결과 B를 사용하면 목록 길이, 정렬, 텍스트가 달라집니다. 서버 데이터와 같은 초기 값을 직렬화해 전달하고, hydration이 끝난 뒤 재검증 결과로 업데이트합니다. 외부 store를 쓴다면 서버 snapshot과 첫 client snapshot이 같은지도 확인합니다.

toLocaleDateString()과 숫자 포맷은 서버의 locale·시간대와 사용자의 브라우저 설정에 따라 달라질 수 있습니다. 첫 렌더에는 ISO 값이나 서버가 정한 동일 문자열을 사용하고, 현지화가 꼭 필요하면 locale과 time zone을 명시적으로 고정하거나 hydration 이후 사용자 설정으로 바꿉니다. 이때 placeholder와 최종 텍스트의 폭 차이로 레이아웃이 흔들리지 않는지도 함께 봅니다.

third-party 코드·확장 프로그램·전송 계층의 DOM 변경

애플리케이션 코드가 같아도 hydration 전에 DOM이 바뀔 수 있습니다. 브라우저 확장 프로그램, 번역·비밀번호 관리 기능, iOS의 전화번호 자동 링크, third-party 위젯이나 분석 스크립트, CSS-in-JS 설정, Edge/CDN의 HTML 변환이 후보입니다. Next.js 공식 오류 문서도 확장 프로그램과 잘못된 CSS-in-JS·Edge/CDN 설정을 원인으로 열거합니다.

시크릿 창이나 확장 프로그램을 모두 끈 새 프로필에서 재현 여부를 비교하고, third-party 스크립트를 한 번에 하나씩 비활성화합니다. HTML 변환·minify 기능도 잠시 끕니다. React 밖의 위젯이 DOM을 직접 관리해야 한다면 hydration 이전에 React가 관리하는 노드를 바꾸지 않도록 하고, ref와 Effect를 통해 mount 이후 연결하는 방식을 검토합니다.

서버와 클라이언트 출력을 비교하는 진단 순서

오류 메시지에서 시작해 변경 지점을 하나씩 제거합니다

  1. 개발 모드 오류의 컴포넌트 경로를 기록합니다. 메시지에 표시된 예상 요소와 실제 요소, component stack, 문제가 시작된 가장 작은 하위 트리를 확인합니다.
  2. 서버 응답과 hydration 전후 DOM을 비교합니다. Network의 document 응답이나 페이지 소스에서 서버 HTML을 보고, DevTools Elements에서 스크립트 실행 뒤 DOM을 확인합니다. 값과 태그 구조를 모두 비교합니다.
  3. 문제 컴포넌트를 정적 JSX로 바꿉니다. 경고가 사라지면 props, 외부 store, 시간·난수, 브라우저 API를 하나씩 다시 넣어 최초 불일치 지점을 찾습니다.
  4. 환경 요인을 분리합니다. 확장 프로그램 없는 브라우저, third-party 스크립트 없는 빌드, HTML을 수정하지 않는 CDN 경로에서 같은 문제가 나는지 비교합니다.
  5. 개발 서버와 production build를 모두 확인합니다. 개발 전용 동작만 보고 결론 내리지 말고 실제 빌드·시작 명령과 전체 새로고침으로 재검증합니다.
관찰 결과 우선 의심할 범위 다음 확인
새로고침에서만 발생 SSR 데이터, 시간·locale, 초기 store 서버 응답과 첫 client snapshot 비교
특정 브라우저에서만 발생 확장 프로그램, 자동 링크, browser API 새 프로필·시크릿 창과 비교
배포에서만 발생 CDN 변환, 환경 변수, 데이터 시점 원본 응답과 CDN 응답 비교
태그 구조 경고가 함께 발생 유효하지 않은 HTML 중첩 가장 가까운 p·a·button·list 구조 수정

원인별 수정 방법

첫 렌더에는 동일한 데이터와 마크업을 사용합니다

가장 좋은 수정은 컴포넌트를 결정적으로 만드는 것입니다. 서버가 렌더한 데이터 스냅샷을 Client Component의 초기 props나 state로 사용하고, 첫 렌더가 끝난 뒤에만 새 데이터를 반영합니다. 정렬은 안정적인 고유 키로 고정하고, 렌더 중 새 시간·난수·ID를 생성하지 않습니다.

function Price({ initialText }) {
  return <span>{initialText}</span>
}

// 서버가 만든 같은 문자열을 첫 client render에도 전달
<Price initialText='12,000원' />

브라우저 전용 값은 Effect 이후에 읽습니다

서버와 브라우저에서 정말 다른 UI가 필요하면 첫 pass에 동일한 fallback을 렌더하고 Effect에서 client-only 상태로 전환할 수 있습니다. React 문서가 제시하는 two-pass 방식입니다.

'use client'

import { useEffect, useState } from 'react'

export default function ThemeLabel() {
  const [theme, setTheme] = useState(null)

  useEffect(() => {
    setTheme(localStorage.getItem('theme') ?? 'light')
  }, [])

  return <span>{theme ?? '테마 확인 중'}</span>
}

Effect는 서버 렌더 중 실행되지 않으므로 첫 서버 출력과 첫 client 출력은 모두 “테마 확인 중”입니다. 다만 hydration 직후 추가 렌더가 생기고 느린 연결에서는 fallback이 오래 보일 수 있습니다. 모든 값을 Effect로 옮기기보다 브라우저에만 존재하는 값인지 먼저 판단합니다. 자세한 동작은 React useEffect 공식 문서의 서버·클라이언트 콘텐츠 절에서 확인할 수 있습니다.

브라우저 전용 컴포넌트만 client-only로 분리합니다

브라우저 전용 chart·editor처럼 SSR이 실질적으로 불가능한 하위 UI는 next/dynamicssr: false로 사전 렌더링을 끌 수 있습니다. 이 옵션은 Client Component에서만 지원되므로 호출 파일도 Client Component여야 합니다.

'use client'

import dynamic from 'next/dynamic'

const BrowserChart = dynamic(
  () => import('./BrowserChart'),
  { ssr: false, loading: () => <p>차트 불러오는 중</p> }
)

export default function ChartSection() {
  return <BrowserChart />
}

이 방법은 해당 하위 트리의 서버 HTML을 포기하므로 핵심 콘텐츠 전체에 일괄 적용하면 초기 표시와 검색·접근성에 불리할 수 있습니다. 브라우저 의존성이 있는 작은 경계로 제한합니다. 정확한 제약은 Next.js lazy loading 공식 가이드를 확인하세요.

suppressHydrationWarning의 한계

suppressHydrationWarning은 수정 기능이 아니라 경고를 숨기는 escape hatch입니다. React와 Next.js 공식 문서에 따르면 한 단계 깊이에만 적용되며, React는 해당 불일치 텍스트를 패치하려고 시도하지 않습니다. 구조가 다른 트리, 여러 자식의 불일치, 브라우저 API 예외, 서로 다른 이벤트 대상까지 해결하지 않습니다.

<time dateTime={iso} suppressHydrationWarning>
  {unavoidablyDifferentText}
</time>

서버와 클라이언트에서 피하기 어려운 단일 timestamp처럼 차이의 범위와 결과를 이미 이해한 경우에만 제한적으로 사용합니다. 경고가 사라졌는지를 성공 조건으로 삼지 말고, 실제 DOM·텍스트·이벤트가 의도대로 유지되는지 별도로 검증해야 합니다.

재현·진단·수정·검증 체크리스트

Next.js hydration failed 재현 진단 수정 검증 체크리스트

재현

  • 클라이언트 내비게이션뿐 아니라 주소창 전체 새로고침에서도 재현되는가?
  • 문제 컴포넌트를 정적 JSX로 바꾸면 경고가 사라지는가?
  • 개발 서버와 production build에서 각각 어떤 메시지가 나오는가?
  • 재현에 필요한 props·데이터·locale·브라우저 조건을 기록했는가?

진단

  • 서버 document 응답과 hydration 뒤 DOM의 텍스트·속성·태그 구조를 비교했는가?
  • 렌더 본문의 Date, Math.random, UUID, locale 포맷, 비결정적 정렬을 검색했는가?
  • typeof window, window, document, localStorage, matchMedia가 JSX 분기에 영향을 주는가?
  • p, a, button, 목록 요소가 유효하게 중첩됐는가?
  • 초기 서버 데이터와 client cache·external store의 첫 snapshot이 같은가?
  • 확장 프로그램, third-party 스크립트, CSS-in-JS, Edge/CDN HTML 변환을 제외해 봤는가?

수정

  • 첫 렌더에서 서버와 클라이언트가 같은 직렬화된 값과 같은 요소 구조를 사용하는가?
  • 브라우저 전용 값만 동일 fallback 뒤 Effect로 이동했는가?
  • ssr: false를 브라우저 전용의 작은 Client Component 경계로 제한했는가?
  • suppressHydrationWarning을 실제 수정 대신 사용하지 않았는가?

검증

  • 새로고침·뒤로 가기·직접 URL 진입에서 hydration 경고가 다시 나오지 않는가?
  • 느린 네트워크에서 fallback과 최종 UI 전환이 과도하게 흔들리지 않는가?
  • 다른 locale·시간대·데이터 없음·긴 목록 조건에서도 첫 출력이 안정적인가?
  • 확장 프로그램 없는 브라우저와 실제 지원 브라우저에서 결과가 같은가?
  • 수정 후 클릭·입력·포커스 등 이벤트가 올바른 요소에 연결되는가?

브라우저 API를 서버 렌더링과 안전하게 분리하는 기본은 Next.js window is not defined 오류 해결에서 먼저 확인할 수 있습니다. hydration 이후 Effect의 실행과 개발 모드 동작이 혼동된다면 React useEffect 두 번 실행되는 이유를 이어서 보세요. 라우팅·렌더링·배포를 한 흐름으로 정리하려면 Next.js App Router 학습 순서가 다음 단계입니다.

검증 기준과 공식 문서

검증 기준일: 2026년 7월 19일. 확인 당시 공식 문서 화면의 최신 표기는 Next.js 16.2.10, React 19.2였습니다. 특정 오류 메시지와 복구 동작은 사용 중인 버전과 개발 도구에 따라 달라질 수 있으므로 프로젝트의 package.json과 lockfile 버전을 먼저 확인하세요. 이 글은 버전과 무관하게 서버 HTML과 클라이언트 첫 렌더가 일치해야 한다는 원칙을 기준으로 하며, 기술 근거는 Next.js와 React 공식 문서로 제한했습니다.

변경 이력: 2026년 7월 19일에 원인 목록을 시간·난수, 브라우저 API, 잘못된 HTML, 데이터·locale, 외부 DOM 변경으로 확장했습니다. 재현 코드와 안정된 데이터 예시, 서버 응답 비교 절차, Effect·client-only 수정 기준, suppressHydrationWarning의 한계, 내부 학습 경로와 검증 체크리스트를 추가했습니다.

결론

Next.js hydration failed 오류의 해결 기준은 서버 HTML과 브라우저의 첫 React 출력이 같은지입니다. 먼저 오류가 시작된 하위 트리를 작은 정적 JSX로 줄이고, 시간·난수·브라우저 API·태그 구조·초기 데이터·locale·외부 DOM 변경을 하나씩 되돌려 최초 차이를 찾습니다. 가능하면 같은 직렬화 데이터와 유효한 HTML로 결정적인 첫 렌더를 만들고, 브라우저 전용 값만 동일 fallback 뒤 Effect로 옮깁니다. ssr: falsesuppressHydrationWarning은 원인이 분명한 제한된 경우에만 사용하고, 마지막에는 production build와 실제 브라우저에서 텍스트·DOM·이벤트까지 확인해야 합니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

“Next.js hydration failed 오류 해결: 원인과 해결 방법”에 대한 2개의 생각

댓글 남기기