Published on

Next.js App Router Hydration 오류 7가지 원인

Authors

서버가 만든 HTML과 브라우저에서 React가 다시 그리려는 결과가 조금이라도 다르면, Next.js App Router에서는 경고 또는 런타임 오류 형태로 hydration 문제가 터집니다. 특히 App Router는 Server ComponentClient Component가 섞이고, StreamingSuspense가 자연스럽게 들어가면서 “언제, 어디서, 무엇이 렌더링되는지”가 헷갈리기 쉬워졌습니다.

이 글은 현장에서 가장 많이 발생하는 Hydration 오류 원인 7가지를 “재현 가능한 코드 패턴”으로 정리하고, 각 원인의 안전한 해결 방법을 제공합니다.

참고로 RSC 캐시나 stale 데이터가 얽히면 “hydration 자체”가 아니라 “서버가 준 내용이 예상과 다르게 바뀌는 문제”로 보일 수 있습니다. 캐시 이슈가 의심되면 Next.js 14 RSC 캐시 꼬임·stale 데이터 해결법도 함께 확인해보세요.

Hydration 오류를 빠르게 분류하는 체크리스트

다음 중 하나라도 해당하면, 원인을 거의 반으로 좁힐 수 있습니다.

  • 서버 렌더 결과에 Date.now() / Math.random() / new Date() 같은 비결정적 값이 섞여 있다
  • window, document, localStorage 등 브라우저 전용 API를 렌더 단계에서 읽는다
  • 초기 렌더에 의존하는 상태가 useEffect에서 뒤늦게 바뀌며 마크업이 달라진다
  • 조건부 렌더링이 서버와 클라이언트에서 서로 다른 조건으로 동작한다
  • Suspense 경계, 스트리밍, 동적 import가 섞여 “초기 HTML”과 “클라이언트 첫 렌더”의 타이밍이 어긋난다

이제 대표 원인 7가지를 하나씩 보겠습니다.

1) 비결정적 값(Date, Math.random)을 렌더에 사용

서버에서 만든 HTML은 서버 시점의 값이고, 브라우저가 Hydration 하는 순간의 값은 또 다릅니다. 단 한 글자 차이만 나도 mismatch가 납니다.

문제가 되는 코드

// app/page.tsx (Server Component)
export default function Page() {
  return (
    <main>
      <p>build time: {new Date().toISOString()}</p>
      <p>nonce: {Math.random()}</p>
    </main>
  )
}

해결 방법 A: 서버에서만 확정된 값을 props로 내려 고정

// app/page.tsx
export default function Page() {
  const now = new Date().toISOString()
  const nonce = crypto.randomUUID() // 서버에서만 생성

  return (
    <main>
      <p>build time: {now}</p>
      <p>nonce: {nonce}</p>
    </main>
  )
}

해결 방법 B: 클라이언트에서만 보여줄 값은 마운트 이후 렌더

'use client'

import { useEffect, useState } from 'react'

export function ClientNow() {
  const [now, setNow] = useState<string | null>(null)

  useEffect(() => {
    setNow(new Date().toISOString())
  }, [])

  return <span>{now ?? ''}</span>
}

핵심은 “초기 HTML에 박히는 값”이 서버와 클라이언트에서 동일해야 한다는 점입니다.

2) 브라우저 전용 API를 렌더 단계에서 접근

App Router에서는 기본이 Server Component이므로, 무심코 window를 참조하면 서버에서 터지거나(ReferenceError), 혹은 조건 분기가 꼬여 서버와 클라이언트 결과가 달라질 수 있습니다.

문제가 되는 코드

'use client'

export default function Header() {
  const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
  return <header data-theme={isDark ? 'dark' : 'light'} />
}

서버 HTML에는 data-theme가 없거나 다른 값인데, 클라이언트 첫 렌더에서 바로 바뀌면 mismatch가 발생합니다.

해결: 초기값을 고정하고, 마운트 이후 업데이트

'use client'

import { useEffect, useState } from 'react'

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

  useEffect(() => {
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
    setTheme(isDark ? 'dark' : 'light')
  }, [])

  return <header data-theme={theme} />
}

추가로, 테마는 cookie로 서버에서 결정해 내려주는 방식이 가장 안정적입니다(서버와 클라이언트가 같은 소스 오브 트루스를 보게 만들기).

3) useState 초기값이 서버/클라이언트에서 다르게 계산됨

