Next.js SSR에서 조건부 렌더링 시 발생하는 Hydration Error의 원인과 해결법
Next.js에서 서버사이드 렌더링(SSR)을 사용할 때 조건부 렌더링이 섞여 있다면 종종 다음과 같은 에러를 보게 된다.
Hydration failed because the initial UI does not match what was rendered on the server.
이 에러는 단순한 경고가 아니다. 서버가 렌더링한 HTML과 클라이언트가 브라우저에서 렌더링한 결과가 다르다는 뜻이다. 즉, 서버와 클라이언트의 렌더 트리가 불일치한다는 의미다.
1. Next.js의 렌더링 구조
Next.js에서 페이지가 렌더링되는 과정은 다음과 같다.
(1) Server Rendering
서버는 React 컴포넌트를 실행해 HTML 문자열을 만든다. 이 HTML은 브라우저로 전달되어 바로 화면에 표시된다. 이 시점에서는 자바스크립트가 실행되지 않는다.
(2) Hydration
클라이언트는 서버에서 전달된 HTML을 React 트리로 복원한다. 서버가 만든 DOM과 클라이언트가 만든 가상 DOM이 완전히 일치해야 한다. 조금이라도 다르면 Hydration Error가 발생한다.
(3) Interaction
하이드레이션이 끝나면 브라우저에서 실제 이벤트 핸들러가 연결되고, 인터랙션이 가능해진다.
2. 문제의 핵심: 조건부 렌더링 불일치
Hydration Error는 대부분 조건부 렌더링이 서버와 클라이언트에서 다르게 평가될 때 발생한다.
서버는 window, localStorage, matchMedia 같은 브라우저 전용 객체를 모른다. 따라서 이런 값으로 조건을 걸면 서버와 클라이언트의 렌더링 결과가 달라진다.
잘못된 예시
export default function Nav() {
const isMobile = window.innerWidth < 768;
return isMobile ? <MobileNav /> : <DesktopNav />;
}
- 서버:
window가 없음 →isMobile은false→<DesktopNav /> - 클라이언트:
window.innerWidth < 768→true→<MobileNav />
결과적으로 두 렌더 트리가 다르기 때문에 React는 “서버가 만든 HTML과 클라이언트의 계산 결과가 다르다”고 판단하고 에러를 발생시킨다.
3. Hydration Error를 일으키는 일반적인 케이스
| 구분 | 원인 예시 |
|---|---|
| 브라우저 전용 객체 | window, localStorage, navigator, matchMedia |
| 시간 의존 | Date.now(), new Date() |
| 랜덤 값 | Math.random(), uuid() |
| 사용자 환경에 따라 분기 | 다크 모드 감지, 뷰포트 크기 체크 |
| App Router 혼합 문제 | Server Component에서 Client Component를 조건부로 렌더링 |
4. 안전한 해결법
1) 마운트 후 렌더링 (Client Only Render)
SSR 시에는 빈 껍데기만 내보내고, 클라이언트 마운트 이후 실제 UI를 그린다.
"use client";
import { useEffect, useState } from "react";
export default function SafeRender() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <div aria-hidden />; // SSR과 동일한 마크업 유지
return <ClientOnlyUI />;
}
서버와 클라이언트가 동일한 HTML 구조를 유지하므로 Hydration Error를 피할 수 있다.
2) SSR 비활성화 (Dynamic Import)
SSR이 불필요한 컴포넌트는 아예 클라이언트 전용으로 만든다.
import dynamic from "next/dynamic";
const ClientOnly = dynamic(() => import("./ClientOnly"), { ssr: false });
export default function Page() {
return <ClientOnly />;
}
이 방법은 지도, 차트, 애니메이션처럼 브라우저 환경에 의존적인 컴포넌트에 적합하다.
3) 서버에서 조건을 계산해 클라이언트로 전달
조건 분기 로직을 서버에서 계산하면 클라이언트에서 다시 판단하지 않아도 된다.
// server component
export default async function Page() {
const userAgent = headers().get("user-agent");
const isMobile = /mobile/i.test(userAgent);
return <Nav isMobile={isMobile} />;
}
// client component
"use client";
export function Nav({ isMobile }: { isMobile: boolean }) {
return isMobile ? <MobileNav /> : <DesktopNav />;
}
이 방식은 SSR과 CSR의 일관성을 유지할 수 있어 가장 이상적인 형태다.
4) suppressHydrationWarning으로 예외 처리
렌더 결과가 달라도 실제 UI에 영향이 없는 경우(예: 시간 표시) 경고만 숨길 수 있다.
<span suppressHydrationWarning>{new Date().toLocaleTimeString()}</span>
단, 이 방법은 임시방편일 뿐 근본적인 해결책은 아니다.
5. 정리
| 원인 | 설명 | 해결 방법 |
|---|---|---|
| 브라우저 전용 코드 | window, localStorage 등 |
마운트 후 렌더링 |
| 환경에 따른 분기 | 화면 크기, 테마, 언어 | 서버에서 계산 후 props로 전달 |
| SSR 불필요 컴포넌트 | 애니메이션, 지도 등 | dynamic(..., { ssr: false }) |
| UI 일시적 불일치 | 시계, 랜덤 값 | suppressHydrationWarning |
결론
Next.js에서 SSR을 사용할 때 Hydration Error는 “서버와 클라이언트가 서로 다른 HTML을 만든다”는 신호다.
SSR 단계에서는 브라우저 전용 코드를 실행하지 않도록 설계하고, 조건부 렌더링은 마운트 이후로 미루는 것이 안전하다.
SSR과 CSR이 동일한 렌더 트리를 유지하는 것이 Next.js에서 안정적인 UI를 만드는 핵심이다.
'Error' 카테고리의 다른 글
| [React] React Hook 호출 순서 문제와 해결 방법: useQueries 활용하기 (0) | 2025.06.15 |
|---|---|
| [Blockchain] NFT metadata가 잘못올라가다. (0) | 2025.06.08 |
| [Blockchain] RPC url 을 구매하다. (0) | 2025.06.01 |
| [Blockchain] RPC server down (0) | 2025.05.18 |
| [WebView] How to Open in Chrome Browser (0) | 2025.04.27 |