Published on

Next.js 14 RSC에서 hydration mismatch 해결법

Authors

서버 컴포넌트(RSC)가 기본이 된 Next.js 14에서는 렌더링 경계가 바뀌면서, 예전보다 hydration mismatch를 더 자주 만나게 됩니다. 특히 App Router에서 use client를 섞어 쓰는 순간, 서버에서 만들어진 HTML과 브라우저에서 React가 다시 계산한 결과가 조금이라도 다르면 경고가 터집니다.

이 글에서는 “왜 RSC에서 mismatch가 더 흔해졌는지”를 먼저 구조적으로 설명하고, 이후에 실제로 많이 터지는 케이스별 해결 패턴을 코드로 정리합니다.

hydration mismatch란 무엇이고, Next.js 14에서 왜 더 잘 터지나

hydration mismatch는 간단히 말해 다음 상황입니다.

  • 서버가 HTML을 만들어서 내려줌
  • 브라우저에서 React가 해당 HTML을 “재사용”하며 이벤트 핸들러를 붙이려고 hydration 수행
  • 그런데 클라이언트에서 계산한 첫 렌더 결과가 서버 HTML과 다름

App Router + RSC에서는 서버 컴포넌트가 기본이므로 “서버에서 이미 많은 UI가 확정된 상태로 내려오는” 비중이 커집니다. 동시에, 일부 영역은 use client로 전환해 클라이언트 컴포넌트로 만들고 상태/이벤트를 붙입니다. 이때 다음과 같은 경계 문제로 mismatch가 발생합니다.

  • 서버에서만 가능한 값(요청 헤더, 쿠키, 사용자 에이전트)을 UI에 직접 반영했는데, 클라이언트 첫 렌더에서 동일하게 재현되지 않음
  • 클라이언트에서만 가능한 값(로컬 스토리지, window, 화면 크기)을 첫 렌더에 반영했는데 서버 HTML과 다름
  • 시간/난수/로케일 포맷이 서버와 클라이언트에서 달라짐
  • 외부 데이터가 SSR 시점과 hydration 시점 사이에 바뀜

핵심은 “서버와 클라이언트가 동일한 입력으로 동일한 결과를 내는가”입니다. RSC 자체가 문제라기보다, RSC 도입으로 경계가 더 명확해졌고, 경계를 넘나드는 값이 많아지면서 mismatch가 표면화되는 경우가 많습니다.

가장 흔한 원인 1: 시간/난수/비결정적 값

문제 패턴

서버에서 렌더링한 시각과 클라이언트에서 렌더링한 시각이 다르면 텍스트가 달라집니다.

// app/page.tsx (Server Component)
export default function Page() {
  return <p>Rendered at: {new Date().toISOString()}</p>
}

서버에서 만든 문자열과 클라이언트 hydration 시점의 문자열은 거의 항상 다릅니다.

해결 1) 서버에서 확정한 값을 props로 내려 “클라이언트도 동일 값”을 쓰게 하기

// app/page.tsx (Server Component)
import ClientTime from './ClientTime'

export default function Page() {
  const renderedAt = new Date().toISOString()
  return <ClientTime renderedAt={renderedAt} />
}
// app/ClientTime.tsx
'use client'

type Props = { renderedAt: string }

export default function ClientTime({ renderedAt }: Props) {
  return <p>Rendered at: {renderedAt}</p>
}

이렇게 하면 hydration 시점에도 동일 문자열을 사용하므로 mismatch가 사라집니다.

해결 2) “클라이언트에서만” 보여줄 값은 마운트 이후에 렌더

// app/ClientOnlyTime.tsx
'use client'

import { useEffect, useState } from 'react'

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

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

  // 서버/첫 렌더에서는 null로 동일하게 맞추고
  // 마운트 이후 값이 채워지도록 한다.
  return <p>Client time: {now ?? '...'}</p>
}

이 패턴은 “서버와 첫 클라이언트 렌더를 동일하게 맞춘다”는 점에서 매우 강력합니다.

가장 흔한 원인 2: window, localStorage, viewport 기반 분기

문제 패턴

// app/Nav.tsx
'use client'

export default function Nav() {
  const isMobile = window.innerWidth < 768
  return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>
}

이 코드는 서버에서 실행될 수 없고, 클라이언트에서도 “첫 렌더 시점”에 서버 HTML과 불일치할 가능성이 큽니다(서버는 데스크톱으로 렌더했는데 클라이언트는 모바일로 판단 등).