useState(() = 초기화 함수는 클라이언트에서도 실행됩니다. 초기화 로직이 브라우저 환경에 의존하면 서버 렌더와 첫 렌더가 달라집니다.

문제가 되는 코드

'use client'

import { useState } from 'react'

export default function Banner() {
  const [show, setShow] = useState(() => {
    return localStorage.getItem('hideBanner') !== '1'
  })

  if (!show) return null
  return <div>Welcome</div>
}

서버에는 localStorage가 없어서 다른 렌더 경로를 타거나, 빌드/런타임에서 예외를 유발합니다.

해결: 초기 렌더는 결정적 값, 이후 useEffect로 동기화

'use client'

import { useEffect, useState } from 'react'

export default function Banner() {
  const [show, setShow] = useState(true)

  useEffect(() => {
    const hidden = localStorage.getItem('hideBanner') === '1'
    setShow(!hidden)
  }, [])

  if (!show) return null
  return <div>Welcome</div>
}

“초기 HTML이 항상 동일하게 나오게” 만든 뒤, 클라이언트에서만 상태를 맞추는 전략입니다.

4) 조건부 렌더링 분기가 서버/클라이언트에서 달라짐

대표적으로 인증 상태, AB 테스트, 지역/언어, 기능 플래그가 서버와 클라이언트에서 서로 다른 기준으로 계산될 때 발생합니다.

문제가 되는 코드(인증)

'use client'

export default function Nav() {
  const token = sessionStorage.getItem('token')
  return (
    <nav>
      {token ? <a href="/me">My</a> : <a href="/login">Login</a>}
    </nav>
  )
}

서버 HTML은 Login으로 렌더됐는데, 클라이언트 첫 렌더에서 My로 바뀌면 mismatch가 납니다.

해결 방법 A: 인증 판단을 서버로 올리고 쿠키 기반으로 렌더

// app/components/Nav.tsx (Server Component)
import { cookies } from 'next/headers'

export default function Nav() {
  const token = cookies().get('token')?.value

  return (
    <nav>
      {token ? <a href="/me">My</a> : <a href="/login">Login</a>}
    </nav>
  )
}

해결 방법 B: 클라이언트 전용 영역은 스켈레톤으로 고정

'use client'

import { useEffect, useState } from 'react'

export function ClientAuthLink() {
  const [ready, setReady] = useState(false)
  const [token, setToken] = useState<string | null>(null)

  useEffect(() => {
    setToken(sessionStorage.getItem('token'))
    setReady(true)
  }, [])

  if (!ready) return <span style={{ display: 'inline-block', width: 60 }} />
  return token ? <a href="/me">My</a> : <a href="/login">Login</a>
}

핵심은 “서버가 만든 첫 화면 구조를 클라이언트가 그대로 존중”하도록 만드는 것입니다.

5) HTML 구조 규칙 위반(특히 table, p, li)로 브라우저가 DOM을 자동 수정

Hydration은 “서버 HTML 문자열”과 “React가 만든 가상 DOM”을 맞춰가는 과정입니다. 그런데 브라우저는 잘못된 HTML을 만나면 DOM을 자동으로 고쳐버립니다. 그러면 React가 기대한 노드 트리와 실제 DOM 트리가 달라져 mismatch가 납니다.

문제가 되는 코드

export default function BadTable() {
  return (
    <table>
      <tr>
        <td>A</td>
      </tr>
    </table>
  )
}

브라우저는 tbody를 암묵적으로 삽입할 수 있고, 이 차이로 경고가 발생할 수 있습니다.

해결: 올바른 시맨틱 구조로 작성

export default function GoodTable() {
  return (
    <table>
      <tbody>
        <tr>
          <td>A</td>
        </tr>
      </tbody>
    </table>
  )
}

비슷하게 p 안에 div를 넣거나, ul 아래에 div를 끼우는 패턴도 피하세요.

6) Suspense/스트리밍 경계에서 초기 렌더 내용이 흔들림

App Router는 서버에서 Streaming으로 HTML을 점진적으로 내려줄 수 있습니다. 이때 Suspense fallback이 서버와 클라이언트에서 다르게 나타나거나, “fallback이 보였다가 사라지는 타이밍”이 서버 HTML과 클라이언트 첫 렌더에서 불일치하면 Hydration 경고가 발생할 수 있습니다.

문제가 되는 코드(클라이언트에서만 데이터 준비)

import { Suspense } from 'react'
import ClientWidget from './ClientWidget'

