Published on

Next.js 14 RSC로 생기는 Hydration Error 7가지

Authors

서버 컴포넌트(RSC) 기반의 Next.js 14(App Router)는 서버에서 만든 HTML클라이언트에서 재실행되는 React 트리가 정확히 일치해야 합니다. 이 일치 과정이 깨지면 콘솔에 익숙한 메시지가 뜹니다.

  • Hydration failed because the initial UI does not match what was rendered on the server.
  • Text content does not match server-rendered HTML.
  • Expected server HTML to contain a matching <...> in <...>.

RSC 자체가 문제라기보다, 서버/클라이언트 경계가 더 엄격해졌고, 캐시/스트리밍/동적 렌더링 옵션이 늘면서 “서버에서 본 것”과 “브라우저에서 다시 계산한 것”이 달라질 여지가 커졌습니다. 아래 7가지는 실무에서 가장 많이 밟는 지뢰입니다.

> 참고: RSC에서 브라우저 전용 API 때문에 터지는 대표 케이스는 별도로 정리해두었습니다. Next.js RSC에서 window is not defined 해결법


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

증상

서버에서 렌더링한 숫자/문자열이 클라이언트 hydration 시점에 달라져 텍스트 불일치가 납니다.

문제 코드

// app/page.tsx (Server Component)
export default function Page() {
  return (
    <main>
      <p>build id: {Math.random()}</p>
      <p>now: {Date.now()}</p>
    </main>
  );
}

해결 전략

  • 서버에서 확정된 값을 내려주고 클라이언트에서 재계산하지 않게 만들기
  • 혹은 클라이언트에서만 계산하고 서버에는 플레이스홀더를 렌더링

해결 예시 A: 서버에서 값 고정

// app/page.tsx
export default async function Page() {
  const buildId = crypto.randomUUID();
  const now = new Date().toISOString();

  return (
    <main>
      <p>build id: {buildId}</p>
      <p>now: {now}</p>
    </main>
  );
}

해결 예시 B: 클라이언트에서만 표시

// app/Now.tsx
'use client';

import { useEffect, useState } from 'react';

export function Now() {
  const [now, setNow] = useState<string>('');
  useEffect(() => setNow(new Date().toISOString()), []);

  return <p>now: {now || 'loading...'}</p>;
}

2) 로케일/타임존 차이로 toLocaleString() 결과가 달라짐

증상

서버(대개 UTC, en-US)와 사용자 브라우저(ko-KR, Asia/Seoul 등)의 locale/timezone이 달라 날짜/숫자 포맷이 달라집니다.

문제 코드

// Server Component
export default function Price() {
  const value = 1234567.89;
  return <p>{value.toLocaleString()}</p>; // 서버/클라 포맷 불일치 가능
}

해결 전략

  • 서버에서 locale/timezone을 명시해 포맷을 고정
  • 또는 서버는 raw 값만 렌더링하고, 포맷팅은 클라이언트에서만 수행

해결 예시: Intl 옵션 고정

export default function Price() {
  const value = 1234567.89;
  const formatted = new Intl.NumberFormat('ko-KR', {
    maximumFractionDigits: 2,
  }).format(value);

  return <p>{formatted}</p>;
}

3) 서버 컴포넌트에서 window, document, localStorage 등 브라우저 API 사용

증상

  • 단순히 hydration error가 아니라 SSR 단계에서 크래시하거나
  • 조건부 렌더링이 꼬여 서버/클라 마크업이 달라지기도 합니다.

문제 코드

// app/page.tsx (Server Component)
export default function Page() {
  const theme = localStorage.getItem('theme'); // 서버에는 localStorage 없음
  return <div data-theme={theme}>...</div>;
}

해결 전략

  • 브라우저 API는 무조건 Client Component로 분리
  • 혹은 dynamic(..., { ssr: false })로 SSR 제외

해결 예시: Client Component로 분리

// app/ThemeGate.tsx
'use client';

import { useEffect, useState } from 'react';

export function ThemeGate() {
  const [theme, setTheme] = useState('light');
  useEffect(() => {
    setTheme(localStorage.getItem('theme') ?? 'light');
  }, []);

  return <div data-theme={theme}>...</div>;
}

> 이 주제는 케이스가 많아 별도 글로 정리했습니다: Next.js RSC에서 window is not defined 해결법


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

증상

서버에서는 false라서 렌더링되지 않았는데, 클라이언트에서는 true가 되어 노드 구조가 바뀌며 hydration mismatch가 납니다.

대표 원인

  • typeof window !== 'undefined'로 분기한 JSX
  • matchMedia, navigator.userAgent, prefers-color-scheme 등 환경 의존 값

문제 코드

export default function Page() {
  const isClient = typeof window !== 'undefined';
  return (
    <main>
      {isClient && <p>only client</p>}
    </main>
  );
}

해결 전략

  • 서버에서는 항상 동일한 마크업을 렌더링하고
  • 클라이언트에서 useEffect 이후에만 추가 UI를 붙이기

