Published on

Next.js 14 Hydration mismatch 7가지 해결법

Authors

서버가 만든 HTML과 브라우저가 첫 렌더링에서 만든 결과가 달라지면 React는 Hydration mismatch 또는 Hydration failed 류의 경고를 띄웁니다. Next.js 14(App Router, RSC)는 서버 렌더링 비중이 크고, 서버 컴포넌트와 클라이언트 컴포넌트가 섞이기 때문에 “서버에서 그린 마크업”과 “클라이언트가 재현한 마크업”의 미세한 차이가 더 쉽게 노출됩니다.

이 글에서는 Next.js 14에서 자주 만나는 mismatch 패턴을 7가지로 분류하고, 각각을 어떻게 재현되는지어떤 방식으로 고치는지를 코드 중심으로 정리합니다.

관련 경고의 원인/증상을 더 넓게 보고 싶다면 다음 글도 함께 참고하세요.


1) 랜덤/시간 기반 값(Math.random, Date.now)을 SSR에 섞지 않기

왜 mismatch가 나나

서버 렌더 시점과 브라우저 첫 렌더 시점의 값이 다르면, 텍스트/속성/스타일이 달라져 mismatch가 납니다.

문제 코드

// app/page.tsx (Server Component)
export default function Page() {
  const id = Math.random().toString(16).slice(2)
  return <div data-id={id}>id: {id}</div>
}

해결법 A: 서버에서 고정된 값만 만들기

서버에서 만든 값이 클라이언트에서도 “동일하게 재현”되어야 합니다. 랜덤이 필요하면 SSR 단계에서 결정하고 클라이언트는 그 값을 그대로 사용하게 하세요.

// app/page.tsx
import crypto from 'node:crypto'

export default function Page() {
  const id = crypto.randomUUID() // 서버에서 한 번 결정
  return <div data-id={id}>id: {id}</div>
}

해결법 B: 클라이언트에서만 생성하고 SSR 출력은 고정

SSR 마크업에는 랜덤을 넣지 않고, 클라이언트에서 마운트 후 세팅합니다.

'use client'

import { useEffect, useState } from 'react'

export function ClientOnlyId() {
  const [id, setId] = useState<string>('')

  useEffect(() => {
    setId(Math.random().toString(16).slice(2))
  }, [])

  return <div data-id={id || 'pending'}>id: {id || 'pending'}</div>
}

2) 로케일/타임존/포맷 차이(toLocaleString) 고정하기

왜 mismatch가 나나

서버는 보통 UTC 또는 서버 로케일로 렌더하고, 브라우저는 사용자 로케일/타임존으로 렌더합니다. toLocaleString() 같은 포맷은 환경 의존적이라 서버와 클라이언트 결과가 달라지기 쉽습니다.

문제 코드

// app/page.tsx
export default function Page() {
  const text = new Date().toLocaleString() // 환경에 따라 다름
  return <p>{text}</p>
}

해결법 A: 서버에서 명시적으로 로케일/타임존 지정

// app/page.tsx
export default function Page() {
  const dt = new Date('2026-01-01T00:00:00.000Z')
  const text = new Intl.DateTimeFormat('ko-KR', {
    timeZone: 'Asia/Seoul',
    dateStyle: 'medium',
    timeStyle: 'short',
  }).format(dt)

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

해결법 B: “표시”는 클라이언트에서만 하되, SSR은 중립 텍스트

SEO가 중요하면 ISO 문자열 등 안정적인 값을 SSR에 두고, 사용자 로컬 표기는 클라이언트에서 덮어씁니다.

'use client'

import { useMemo } from 'react'

export function LocalTime({ iso }: { iso: string }) {
  const text = useMemo(() => {
    return new Intl.DateTimeFormat(undefined, {
      dateStyle: 'medium',
      timeStyle: 'short',
    }).format(new Date(iso))
  }, [iso])

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

3) 브라우저 전용 API(window, document, localStorage)를 SSR에서 실행하지 않기

왜 mismatch가 나나

서버는 window가 없고, 클라이언트는 있습니다. 조건 분기/초기 값이 달라져 렌더 결과가 달라지면 mismatch가 납니다.

문제 코드

// app/page.tsx
export default function Page() {
  const theme = localStorage.getItem('theme') // 서버에서 불가
  return <div data-theme={theme}>...</div>
}

해결법: 클라이언트 컴포넌트로 옮기고 초기 렌더를 일관되게

핵심은 첫 렌더의 HTML을 서버/클라이언트가 동일하게 만들기입니다. 예를 들어, SSR에서는 data-theme를 항상 light로 두고, 마운트 후 실제 값을 반영합니다.

'use client'

import { useEffect, useState } from 'react'

export function ThemeGate() {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')

  useEffect(() => {
    const v = window.localStorage.getItem('theme')
    if (v === 'dark') setTheme('dark')
  }, [])

  return <div data-theme={theme}>content</div>
}

추가로, 쿠키 기반으로 테마를 SSR에서 확정할 수도 있습니다. 이 경우엔 cookies()로 서버에서 읽고, 클라이언트는 그 값을 그대로 받아 초기 렌더를 맞추는 방식이 안정적입니다.


4) 조건부 렌더링이 “환경”에 따라 갈리지 않게 하기