해결 1) CSS로 해결 가능한 것은 JS 분기를 없애기

반응형 UI는 가능한 한 CSS 미디어쿼리로 처리하세요. 텍스트 자체를 바꾸는 것이 아니라 레이아웃만 바꾸면 mismatch 리스크가 크게 줄어듭니다.

해결 2) 마운트 이후에만 viewport 기반 분기 적용

'use client'

import { useEffect, useState } from 'react'

export default function Nav() {
  const [isMobile, setIsMobile] = useState<boolean | null>(null)

  useEffect(() => {
    const calc = () => setIsMobile(window.innerWidth < 768)
    calc()
    window.addEventListener('resize', calc)
    return () => window.removeEventListener('resize', calc)
  }, [])

  if (isMobile === null) return <div>...</div>
  return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>
}

서버 HTML과 첫 렌더가 동일하게 ...로 맞춰지므로 mismatch를 피할 수 있습니다.

가장 흔한 원인 3: 서버에서 쿠키/헤더로 분기한 UI를 클라이언트가 재현 못함

RSC에서는 서버 컴포넌트에서 cookies()headers()로 사용자 상태를 쉽게 읽을 수 있습니다. 문제는 그 결과를 클라이언트 컴포넌트에서 “다시 계산”하려고 하면 입력이 달라진다는 점입니다.

문제 패턴

  • 서버: 쿠키에 theme=dark라서 다크 UI HTML을 내려줌
  • 클라이언트: 초기 상태는 라이트로 렌더하고, 마운트 후 쿠키를 읽어 다크로 바꿈
  • 결과: 첫 렌더 불일치

해결) 서버에서 확정한 값을 클라이언트 초기 상태로 주입

// app/layout.tsx (Server Component)
import { cookies } from 'next/headers'
import ThemeClientProvider from './theme/ThemeClientProvider'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  const theme = cookies().get('theme')?.value ?? 'light'

  return (
    <html lang="ko">
      <body>
        <ThemeClientProvider initialTheme={theme}>
          {children}
        </ThemeClientProvider>
      </body>
    </html>
  )
}
// app/theme/ThemeClientProvider.tsx
'use client'

import { createContext, useMemo, useState } from 'react'

type Theme = 'light' | 'dark'

export const ThemeContext = createContext<{
  theme: Theme
  setTheme: (t: Theme) => void
} | null>(null)

export default function ThemeClientProvider(
  { initialTheme, children }: { initialTheme: Theme; children: React.ReactNode }
) {
  const [theme, setTheme] = useState<Theme>(initialTheme)
  const value = useMemo(() => ({ theme, setTheme }), [theme])
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}

“서버에서 이미 결정한 값”을 클라이언트의 초기 state로 맞추면 mismatch를 거의 제거할 수 있습니다.

가장 흔한 원인 4: 잘못된 서버/클라이언트 경계(컴포넌트 쪼개기 실패)

RSC에서는 다음 규칙이 중요합니다.

  • 서버 컴포넌트는 브라우저 API를 쓰면 안 됨
  • 클라이언트 컴포넌트는 서버 전용 API(cookies(), headers())를 직접 못 씀
  • 따라서 “데이터 결정”과 “인터랙션”을 분리해야 함

권장 구조

  • Server: 데이터 패칭, 권한/테넌트/로케일 결정, 초기 props 생성
  • Client: 상태, 이벤트, 애니메이션, 브라우저 기반 기능
// app/products/page.tsx (Server Component)
import ProductListClient from './ProductListClient'

async function fetchProducts() {
  const res = await fetch('https://example.com/api/products', {
    cache: 'no-store',
  })
  if (!res.ok) throw new Error('Failed to fetch')
  return res.json() as Promise<Array<{ id: string; name: string }>>
}

export default async function Page() {
  const products = await fetchProducts()
  return <ProductListClient initialProducts={products} />
}
// app/products/ProductListClient.tsx
'use client'

import { useState } from 'react'

type Product = { id: string; name: string }