해결 예시

'use client';

import { useEffect, useState } from 'react';

export function ClientOnly({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted ? children : null;
}

5) App Router 캐시/데이터 패칭 불일치로 “서버 HTML”이 예상과 다름

증상

개발 중에는 괜찮다가, 배포/리로드/프리패치 이후 특정 라우트에서만 hydration error가 발생합니다. 실제 원인은 hydration 자체가 아니라 서버가 내려준 HTML이 stale(오래된) 상태이거나, 서버/클라이언트가 서로 다른 데이터를 기준으로 렌더링한 경우입니다.

흔한 트리거

  • fetch()의 기본 캐시 동작을 잘못 이해
  • revalidate, dynamic, cache: 'no-store' 혼용
  • 라우트 전환 시 프리패치된 RSC payload와 실제 데이터가 어긋남

해결 전략

  • 데이터의 일관성을 먼저 정의: “이 페이지는 항상 최신인가? 일정 주기 재검증인가?”
  • 그에 맞춰 fetch 옵션과 route segment 설정을 통일

예시: 항상 최신(SSR 성격)

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

export default async function Page() {
  const res = await fetch('https://api.example.com/items', {
    cache: 'no-store',
  });
  const items = await res.json();

  return <pre>{JSON.stringify(items, null, 2)}</pre>;
}

예시: ISR 성격(주기 재검증)

// app/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/items', {
    next: { revalidate: 60 },
  });
  const items = await res.json();
  return <pre>{JSON.stringify(items, null, 2)}</pre>;
}

> 캐시 꼬임/불일치 이슈는 재현이 까다로워 별도 체크리스트가 필요합니다. Next.js 14 App Router 캐시 꼬임 해결법


6) <html>/<body>에 붙는 속성(className 등)이 서버/클라에서 달라짐

증상

다크모드/테마 라이브러리(next-themes 등) 사용 시 특히 많습니다.

  • 서버는 class="light"로 렌더
  • 클라이언트는 마운트 직후 class="dark"로 변경
  • 그 사이 hydration mismatch 경고가 발생

해결 전략

  • 서버와 클라이언트가 처음부터 동일한 테마를 결정하게 만들기(쿠키 기반)
  • 또는 Next.js 권장 패턴처럼 suppressHydrationWarning을 제한적으로 사용

예시: root layout에서 suppressHydrationWarning

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko" suppressHydrationWarning>
      <body suppressHydrationWarning>
        {children}
      </body>
    </html>
  );
}

주의할 점:

  • 이 옵션은 “문제를 숨기는” 도구입니다. 초기 테마 결정을 쿠키로 통일하는 쪽이 더 근본적입니다.

7) 잘못된 HTML 중첩/브라우저 자동 보정으로 DOM 구조가 달라짐

증상

서버가 만든 문자열 HTML은 정상처럼 보이지만, 브라우저가 파싱하면서 DOM을 자동으로 고쳐버려 React가 기대한 트리와 달라집니다.

대표 케이스

  • <p> 안에 <div>를 넣는 등 잘못된 중첩
  • 테이블 관련 태그 누락(tbody 등)으로 브라우저가 자동 삽입

문제 코드

export default function Page() {
  return (
    <p>
      <div>invalid nesting</div>
    </p>
  );
}

해결

  • React 경고(Invalid DOM nesting)를 hydration error보다 먼저 잡아야 합니다.
  • 마크업을 올바르게 수정

수정 코드

export default function Page() {
  return (
    <div>
      <p>valid</p>
      <div>also valid</div>
    </div>
  );
}

디버깅 체크리스트(빠른 순서)

  1. 비결정적 값이 JSX에 있는지: Date.now, Math.random, new Date(), UUID, 정렬이 랜덤인 데이터
  2. locale/timezone 포맷이 서버/클라에서 다를 여지가 있는지
  3. 브라우저 API를 Server Component에서 쓰지 않았는지
  4. 조건부 렌더링이 환경 의존인지(UA, media query, window 존재 여부)
  5. fetch 캐시 정책이 페이지 성격과 일치하는지(no-store vs revalidate)
  6. <html>/<body> 속성이 테마 등으로 바뀌는지(초기값 통일 or suppressHydrationWarning)
  7. Invalid DOM nesting 경고가 있는지

결론: RSC 시대의 hydration error는 “경계”와 “결정성” 문제다

Next.js 14 RSC에서 hydration error는 대개 React 자체 버그가 아니라,

  • 서버와 클라이언트가 서로 다른 입력값(시간/로케일/환경/캐시)을 가지고 렌더링했거나
  • 서버/클라이언트 경계가 흐려져 브라우저 전용 로직이 서버로 새어 나왔거나
  • HTML 구조가 브라우저 파서에 의해 자동 보정되었기 때문입니다.

위 7가지를 기준으로 코드를 “결정적으로” 만들고, 데이터 패칭/캐시 정책을 페이지 단위로 명확히 정리하면, hydration error의 80%는 재발하지 않게 관리할 수 있습니다.