왜 mismatch가 나나

typeof window !== 'undefined' 같은 분기에서 서버와 클라이언트가 서로 다른 트리를 렌더하면 mismatch가 발생합니다.

문제 코드

export default function Page() {
  const isClient = typeof window !== 'undefined'
  return (
    <div>
      {isClient ? <span>client</span> : <span>server</span>}
    </div>
  )
}

해결법 A: “같은 트리”를 유지하고 내용만 마운트 후 변경

'use client'

import { useEffect, useState } from 'react'

export function ClientFlag() {
  const [ready, setReady] = useState(false)
  useEffect(() => setReady(true), [])

  return <span>{ready ? 'client' : 'server'}</span>
}

해결법 B: 클라이언트 전용 UI는 동적 import로 SSR 비활성화

지도, 차트, 에디터처럼 SSR 의미가 거의 없는 컴포넌트는 SSR을 끄는 편이 낫습니다.

// app/page.tsx
import dynamic from 'next/dynamic'

const Chart = dynamic(() => import('./Chart'), { ssr: false })

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

5) 외부 데이터 패칭의 “캐시/재검증”으로 서버 HTML이 흔들리지 않게 하기

왜 mismatch가 나나

Next.js 14의 RSC는 fetch 캐시, revalidate, 라우트 세그먼트 캐시 등으로 인해 서버가 만든 HTML이 요청마다 달라질 수 있습니다. 여기에 클라이언트에서 별도 패칭(SWR/React Query)을 동시에 하면, 서버에서 그린 초기 화면클라이언트가 즉시 덮어쓴 화면이 달라져 mismatch처럼 보이거나 경고로 이어질 수 있습니다.

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

  • 서버는 캐시된 오래된 데이터를 렌더
  • 클라이언트는 즉시 최신 데이터를 받아 렌더
  • 텍스트/리스트 개수/정렬이 달라짐

RSC 캐시 관련 이슈는 다음 글이 도움이 됩니다.

해결법: 서버와 클라이언트의 데이터 소스를 “하나로 합치기”

가장 단단한 방법은 서버에서 가져온 데이터를 클라이언트 상태 라이브러리의 초기값으로 주입하는 것입니다.

예시: React Query로 SSR 값과 CSR 값을 일치

// app/page.tsx (Server Component)
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query'
import { ClientList } from './ClientList'

async function getItems() {
  const res = await fetch('https://example.com/api/items', { cache: 'no-store' })
  return res.json() as Promise<Array<{ id: string; name: string }>>
}

export default async function Page() {
  const qc = new QueryClient()
  await qc.prefetchQuery({ queryKey: ['items'], queryFn: getItems })

  return (
    <HydrationBoundary state={dehydrate(qc)}>
      <ClientList />
    </HydrationBoundary>
  )
}
// app/ClientList.tsx
'use client'

import { useQuery } from '@tanstack/react-query'

async function getItems() {
  const res = await fetch('/api/items')
  return res.json() as Promise<Array<{ id: string; name: string }>>
}

