- Published on
Next.js 14 RSC에서 hydration mismatch 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 렌더링된 HTML과 브라우저에서 React가 다시 그린 결과가 다르면, React는 hydration mismatch 경고를 내고(혹은 특정 노드를 버리고 재렌더링) UI가 순간적으로 흔들리거나 이벤트 바인딩이 꼬이는 현상이 생깁니다. Next.js 14에서 RSC(React Server Components)가 기본 축으로 자리 잡으면서, 이 문제는 단순히 “SSR과 CSR의 값이 달라서” 수준이 아니라 서버/클라이언트 경계가 섞인 상태에서 비결정적 값이 섞일 때 더 자주 드러납니다.
이 글에서는 Next.js 14 App Router 기준으로 hydration mismatch를 원인별로 분해하고, RSC 구조에서 안전하게 고치는 방법(그리고 애초에 안 만들도록 설계하는 방법)을 코드와 함께 정리합니다.
hydration mismatch가 RSC에서 더 잘 터지는 이유
App Router에서 페이지는 기본적으로 서버 컴포넌트로 렌더링됩니다. 서버 컴포넌트는 브라우저에서 실행되지 않기 때문에 다음이 자연스럽게 분리됩니다.
- 서버: DB 조회, 내부 API 호출, 쿠키 기반 인증, 민감한 로직
- 클라이언트: 브라우저 API, 이벤트 핸들러, 인터랙션
문제는 서버가 만든 HTML을 클라이언트가 “동일한 결과”로 재현해야 hydration이 안정적으로 끝난다는 점입니다. RSC 자체는 서버에서만 실행되지만, 서버 컴포넌트 트리 안에 클라이언트 컴포넌트가 섞이면, 그 클라이언트 컴포넌트의 초기 렌더링 결과가 서버가 내린 마크업과 정확히 맞아야 합니다.
즉, RSC 환경에서 hydration mismatch는 대개 아래 두 가지 패턴에서 발생합니다.
- 서버가 만든 마크업에 클라이언트에서만 알 수 있는 값이 섞임
- 서버와 클라이언트가 같은 코드를 실행해도 결과가 비결정적임
증상 빠르게 식별하기: 경고 메시지와 실제 원인 매핑
콘솔에서 흔히 보는 메시지는 다음 형태입니다.
Text content does not match server-rendered HTMLHydration failed because the initial UI does not match what was rendered on the server
하지만 메시지만으로는 “어디서”가 잘 안 잡힙니다. 실무에서는 다음 순서가 빠릅니다.
- 문제 페이지에서 클라이언트 컴포넌트 경계를 찾기: 파일 상단에
"use client"가 있는 컴포넌트부터 의심 - 그 컴포넌트가 초기 렌더에서 참조하는 값 중 시간/난수/로케일/스토리지/브라우저 크기가 있는지 확인
- 서버 렌더와 클라이언트 렌더가 달라질 수 있는 조건부 렌더링이 있는지 확인
추가로, hydration mismatch는 종종 레이아웃 흔들림(CLS)로도 체감됩니다. 원인과 해결이 렌더링 안정화로 이어진다는 점에서, CLS 튀는 이유? content-visibility로 렌더링 최적화 글의 “초기 렌더 안정화” 관점도 함께 보면 도움이 됩니다.
원인 1: Date.now() / Math.random() / 타임존 등 비결정적 값
서버에서 렌더링할 때의 시간과, 브라우저에서 hydration 시점의 시간이 다르면 텍스트가 달라집니다.
잘못된 예
// app/page.tsx (Server Component)
import ClientClock from "./ClientClock";
export default function Page() {
// 서버에서 렌더링된 시각
const serverNow = Date.now();
return (
<main>
<p>serverNow: {serverNow}</p>
<ClientClock />
</main>
);
}
// app/ClientClock.tsx
"use client";
export default function ClientClock() {
// 클라이언트 hydration 시각
const clientNow = Date.now();
return <p>clientNow: {clientNow}</p>;
}
둘 다 렌더링되며 사용자에게 “서버와 클라이언트 값이 다름”이 드러나기도 하지만, 더 흔한 케이스는 같은 위치에 같은 텍스트를 렌더하려다 mismatch가 나는 경우입니다.
해결 패턴 A: 서버에서 결정한 값을 클라이언트에 주입
// app/page.tsx
import ClientClock from "./ClientClock";
export default function Page() {
const now = Date.now();
return (
<main>
<ClientClock initialNow={now} />
</main>
);
}
// app/ClientClock.tsx
"use client";
import { useEffect, useState } from "react";
type Props = { initialNow: number };
export default function ClientClock({ initialNow }: Props) {
const [now, setNow] = useState(initialNow);
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
return <p>now: {now}</p>;
}
핵심은 첫 렌더는 서버와 동일한 값으로 시작하고, 이후에만 클라이언트에서 업데이트하는 것입니다.
해결 패턴 B: 첫 렌더에서 비결정적 값을 렌더하지 않기
“처음엔 placeholder, 마운트 후 실제 값” 패턴입니다.
"use client";
import { useEffect, useState } from "react";
export default function ClientOnlyTime() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <p>time: --</p>;
return <p>time: {new Date().toISOString()}</p>;
}
이 방식은 mismatch를 피하지만, UX 관점에서는 초기 공백/플리커가 생길 수 있어 “정말 필요한 값만” 이렇게 처리하는 게 좋습니다.
원인 2: 브라우저 전용 API를 초기 렌더에서 사용
다음 값들은 서버 렌더에서 존재하지 않거나, 서버와 클라이언트가 다르게 계산합니다.
window/documentlocalStorage/sessionStoragematchMedia, viewport widthnavigator.language
잘못된 예: localStorage 기반 토글
"use client";
export default function ThemeBadge() {
const theme = localStorage.getItem("theme") ?? "light";
return <span>theme: {theme}</span>;
}
서버는 localStorage를 모릅니다. 서버가 렌더한 마크업과 클라이언트 첫 렌더가 달라지기 쉽습니다.
해결: 서버에서 쿠키로 결정하거나, 마운트 후 읽기
권장 순서는 아래입니다.
- 가능한 경우: 쿠키로 테마를 결정해서 서버에서 동일하게 렌더
- 불가한 경우: 마운트 후
localStorage를 읽고 상태 업데이트
쿠키 기반 예시:
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
const theme = cookies().get("theme")?.value ?? "light";
return (
<html data-theme={theme}>
<body>{children}</body>
</html>
);
}
클라이언트에서 읽어야 한다면:
"use client";
import { useEffect, useState } from "react";
export default function ThemeBadge() {
const [theme, setTheme] = useState("light");
useEffect(() => {
const t = window.localStorage.getItem("theme") ?? "light";
setTheme(t);
}, []);
return <span>theme: {theme}</span>;
}
이때도 첫 렌더 값은 서버와 합의된 기본값(예: light)을 쓰는 게 안전합니다.
원인 3: 조건부 렌더링이 서버/클라이언트에서 다르게 평가됨
대표적으로 “로그인 여부”를 클라이언트에서만 판단하는 경우가 많습니다.
- 서버: 비로그인으로 렌더
- 클라이언트: hydration 직후 토큰을 읽고 로그인으로 렌더
그 결과 헤더 메뉴 구조가 바뀌며 mismatch가 납니다.
해결: 인증 상태는 서버에서 결정하고 내려주기
Next.js 14에서는 서버에서 cookies()로 세션을 확인하고, 그 결과를 클라이언트 컴포넌트에 props로 전달하는 방식이 가장 덜 흔들립니다.
// app/Header.tsx (Server Component)
import { cookies } from "next/headers";
import HeaderClient from "./HeaderClient";
export default function Header() {
const token = cookies().get("session")?.value;
const isAuthed = Boolean(token);
return <HeaderClient isAuthed={isAuthed} />;
}
// app/HeaderClient.tsx
"use client";
type Props = { isAuthed: boolean };
export default function HeaderClient({ isAuthed }: Props) {
return (
<nav>
{isAuthed ? (
<a href="/account">Account</a>
) : (
<a href="/login">Login</a>
)}
</nav>
);
}
이렇게 하면 첫 렌더의 구조가 서버와 클라이언트에서 동일하게 고정됩니다.
원인 4: i18n, 숫자/날짜 포맷의 로케일 불일치
서버 런타임의 로케일과 브라우저 로케일이 다르면 Intl.NumberFormat 결과가 달라집니다.
해결: 로케일을 명시적으로 고정
- Accept-Language를 서버에서 파싱해 로케일을 결정
- 또는 앱 정책으로 단일 로케일을 강제
// app/Price.tsx
import "server-only";
type Props = { value: number; locale: string; currency: string };
export default function Price({ value, locale, currency }: Props) {
const text = new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(value);
return <span>{text}</span>;
}
클라이언트에서도 동일한 locale 값을 사용하도록 props로 내려주면 mismatch 가능성이 크게 줄어듭니다.
원인 5: 외부 DOM 변형(브라우저 확장, 서드파티 스크립트)
광고 스크립트, A/B 테스트 도구, 번역 확장 프로그램이 hydration 전에 DOM을 바꾸면 React 입장에서는 “서버가 준 HTML이 변조됨”이 됩니다.
대응 전략
- 서드파티 스크립트는
next/script로 로딩 전략을 통제 - hydration 전에 DOM을 건드리는 도구는 가능하면
afterInteractive이후로 미루기 - 특정 노드가 변형될 수밖에 없다면, 그 영역을 클라이언트 전용으로 격리
// app/ThirdParty.tsx
import Script from "next/script";
export default function ThirdParty() {
return (
<>
<Script
src="https://example.com/ab.js"
strategy="afterInteractive"
/>
</>
);
}
해결의 핵심: 서버/클라이언트 경계 재설계 체크리스트
hydration mismatch를 “땜질”로 막는 것보다, RSC 구조를 아래 원칙으로 재정렬하는 게 재발 방지에 효과적입니다.
1) 서버에서 결정 가능한 값은 서버에서 확정한다
- 인증 상태: 쿠키 기반으로 서버에서 확정
- 초기 데이터: 서버 컴포넌트에서 fetch 후 props로 전달
- 로케일: 서버에서 결정 후 전달
2) 클라이언트 컴포넌트의 첫 렌더는 결정적으로 만든다
- 첫 렌더에서
Date.now()같은 값 렌더 금지 - 첫 렌더에서
localStorage읽지 않기 - 첫 렌더에서 viewport 기반 분기 금지
3) 조건부 렌더링은 “구조”를 바꾸지 말고 “내용”만 바꾼다
메뉴 아이템 자체를 추가/삭제하는 대신, 동일한 레이아웃을 유지하고 텍스트나 disabled 상태만 바꾸면 mismatch와 CLS를 동시에 줄일 수 있습니다.
이 과정에서 불필요한 리렌더까지 동반된다면, hydration 이후 렌더 폭발로 체감 성능이 떨어질 수 있습니다. 관련해서는 React 렌더링 폭발 - useMemo·key로 리렌더 차단 글의 패턴을 함께 적용하면 좋습니다.
실전 디버깅: 문제 컴포넌트를 빠르게 격리하는 방법
방법 A: 클라이언트 컴포넌트를 임시로 동적 로딩(SSR 비활성)해 원인 확인
원인 파악용으로만 쓰고, 장기 해결책으로 남발하진 않는 것을 권장합니다.
// app/page.tsx
import dynamic from "next/dynamic";
const SuspiciousWidget = dynamic(() => import("./SuspiciousWidget"), {
ssr: false,
});
export default function Page() {
return (
<main>
<SuspiciousWidget />
</main>
);
}
이렇게 했을 때 mismatch가 사라지면, 해당 위젯의 “첫 렌더 결정성”이 깨져 있다는 뜻입니다.
방법 B: 서버 컴포넌트에서 값 스냅샷을 찍어 props로 고정
특정 값이 서버/클라이언트에서 달라지는지 확인하려면, 서버에서 계산한 값을 props로 내려서 비교합니다.
// app/DebugSnapshot.tsx (Server Component)
import ClientDebug from "./ClientDebug";
export default function DebugSnapshot() {
const snapshot = {
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
now: Date.now(),
};
return <ClientDebug snapshot={snapshot} />;
}
// app/ClientDebug.tsx
"use client";
import { useEffect } from "react";
type Props = { snapshot: { tz: string; now: number } };
export default function ClientDebug({ snapshot }: Props) {
useEffect(() => {
const clientTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// 콘솔 비교로 차이를 확인
console.log("server snapshot", snapshot);
console.log("client tz", clientTz, "client now", Date.now());
}, [snapshot]);
return null;
}
이 비교로 “차이가 나는 축”을 좁히면, 고치는 방향(서버에서 확정 vs 마운트 후 계산)이 명확해집니다.
suppressHydrationWarning는 언제 쓰고, 언제 피해야 하나
React에는 특정 노드의 mismatch 경고를 무시하는 suppressHydrationWarning 옵션이 있습니다. 하지만 이것은 증상을 숨길 뿐이며, 실제로 DOM이 갈아엎어지거나 이벤트 연결이 어긋나는 문제를 해결하지 못할 수 있습니다.
정말로 “서버와 클라이언트가 다를 수밖에 없는 텍스트”에만 제한적으로 쓰는 것이 좋습니다.
export default function NonDeterministicText({ text }: { text: string }) {
return <span suppressHydrationWarning>{text}</span>;
}
권장 사용처는 “클라이언트 전용에서만 의미가 있는 값(예: 실시간 시각)”을 표시하되, 레이아웃/구조에 영향이 없고 사용자에게도 큰 문제가 없는 경우 정도입니다.
정리: RSC 시대의 hydration mismatch는 “경계 설계” 문제다
Next.js 14 RSC에서 hydration mismatch를 줄이는 가장 확실한 방법은 다음 한 줄로 요약됩니다.
- 첫 렌더 결과를 서버와 클라이언트가 동일하게 만들고, 달라질 값은 마운트 이후에만 바꾼다.
이를 위해서는 서버에서 결정 가능한 값(인증, 로케일, 초기 데이터)을 서버에서 확정하고, 클라이언트 컴포넌트는 “초기 렌더 결정성”을 지키는 방향으로 설계해야 합니다. 그 결과로 mismatch뿐 아니라 초기 레이아웃 흔들림과 불필요한 리렌더까지 함께 줄어드는 경우가 많습니다.
추가로, 렌더 안정화 관점은 CLS 튀는 이유? content-visibility로 렌더링 최적화도 같이 참고하면, 사용자 체감 품질까지 한 번에 개선하는 데 도움이 됩니다.