일요일

Next.js Hydration Error 해결 — 서버/클라이언트 불일치 원인과 디버깅 완전 가이드

🔍 검색 키워드: Next.js hydration error, hydration failed, Text content does not match server-rendered HTML, Hydration Mismatch, next.js 하이드레이션 에러 해결, useEffect hydration, next.js 서버 클라이언트 불일치

Next.js 프로젝트를 띄웠는데 콘솔에 이런 에러가 뜨는 순간 멘탈이 흔들린다.

Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Text content did not match. Server: "2026-06-21" Client: "2026-06-20"

에러 메시지는 있는데 어디서 터지는지 안 보인다. 서버에서는 잘 렌더링되는 것 같은데 클라이언트에서 뭔가 다르다는 것이다. 이 글에서 원인별로 정리해서 어디서 문제인지 빠르게 찾을 수 있도록 쓴다.


하이드레이션(Hydration)이 뭔지 먼저

Next.js는 서버에서 HTML을 미리 렌더링하고, 클라이언트에서 그 HTML 위에 React가 붙는다(hydrate). 이때 서버에서 만든 HTML과 클라이언트가 첫 렌더링하는 결과물이 완전히 일치해야 한다. 다르면 React가 신뢰를 잃고 에러를 던진다.


원인 1: 브라우저 전용 API를 렌더 중에 접근

가장 흔한 케이스다. window, document, localStorage 같은 객체는 서버(Node.js)에 없다.

// ❌ 잘못된 코드 — 서버에서 window가 undefined라 에러
function MyComponent() {
  const width = window.innerWidth; // 서버에서 undefined
  return <div>너비: {width}px</div>;
}
// ✅ 해결 — useEffect 안에서만 접근
import { useState, useEffect } from 'react';

function MyComponent() {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    setWidth(window.innerWidth);
  }, []);

  return <div>너비: {width}px</div>;
}

또는 컴포넌트 전체를 클라이언트 전용으로 만들어야 한다면:

// ✅ dynamic import + ssr: false
import dynamic from 'next/dynamic';

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

원인 2: 날짜/시간 값 불일치

두 번째로 흔하고, 찾기 까다롭다. 서버는 UTC로 렌더링하고 클라이언트는 로컬 타임존으로 렌더링한다.

// ❌ 문제 코드
function PostDate() {
  const today = new Date().toLocaleDateString('ko-KR');
  return <span>{today}</span>;
  // 서버(UTC): "2026. 6. 21."
  // 클라이언트(KST +9): "2026. 6. 22."
}
// ✅ 해결 1 — 서버에서 데이터로 날짜를 받아 props로 전달
function PostDate({ date }: { date: string }) {
  return <span>{date}</span>;
}

// 서버 컴포넌트에서:
export default function Page() {
  const date = new Date().toISOString().split('T')[0]; // "2026-06-21" (UTC 기준 고정)
  return <PostDate date={date} />;
}
// ✅ 해결 2 — 클라이언트 전용 렌더링
function PostDate() {
  const [date, setDate] = useState('');

  useEffect(() => {
    setDate(new Date().toLocaleDateString('ko-KR'));
  }, []);

  if (!date) return null;
  return <span>{date}</span>;
}

원인 3: Math.random() / Date.now() 같은 불확정 값

서버와 클라이언트에서 각각 한 번씩 실행되므로 결과가 달라진다.

// ❌ 문제 코드
function Avatar() {
  const id = Math.floor(Math.random() * 1000); // 서버 42, 클라이언트 731
  return <img src={`/avatar/${id}.jpg`} />;
}

// ✅ 해결 — userId 기반의 결정론적 값 사용
function Avatar({ userId }: { userId: number }) {
  return <img src={`/avatar/${userId % 100}.jpg`} />;
}

원인 4: 인증 상태 불일치 (Clerk, NextAuth 등)

인증 라이브러리 쓸 때 자주 발생한다. 서버는 쿠키 없이 렌더링하고, 클라이언트는 로그인 상태를 감지해서 다른 UI를 그린다.

// ✅ 해결 - 인증 상태에 따라 분기되는 UI는 클라이언트 전용으로
'use client';

import { useUser } from '@clerk/nextjs';

export default function Header() {
  const { isLoaded, isSignedIn } = useUser();

  if (!isLoaded) return null; // 로드 전엔 아무것도 렌더링 안 함

  return isSignedIn ? <UserButton /> : <SignInButton />;
}

원인 5: Partial Prerendering(PPR) + Suspense 경계 없음 (Next.js 15+)

Next.js 15 이상에서 PPR(Partial Prerendering)을 쓸 때, 동적 데이터 영역을 <Suspense>로 감싸지 않으면 미스매치가 난다.

// ✅ 해결 — 동적 컴포넌트를 Suspense로 래핑
import { Suspense } from 'react';

export default function Page() {
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <DynamicContent />
    </Suspense>
  );
}

async function DynamicContent() {
  const data = await fetchDynamicData();
  return <div>{data.value}</div>;
}

원인 6: 빌드 캐시 오염

코드는 맞는데 .next 캐시가 오래된 경우다. 주로 use client 경계 변경 후 발생한다.

# 캐시 완전 삭제 후 재시작
rm -rf .next
npm run dev

# 프로덕션 빌드라면
rm -rf .next
npm run build
npm start

빠른 진단 체크리스트

증상 의심 원인 해결
window is not defined 브라우저 API 직접 접근 useEffect 안에서 접근
날짜/숫자 값 불일치 new Date(), Math.random() 렌더 중 사용 useEffect 또는 서버 props 전달
인증 UI 불일치 인증 상태 SSR isLoaded 체크 후 렌더
코드 변경 후 갑자기 발생 빌드 캐시 오염 rm -rf .next 후 재시작
PPR 활성화 후 발생 Suspense 경계 없음 동적 컴포넌트 Suspense 래핑

정리

Hydration Error는 "서버에서 만든 HTML과 클라이언트 첫 렌더가 다르다"는 한 가지 원인에서 파생된다. 원인 찾는 순서:

  1. 브라우저 전용 API(window, document, localStorage) 렌더 중 직접 접근 여부 확인
  2. new Date(), Math.random() 같은 비결정론적 값 렌더 중 사용 여부 확인
  3. .next 캐시 삭제 후 재시작
  4. 인증 라이브러리 상태 로드 완료 전 렌더 여부 확인
  5. PPR 설정과 Suspense 경계 검토

대부분은 1~3번에서 해결된다.

댓글 없음:

댓글 쓰기