Published on

Next.js Hydration mismatch 경고 완전 해결법

Authors

서버 렌더링(SSR) 또는 정적 생성(SSG)으로 만들어진 HTML을 브라우저가 받아서 React가 이벤트 핸들러를 붙이고 상태를 이어받는 과정을 hydration이라고 합니다. 이때 서버가 만든 초기 HTML과 클라이언트가 첫 렌더에서 만든 결과가 조금이라도 다르면 React는 Hydration failed because the initial UI does not match what was rendered on the server 같은 경고를 띄웁니다.

Hydration mismatch는 단순 경고로 끝나지 않습니다. 실제로는 다음과 같은 비용이 발생할 수 있습니다.

  • React가 해당 서브트리를 버리고 클라이언트 렌더로 재생성하면서 성능 저하
  • 레이아웃 점프, 깜빡임, 입력 포커스 손실
  • 사용자 환경에서만 간헐적으로 재현되는 디버깅 난이도 증가

아래는 Next.js(App Router 및 Pages Router 공통)에 적용 가능한 “완전 해결” 접근법입니다. 핵심은 서버에서 만든 마크업과 클라이언트 첫 렌더의 마크업을 동일하게 만드는 것입니다.

Hydration mismatch의 5대 원인

1) 비결정적 값 사용: Date.now(), Math.random(), UUID

서버 렌더 시점과 브라우저 렌더 시점이 다르니 당연히 결과가 달라집니다.

  • 서버: Date.now()는 서버 시간
  • 클라이언트: hydration 시점의 브라우저 시간

특히 다음 패턴이 위험합니다.

  • 렌더 함수에서 랜덤/시간 기반 값을 직접 생성
  • key로 랜덤 값을 사용

2) 사용자 환경 의존: window, localStorage, navigator, matchMedia

서버에는 window가 없고, 설령 조건 분기해도 결과가 달라지면 mismatch가 납니다.

예: 다크모드 감지

  • 서버는 기본 라이트로 렌더
  • 클라이언트는 prefers-color-scheme: dark로 다크 렌더

3) 외부 데이터 불일치: 클라이언트에서만 다른 API 호출

서버 렌더에 사용한 데이터와 클라이언트 첫 렌더 데이터가 다르면 발생합니다.

  • SSR에서 A 데이터를 렌더
  • 클라이언트에서 즉시 재요청하여 B로 렌더

React Query, SWR을 쓸 때 dehydration을 제대로 안 하면 흔합니다.

4) DOM 구조가 달라지는 조건부 렌더링

예: isMobile을 서버에서 잘못 추정하거나, 클라이언트에서만 계산해 분기하면 DOM 트리가 달라집니다.

  • 서버: 데스크톱 헤더 DOM
  • 클라이언트: 모바일 헤더 DOM

5) 브라우저 확장/자동 번역/인라인 스크립트의 DOM 개입

이건 코드가 완벽해도 발생할 수 있습니다.

  • 자동 번역이 텍스트 노드를 바꿈
  • 확장 프로그램이 DOM에 속성 삽입

이 경우는 “내 코드”로 100퍼센트 제거는 어렵고, 영향을 최소화하는 방향으로 설계합니다.

진단: 어디서 mismatch가 나는지 빠르게 찾는 법

1) 경고 메시지의 컴포넌트 스택 확인

React는 대개 mismatch가 발생한 근처 컴포넌트 스택을 제공합니다. 먼저 해당 컴포넌트에서 아래 항목을 검색하세요.

  • Date.now() / Math.random() / new Date()
  • window / document / localStorage
  • Intl 포맷팅, toLocaleString()
  • navigator.language, timezone

2) 개발 모드에서 의도적으로 “서버와 동일한 값”을 강제

예를 들어 시간/난수 의심 시, 값을 props로 주입해 고정해보면 범인이 빨리 드러납니다.

3) 서버 HTML과 클라이언트 첫 렌더를 비교

브라우저에서 view-source:로 서버 HTML을 확인하고, hydration 직후 DOM이 바뀌는지 관찰합니다.

해결 패턴 1: 비결정적 값은 “서버에서 한 번만” 만들고 내려보내기

문제 코드

export default function Banner() {
  return <p>빌드 번호: {Math.random()}</p>
}

해결 1) 서버에서 생성한 값을 props로 전달

Pages Router라면 getServerSideProps 또는 getStaticProps에서 생성합니다.

export async function getServerSideProps() {
  return { props: { buildId: String(Date.now()) } }
}

export default function Banner({ buildId }: { buildId: string }) {
  return <p>빌드 번호: {buildId}</p>
}

App Router라면 서버 컴포넌트에서 생성하고 클라이언트 컴포넌트에 props로 넘깁니다.

// app/page.tsx (Server Component)
import ClientBanner from "./ClientBanner"

export default function Page() {
  const buildId = String(Date.now())
  return <ClientBanner buildId={buildId} />
}
// app/ClientBanner.tsx
"use client"

