Next.js 하이드레이션 오류 원인과 해결법 완벽 정리

3초 요약: 이렇게 고치세요
  • 원인: 서버가 렌더링한 초기 HTML과 브라우저가 처음 그린 HTML 구조가 서로 달라서 발생 — React는 둘이 같을 거라 믿고 결합을 시도하다가 실패함
  • 해결: 잘못된 태그 중첩이면 마크업 수정 / window·localStorage 참조면 useEffect로 이동 / 시간·랜덤값처럼 매번 달라지는 값이면 ssr: false로 분리
  • 주의: suppressHydrationWarning은 경고를 숨길 뿐 원인을 고치는 게 아니라서, 불가피한 텍스트 불일치(타임스탬프 등)에만 제한적으로 써야 함
const [isMounted, setIsMounted] = useState(false);
useEffect(() => setIsMounted(true), []);
if (!isMounted) return null; // 서버와 첫 클라이언트 렌더는 항상 동일하게
↓ 왜 이런 에러가 나는지 원리 보기

Next.js를 쓰다 보면 콘솔에 "Hydration failed because the initial UI does not match..."라는 붉은 에러가 뜰 때가 있습니다. 빌드는 멀쩡히 끝났는데 화면이 그려진 직후에 갑자기 나는 에러라서 처음 보면 당황스럽습니다.

원인은 대부분 서버와 클라이언트가 그린 첫 화면이 미묘하게 다르기 때문이고, 패턴이 정해져 있어서 하나씩 짚어보면 금방 잡힙니다.


하이드레이션(Hydration), 정확히 어떤 과정인가

Next.js의 핵심 기능은 서버 사이드 렌더링(SSR)입니다. 서버는 먼저 사용자에게 정적인 HTML 뼈대를 빠르게 그려서 브라우저에 내려보내 줍니다.

그 후, 브라우저(클라이언트)가 이 정적인 HTML 위에 React의 자바스크립트 코드와 이벤트 리스너를 결합하여 인터랙티브한 페이지로 완성하는 과정을 거치는데, 이 일련의 결합 과정을 '하이드레이션(Hydration)'이라고 부릅니다. 물을 주어 정적인 HTML에 생기를 불어넣는다는 은유적인 표현입니다.

하이드레이션 오류가 발생하는 근본 원인

하이드레이션 오류는 "서버가 렌더링한 초기 HTML 구조"와 "브라우저가 첫 번째로 렌더링한 HTML 구조"가 서로 다를 때 발생합니다. React는 서버와 클라이언트의 DOM 트리가 일치할 것이라고 믿고 결합을 시도하는데, 두 구조가 다르면 결합에 실패하고 에러를 뿜어내게 됩니다.


자주 발생하는 3대 원인과 코드로 보는 실수

실무에서 하이드레이션 오류를 유발하는 대표적인 실수 사례와 예시 코드입니다. 특히 다크모드 토글이나 로그인 여부에 따라 조건부로 다르게 그려지는 컴포넌트에서 이 에러를 가장 자주 마주치게 됩니다.

1. 잘못된 HTML 태그 중첩 규칙

HTML5 표준 명세상 특정 태그 안에는 다른 특정 태그가 들어갈 수 없습니다. React는 클라이언트 렌더링 과정에서 이를 엄격히 교정하려 하기 때문에 서버의 렌더링 결과물과 충돌이 발생합니다.

  • 대표적인 실수: <p> 태그 내부에 <div><ul> 같은 블록 요소를 넣는 경우.
  • 실수 코드 예시:
<!-- 잘못된 예: p 태그 안에 div가 들어감 -->
<p>
  <div>이 텍스트는 하이드레이션 에러를 유발합니다.</div>
</p>
  • 해결법: 외부 요소를 <div>로 변경하거나, 내부 요소를 <span> 같은 인라인 요소로 변경해야 합니다.

2. 브라우저 전용 전역 객체(window, document) 사용

서버(Node.js 환경)에는 windowlocalStorage 같은 브라우저 전용 객체가 존재하지 않습니다.

  • 실수 코드 예시:
import dynamic from 'next/dynamic';

// ssr: false 설정을 통해 하이드레이션 에러를 원천 차단합니다.
const ClientOnlyComponent = dynamic(
  () => import('../components/ClientOnlyComponent'),
  { ssr: false }
);