export function ClientList() {
  const { data } = useQuery({ queryKey: ['items'], queryFn: getItems })

  return (
    <ul>
      {(data ?? []).map((it) => (
        <li key={it.id}>{it.name}</li>
      ))}
    </ul>
  )
}

포인트는 “서버가 렌더한 리스트”와 “클라이언트가 첫 렌더에서 가진 캐시”가 동일해지는 것입니다.


6) 리스트 key를 안정적으로 유지하기(인덱스 키 금지)

왜 mismatch가 나나

SSR과 CSR에서 배열 순서가 바뀌거나 항목이 추가/삭제되면, 인덱스를 key로 쓰는 순간 DOM 재사용이 꼬이면서 예상치 못한 텍스트/속성 차이가 발생할 수 있습니다. 결과적으로 hydration 경고가 나거나, 경고 없이도 UI가 순간적으로 뒤틀릴 수 있습니다.

문제 코드

export function List({ items }: { items: Array<{ id: string; name: string }> }) {
  return (
    <ul>
      {items.map((it, idx) => (
        <li key={idx}>{it.name}</li>
      ))}
    </ul>
  )
}

해결법: 서버/클라이언트 모두에서 동일한 안정 키 사용

export function List({ items }: { items: Array<{ id: string; name: string }> }) {
  return (
    <ul>
      {items.map((it) => (
        <li key={it.id}>{it.name}</li>
      ))}
    </ul>
  )
}

추가 팁:

  • 정렬이 바뀔 수 있으면 서버/클라이언트 정렬 기준을 통일하세요.
  • “새 항목이 앞에 추가되는” UI는 인덱스 키 사용 시 거의 항상 문제가 됩니다.

7) 정말 불가피한 차이는 suppressHydrationWarning으로 “국소 봉합”하기

언제 쓰나

광고 슬롯, A/B 테스트 배너, 클라이언트에서만 확정되는 사용자별 문구처럼 “서버에서 정확히 맞출 수 없는” 영역이 있습니다. 이런 경우 전체를 클라이언트 컴포넌트로 바꾸면 성능/SEO가 손해일 수 있으니, 차이가 나는 노드만 최소 범위로 억제하는 전략이 유효합니다.

예시

// app/page.tsx
export default function Page() {
  return (
    <div>
      <h1>Article</h1>
      <p suppressHydrationWarning>
        {new Date().toISOString()}
      </p>
    </div>
  )
}

주의점:

  • 이 옵션은 “문제를 해결”하는 게 아니라 “경고를 숨기고 해당 노드의 불일치를 허용”합니다.
  • 남용하면 실제 버그를 놓칩니다. 정말 불가피한 영역, 그리고 가장 작은 범위에만 적용하세요.

디버깅 체크리스트(재현을 줄이는 순서)

  1. 경고가 나는 컴포넌트를 최소 단위로 분리한 뒤, 그 컴포넌트가 Server인지 Client인지 확인
  2. 렌더 결과가 요청 시점/환경에 따라 달라지는 값이 있는지 확인: 시간, 랜덤, 로케일, 타임존
  3. 브라우저 전용 API 접근 여부 확인: window, document, localStorage, matchMedia
  4. 데이터 소스가 두 개인지 확인: 서버 패칭과 클라이언트 패칭이 같은 UI를 동시에 만지는지
  5. 리스트 렌더링에서 key가 안정적인지 확인
  6. 그래도 불가피하면 dynamic(..., { ssr: false }) 또는 suppressHydrationWarning을 최소 범위로 적용

마무리

Next.js 14의 hydration mismatch는 대부분 “서버와 클라이언트가 첫 화면에서 동일한 마크업을 만들지 못했다”로 귀결됩니다. 해결의 핵심은 한 가지입니다.

  • 첫 렌더에서 동일한 값/동일한 트리를 만들고
  • 차이가 필요한 부분은 마운트 이후에만 변경하거나
  • 아예 SSR 대상에서 제외하거나
  • 최후에만 불일치 허용 옵션을 사용

위 7가지를 순서대로 점검하면, 경고를 없애는 수준을 넘어 SSR과 CSR의 경계를 더 명확히 설계할 수 있습니다.