export default function ClientBanner({ buildId }: { buildId: string }) {
  return <p>빌드 번호: {buildId}</p>
}

핵심은 클라이언트 첫 렌더에서 새로 만들지 않는 것입니다.

해결 패턴 2: 클라이언트 전용 값은 “hydration 이후”에만 반영

서버와 클라이언트가 처음에는 동일한 UI를 렌더하고, 그 다음 useEffect로 클라이언트 전용 값을 반영하면 mismatch가 사라집니다.

안전한 useHasMounted

"use client"
import { useEffect, useState } from "react"

export function useHasMounted() {
  const [mounted, setMounted] = useState(false)
  useEffect(() => setMounted(true), [])
  return mounted
}

예: 로컬스토리지 기반 사용자 설정 표시

"use client"
import { useEffect, useState } from "react"
import { useHasMounted } from "./useHasMounted"

export default function UserMode() {
  const mounted = useHasMounted()
  const [mode, setMode] = useState("light")

  useEffect(() => {
    const v = localStorage.getItem("mode")
    if (v === "dark" || v === "light") setMode(v)
  }, [])

  if (!mounted) {
    // 서버와 동일한 초기 마크업을 보장하기 위해 플레이스홀더
    return <span>mode: light</span>
  }

  return <span>mode: {mode}</span>
}

포인트는 서버가 만든 HTML과 클라이언트 첫 렌더가 같은 문자열을 출력하도록 플레이스홀더를 설계하는 것입니다.

해결 패턴 3: 시간/로케일 포맷은 서버와 클라이언트 기준을 맞추기

toLocaleString()은 서버 런타임의 로케일과 타임존에 따라 결과가 달라집니다. 서버는 UTC, 클라이언트는 KST면 mismatch가 날 수 있습니다.

해결 1) 서버에서 포맷된 문자열을 내려보내기

export default function TimeLabel({ iso }: { iso: string }) {
  // 서버 컴포넌트에서 iso를 생성했다고 가정
  const formatted = new Date(iso).toISOString()
  return <time dateTime={iso}>{formatted}</time>
}

해결 2) 클라이언트에서 포맷하되, 첫 렌더는 고정 문자열

"use client"
import { useEffect, useState } from "react"

export default function LocalTime({ iso }: { iso: string }) {
  const [text, setText] = useState("-")

  useEffect(() => {
    const d = new Date(iso)
    setText(d.toLocaleString())
  }, [iso])

  return <time dateTime={iso}>{text}</time>
}

이 패턴은 SEO가 중요한 시간 표기에는 손해일 수 있으니, 가능하면 서버에서 포맷을 고정하는 편이 낫습니다.

해결 패턴 4: 반응형 분기 렌더 대신 CSS로 해결하거나, 서버 추정을 일관되게

위험한 패턴

"use client"
import { useEffect, useState } from "react"

export default function Header() {
  const [mobile, setMobile] = useState(false)

  useEffect(() => {
    setMobile(window.innerWidth < 768)
  }, [])

  return mobile ? <MobileHeader /> : <DesktopHeader />
}

첫 렌더에서 mobilefalse이므로 서버와 같아 보일 수 있지만, 실제로 서버에서 DesktopHeader를 렌더했는데 클라이언트에서 즉시 MobileHeader로 바뀌면 레이아웃 점프가 생깁니다. 더 나쁜 경우, 서버에서 UA 기반으로 모바일이라고 판단해 MobileHeader를 렌더했는데 클라이언트 첫 렌더는 DesktopHeader로 시작하면 mismatch가 납니다.

해결 1) DOM은 동일하게 두고 CSS로만 제어

export default function Header() {
  return (
    <>
      <div className="only-mobile"><MobileHeader /></div>
      <div className="only-desktop"><DesktopHeader /></div>
    </>
  )
}

이 방식은 DOM은 늘 존재하므로 비용이 있지만, hydration 안정성은 매우 높습니다.

해결 2) 서버에서 viewport를 모르면 분기하지 말고, 클라이언트에서만 바꾸되 초기 마크업을 통일

즉, 초기에 공통 헤더를 렌더하고 hydration 이후에만 교체합니다.

해결 패턴 5: 클라이언트 전용 컴포넌트는 동적 임포트로 SSR을 끄기

지도, 차트, 에디터처럼 SSR과 궁합이 나쁜 컴포넌트는 dynamic으로 SSR을 꺼서 mismatch 자체를 원천 차단할 수 있습니다.

import dynamic from "next/dynamic"

const Chart = dynamic(() => import("./Chart"), {
  ssr: false,
  loading: () => <div style={{ height: 240 }}>loading...</div>,
})

export default function Dashboard() {
  return <Chart />
}

주의점

  • SEO가 필요한 콘텐츠에 무분별하게 적용하면 검색 노출이 손해
  • loading UI를 서버와 클라이언트가 동일하게 렌더하도록 설계해야 깜빡임이 줄어듭니다