export default function Page() {
  return <ClientOnlyComponent />;
}
  • 해결법: 컴포넌트가 완전히 마운트된(클라이언트에 안착한) 후에 해당 객체를 참조하도록 제어해야 합니다.

3. 클라이언트 고유 데이터 렌더링 (Date, Random)

new Date()Math.random() 같은 함수는 실행할 때마다 다른 값을 반환합니다. 서버가 빌드/렌더링한 시점의 시간과 사용자의 브라우저가 화면을 그린 시점의 시간이 1초라도 다르면 텍스트 불일치로 에러가 납니다.

예전에 외주로 맡았던 어드민 페이지에서 "최근 접속 시각" 배지를 {new Date().toLocaleTimeString()}로 그냥 찍었다가 콘솔에 하이드레이션 경고가 간헐적으로 뜬 적이 있습니다. 개발 서버에서는 거의 재현이 안 돼서 처음엔 그냥 넘어갔는데, 배포 후 사용자 리포트로 다시 마주치고 나서야 서버 렌더링 시각과 클라이언트 렌더링 시각이 초 단위로 갈린다는 걸 알아챘습니다. suppressHydrationWarning으로 눌러버릴 수도 있었지만, 결국 그 배지만 useEffect 이후로 미루는 쪽으로 고쳤습니다.


상황별로 고르는 3가지 해결법

프로젝트 상황에 맞게 골라 쓸 수 있는 세 가지 방법입니다.

솔루션 A: useEffect를 활용한 클라이언트 렌더링 지연

컴포넌트가 클라이언트에 마운트되기 전까지는 클라이언트 전용 로직을 실행하지 않도록 보장하는 가장 표준적인 방법입니다.

import { useState, useEffect } from 'react';

export default function MyComponent() {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  if (!isMounted) {
    return null; // 혹은 로딩 스켈레톤 반환
  }

  return <div>{window.innerWidth < 768 ? "모바일" : "PC"}</div>;
}

솔루션 B: Next.js Dynamic Import로 SSR 비활성화

특정 컴포넌트 전체를 서버 렌더링 과정에서 제외하고 오직 클라이언트 측에서만 로드하도록 동적 임포트(Dynamic Import)를 구성합니다.

import dynamic from 'next/dynamic';

// ssr: false 설정을 통해 하이드레이션 에러를 원천 차단합니다.
const ClientOnlyComponent = dynamic(
  () => import('../components/ClientOnlyComponent'),
  { ssr: false }
);

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

솔루션 C: suppressHydrationWarning 속성 사용

서버와 클라이언트의 텍스트가 다를 수밖에 없는 특수한 경우(예: 타임스탬프 표시), React에 경고를 무시하라고 알려주는 속성입니다. 남용하지 않고 불가피한 텍스트 불일치에만 사용해야 합니다.

<!-- suppressHydrationWarning 속성을 통해 경고를 비활성화 -->
<span suppressHydrationWarning>
  {new Date().toLocaleTimeString()}
</span>

한눈에 보는 체크리스트

정리하면 세 가지만 점검하면 됩니다. <p> 태그 안에 div나 ul 같은 블록 요소가 들어가 있지 않은지 마크업을 확인하고, 있다면 바깥 요소를 div로 바꿔주면 됩니다. window나 localStorage 같은 브라우저 전용 API를 렌더링 로직에 직접 쓰고 있다면 useEffect 내부로 옮겨 상태를 업데이트하도록 바꿔야 합니다. 시간이나 랜덤 숫자처럼 매번 값이 달라지는 데이터가 초기 UI에 노출된다면 ssr: false로 설정한 별도 컴포넌트로 분리하는 것이 안전합니다.

공식 참고 레퍼런스

더 자세한 기술적 명세와 에러 해결 팁은 공식 문서를 참조하시면 도움이 됩니다.

호마다의 웹개발 팁

하이드레이션 에러는 결국 HTML 마크업 기본기와 맞닿아 있는 경우가 많습니다. <p> 안에 <div>를 넣는 실수처럼, HTML5 태그 중첩 규칙 자체를 모르고 지나치는 경우가 의외로 흔합니다. 기초 문법이 헷갈리신다면 [호마다의 IT 개발 입문 블로그] 의 HTML5 태그 가이드도 함께 참고해 보세요.

댓글

이 블로그의 인기 게시물

TypeScript: Unknown vs Any 타입 차이점과 안전한 타입 가드(Type Guard)

Next.js App Router에서 getServerSideProps 대체 및 데이터 페칭법