export default function ProductListClient({ initialProducts }: { initialProducts: Product[] }) {
  const [products] = useState(initialProducts)
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

이 방식은 서버 HTML과 클라이언트 첫 렌더가 동일한 initialProducts로 시작하므로 mismatch 가능성이 낮습니다.

가장 흔한 원인 5: 외부 데이터가 “SSR과 hydration 사이”에 바뀌는 경우

특히 cache: 'no-store' 또는 매우 짧은 revalidate를 쓰는 페이지에서, 서버가 렌더한 뒤 브라우저가 hydration 하기 전 사이에 데이터가 바뀌면 텍스트/리스트가 달라질 수 있습니다.

대응 전략

  • 사용자에게 “최신 데이터”가 꼭 필요하지 않다면 revalidate를 둬서 짧은 시간 동안은 동일 스냅샷을 유지
  • 혹은 서버에서 확정한 데이터를 클라이언트의 초기 데이터로 고정하고, 이후에 클라이언트에서 갱신(fetch)하되 UI 전환을 명시적으로 처리
'use client'

import { useEffect, useState } from 'react'

type Item = { id: string; title: string }

export default function Feed({ initialItems }: { initialItems: Item[] }) {
  const [items, setItems] = useState<Item[]>(initialItems)

  useEffect(() => {
    let cancelled = false
    ;(async () => {
      const res = await fetch('/api/feed')
      if (!res.ok) return
      const next = (await res.json()) as Item[]
      if (!cancelled) setItems(next)
    })()
    return () => {
      cancelled = true
    }
  }, [])

  return (
    <ul>
      {items.map((it) => (
        <li key={it.id}>{it.title}</li>
      ))}
    </ul>
  )
}

포인트는 “첫 렌더는 서버 스냅샷과 동일”하게 만들고, 갱신은 hydration 이후에 하도록 분리하는 것입니다.

suppressHydrationWarning는 언제 써도 되나

React에는 특정 노드의 mismatch 경고를 무시하는 suppressHydrationWarning이 있습니다. 하지만 이건 근본 해결이 아니라 “의도적으로 다를 수밖에 없는 값”에만 제한적으로 써야 합니다.

  • 좋은 예: 클라이언트에서만 의미 있는 타임스탬프, 랜덤 배너 문구 등
  • 나쁜 예: 사용자 권한, 결제 금액, 장바구니 수량처럼 정확성이 중요한 값
export default function NonDeterministic() {
  return (
    <span suppressHydrationWarning>
      {new Date().toISOString()}
    </span>
  )
}

이 방법을 남용하면 “경고는 사라지지만 UI는 순간적으로 바뀌는” 문제를 숨기게 됩니다.

디버깅 체크리스트: mismatch를 재현하고 원인을 좁히는 법

  1. 경고 메시지에서 “어느 컴포넌트 트리”에서 mismatch가 발생했는지 확인
  2. 해당 컴포넌트가 서버 컴포넌트인지, use client인지 먼저 구분
  3. 다음 항목이 있는지 검색
    • Date, Math.random, crypto 기반 값
    • window, document, localStorage, navigator
    • Intl 포맷(서버/클라이언트 타임존, 로케일 차이)
    • 조건부 렌더링이 서버 입력(쿠키/헤더)과 클라이언트 입력(스토리지/viewport)을 섞는지
  4. “서버에서 확정한 값”을 클라이언트 초기 state로 주입하는 방향으로 리팩터

이미지/레이아웃 변화도 mismatch처럼 보일 수 있다

엄밀히 말하면 이미지 로딩/레이아웃 시프트는 hydration mismatch와 별개지만, 실제 현장에서는 “초기 UI가 바뀌어 보인다”는 점에서 원인 분석을 더 어렵게 만듭니다. 특히 next/image 설정이 잘못되면 LCP와 함께 초기 레이아웃이 흔들려 hydration 문제로 오인되기도 합니다.

이미지 쪽이 의심된다면 다음 글도 함께 확인해보세요.

실전 결론: RSC에서 mismatch를 없애는 설계 원칙

  • 첫 렌더 결과가 서버와 클라이언트에서 같도록 “결정적 입력”만 사용한다
  • 브라우저 전용 값은 마운트 이후에 적용한다(useEffect로 지연)
  • 서버에서 결정한 값(쿠키/헤더/권한/로케일)은 클라이언트 초기 state로 주입한다
  • 데이터는 서버 스냅샷으로 첫 렌더를 고정하고, 갱신은 hydration 이후에 수행한다
  • suppressHydrationWarning는 의도적으로 달라질 수밖에 없는 작은 영역에만 제한적으로 사용한다

RSC는 클라이언트 번들을 줄이고 서버에서 더 많은 일을 하게 해주지만, 그만큼 “서버와 클라이언트가 같은 화면을 그리도록 입력을 통제하는 능력”이 중요해졌습니다. mismatch가 났다면 대부분은 버그라기보다 경계 설계의 신호이므로, 위 패턴대로 입력을 정리하면 재발을 크게 줄일 수 있습니다.