- Published on
Next.js 14 RSC로 생기는 Hydration Error 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(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'로 분기한 JSXmatchMedia,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>
);
}
디버깅 체크리스트(빠른 순서)
- 비결정적 값이 JSX에 있는지:
Date.now,Math.random,new Date(), UUID, 정렬이 랜덤인 데이터 - locale/timezone 포맷이 서버/클라에서 다를 여지가 있는지
- 브라우저 API를 Server Component에서 쓰지 않았는지
- 조건부 렌더링이 환경 의존인지(UA, media query, window 존재 여부)
- fetch 캐시 정책이 페이지 성격과 일치하는지(
no-storevsrevalidate) <html>/<body>속성이 테마 등으로 바뀌는지(초기값 통일 or suppressHydrationWarning)- Invalid DOM nesting 경고가 있는지
결론: RSC 시대의 hydration error는 “경계”와 “결정성” 문제다
Next.js 14 RSC에서 hydration error는 대개 React 자체 버그가 아니라,
- 서버와 클라이언트가 서로 다른 입력값(시간/로케일/환경/캐시)을 가지고 렌더링했거나
- 서버/클라이언트 경계가 흐려져 브라우저 전용 로직이 서버로 새어 나왔거나
- HTML 구조가 브라우저 파서에 의해 자동 보정되었기 때문입니다.
위 7가지를 기준으로 코드를 “결정적으로” 만들고, 데이터 패칭/캐시 정책을 페이지 단위로 명확히 정리하면, hydration error의 80%는 재발하지 않게 관리할 수 있습니다.