해결 패턴 6: 데이터 패칭 라이브러리의 SSR dehydrate를 정확히 적용

React Query를 예로 들면, 서버에서 가져온 데이터를 클라이언트 캐시에 주입하지 않으면 클라이언트가 첫 렌더에서 빈 상태로 시작하거나 다른 값을 가져오며 mismatch가 날 수 있습니다.

아래는 개념 예시입니다.

// 서버에서 prefetch 후 dehydratedState를 내려보낸다고 가정
// 실제 구현은 App Router 또는 Pages Router 구성에 따라 달라집니다.

"use client"
import { HydrationBoundary, QueryClient, QueryClientProvider } from "@tanstack/react-query"

export default function Providers({ dehydratedState, children }: any) {
  const [client] = React.useState(() => new QueryClient())

  return (
    <QueryClientProvider client={client}>
      <HydrationBoundary state={dehydratedState}>
        {children}
      </HydrationBoundary>
    </QueryClientProvider>
  )
}

중요한 건 서버 HTML을 만든 데이터와 클라이언트 첫 렌더의 데이터가 같아야 한다는 점입니다.

suppressHydrationWarning은 언제 쓰고, 언제 쓰면 안 되나

React는 특정 노드에 대해 mismatch 경고를 숨기는 suppressHydrationWarning을 제공합니다.

export default function Price({ value }: { value: number }) {
  return (
    <span suppressHydrationWarning>
      {value}
    </span>
  )
}

이건 “해결”이 아니라 “경고 숨김”입니다. 다음 조건을 만족할 때만 제한적으로 사용하세요.

  • 값이 사용자에게 중요하지 않은 장식적 텍스트
  • hydration 이후 곧바로 올바른 값으로 안정화됨
  • DOM 구조는 변하지 않고 텍스트만 변함

반대로 가격, 결제, 인증 상태처럼 핵심 정보에 쓰면 사용자 신뢰 문제로 이어질 수 있습니다. 인증 토큰이나 서명 검증 관련 이슈를 다룰 때는 캐시와 회전 전략을 점검하는 것이 더 근본적이며, 관련해서는 JWT invalid signature - JWK 회전·캐시 점검법도 함께 참고할 만합니다.

실전 체크리스트: 이 순서대로 하면 대부분 끝난다

  1. mismatch가 난 컴포넌트에서 시간/난수/로케일/환경 의존 값을 찾는다
  2. 렌더 함수에서 비결정적 값을 생성하지 않는다
  3. 클라이언트 전용 값은 useEffect 이후에만 반영하고, 첫 렌더는 서버와 동일하게 고정한다
  4. 데이터는 SSR과 CSR이 동일한 소스와 동일한 초기 값을 쓰도록 dehydrate 또는 props 주입을 한다
  5. SSR이 불필요한 무거운 위젯은 dynamic으로 ssr: false를 고려한다
  6. 최후의 수단으로 텍스트 노드에 한해 suppressHydrationWarning을 사용한다

부록: 재현 가능한 최소 예제와 고친 버전

재현 예제

export default function Page() {
  return (
    <main>
      <h1>hello</h1>
      <p>{new Date().toLocaleString()}</p>
    </main>
  )
}

서버와 브라우저의 로케일 또는 타임존이 다르면 높은 확률로 mismatch가 납니다.

수정 예제 1) 서버에서 문자열을 확정

export default function Page() {
  const nowIso = new Date().toISOString()
  return (
    <main>
      <h1>hello</h1>
      <p>{nowIso}</p>
    </main>
  )
}

수정 예제 2) 클라이언트에서만 로컬 포맷

"use client"
import { useEffect, useState } from "react"

export default function LocalClock() {
  const [text, setText] = useState("-")

  useEffect(() => {
    setText(new Date().toLocaleString())
  }, [])

  return <p>{text}</p>
}

마무리: Hydration mismatch는 “SSR과 CSR의 계약 위반”이다

Hydration mismatch를 완전히 없애려면, 서버가 만든 HTML과 클라이언트 첫 렌더 결과가 동일하다는 계약을 지키면 됩니다. 즉, 렌더 시점에 따라 달라지는 값은 렌더링 단계에서 생성하지 말고, 서버에서 확정해 내려보내거나 hydration 이후에만 반영하세요.

운영 환경에서 이 경고를 방치하면 사용자별로만 발생하는 UI 깨짐과 성능 저하가 누적됩니다. 특히 트래픽이 많고 콜드 스타트나 초기 응답 지연이 있는 환경이라면, 첫 페인트 이후의 재렌더 비용이 더 체감될 수 있습니다. 인프라 관점의 초기 응답 최적화는 GCP Cloud Run 503·Cold Start 원인과 튜닝도 함께 보면 전체 사용자 경험을 더 안정적으로 만들 수 있습니다.