export default function Page() {
  return (
    <Suspense fallback={<div>Loading</div>}>
      <ClientWidget />
    </Suspense>
  )
}

ClientWidget이 마운트 즉시 상태를 바꾸거나, 렌더 결과가 환경에 따라 달라지면 fallback과 실제 UI의 교체 과정에서 mismatch가 더 쉽게 드러납니다.

해결: 서버에서 가능한 데이터는 서버에서 준비, 클라이언트는 “상호작용”만

// app/page.tsx
import { Suspense } from 'react'
import ServerSummary from './ServerSummary'

export default function Page() {
  return (
    <main>
      <Suspense fallback={<div>Loading summary</div>}>
        <ServerSummary />
      </Suspense>
    </main>
  )
}
// app/ServerSummary.tsx (Server Component)
export default async function ServerSummary() {
  const res = await fetch('https://example.com/api/summary', {
    cache: 'no-store',
  })
  const data = await res.json()

  return <pre>{JSON.stringify(data, null, 2)}</pre>
}

서버에서 확정 가능한 마크업을 최대한 서버에서 확정해두면, Hydration 단계에서 흔들릴 여지가 줄어듭니다.

7) 외부 라이브러리가 SSR을 전제로 하지 않거나, DOM을 직접 조작

차트, WYSIWYG 에디터, 지도 SDK, 광고 스크립트 등은 종종 다음 중 하나를 합니다.

  • 렌더 도중 document.createElement 같은 DOM 조작
  • 마운트 직후 내부적으로 DOM을 갈아엎음
  • 서버 렌더 결과와 다른 마크업을 강제로 주입

이런 경우 서버 HTML과 클라이언트 렌더가 일치하기 어렵습니다.

해결 방법 A: 클라이언트 전용으로 분리 + 동적 import

// app/components/Chart.tsx
'use client'

import dynamic from 'next/dynamic'

const ChartImpl = dynamic(() => import('./ChartImpl'), {
  ssr: false,
  loading: () => <div style={{ height: 240 }}>Loading chart</div>,
})

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

해결 방법 B: 서버/클라이언트 공통 마크업을 “고정”하고 위에만 올리기

예를 들어 서버에서는 정적 이미지/요약만 렌더하고, 클라이언트에서만 인터랙티브 위젯을 덮어씌우는 방식이 훨씬 안정적입니다.

디버깅 팁: 어디서 mismatch가 시작됐는지 추적하는 방법

  1. 개발 모드에서 콘솔 경고의 컴포넌트 스택을 먼저 확인합니다.
  2. 문제가 의심되는 컴포넌트에서 “초기 렌더에 쓰이는 값”을 찾아봅니다.
    • 시간, 난수, 로케일, 타임존, 사용자별 상태, 스토리지 값
  3. 서버/클라이언트 렌더 결과를 비교합니다.
    • 같은 props로 같은 마크업이 나오는지
  4. 서버에서 확정할 수 있는 것은 서버로 올립니다.
    • 인증, 로케일, 기능 플래그는 쿠키/헤더 기반으로 서버에서 결정

운영에서 간헐적으로만 보인다면, 캐시와 데이터 일관성 문제와 섞여 보일 수 있습니다. 특히 App Router에서 fetch 캐시 전략이 의도와 다르면 “서버가 내려준 HTML이 사용자마다 다르게 보이는” 현상이 생기고, 그 결과 Hydration 경고로 관측되기도 합니다. 이 경우 Next.js 14 RSC 캐시 꼬임·stale 데이터 해결법을 같이 점검하세요.

정리: Hydration 오류를 없애는 가장 현실적인 원칙

  • 초기 HTML에 들어가는 값은 항상 결정적이어야 합니다(시간/난수/환경 의존 금지).
  • 브라우저 API는 렌더 단계가 아니라 useEffect 이후에 읽습니다.
  • 조건부 UI는 서버와 클라이언트가 같은 기준(쿠키/헤더/서버 세션)으로 판단하게 만듭니다.
  • 올바른 HTML 구조를 지켜 브라우저의 DOM 자동 수정을 피합니다.
  • DOM을 직접 만지는 라이브러리는 dynamicssr: false로 격리합니다.

위 7가지를 체크하면, App Router에서 발생하는 대부분의 Hydration 오류는 재현 가능하게 만들 수 있고(=원인 특정 가능), 안정적으로 제거할 수 있습니다.