Published on

Zustand persist로 Hydration Mismatch 해결법

Authors

서버 사이드 렌더링(SSR)이나 React Server Components(RSC)를 사용하는 Next.js에서 Zustand의 persist 미들웨어를 붙이면, 개발자 도구 콘솔에 hydration mismatch 경고가 뜨는 경우가 많습니다. 대개는 서버에서 만든 초기 HTML브라우저에서 localStorage/sessionStorage를 읽어 복원한 상태가 달라서 생깁니다.

이 글에서는 Zustand persist가 왜 mismatch를 만들고, 실무에서 가장 안전하게 쓰는 해결책(게이팅, skipHydration, 수동 rehydrate, 스토리지 분리, 초기값 설계)을 코드로 정리합니다.

Hydration mismatch가 발생하는 구조

Next.js에서 SSR이 켜져 있으면 렌더링 흐름은 대략 이렇습니다.

  1. 서버에서 React가 컴포넌트를 렌더링해 HTML을 만든다(이때 브라우저 스토리지를 읽을 수 없음).
  2. 브라우저가 HTML을 받아 화면에 그린다.
  3. React가 같은 컴포넌트를 다시 실행해 이벤트 핸들러를 붙이며 “hydrate”한다.
  4. Zustand persist는 이 시점에 스토리지(localStorage 등)에서 값을 읽고 상태를 바꾼다.

문제는 1번과 3~4번 사이에 UI에 영향을 주는 값이 바뀌면 React가 “서버가 만든 마크업과 클라이언트가 만든 마크업이 다르다”고 판단한다는 점입니다.

대표적인 증상은 다음과 같습니다.

  • 서버에서는 isLoggedIn=false라서 “로그인” 버튼이 보였는데, 클라이언트에서 persist 복원 후 true가 되어 “로그아웃” 버튼으로 바뀜
  • 서버에서는 theme=light로 렌더됐는데, 클라이언트에서 dark로 복원되어 className/스타일이 달라짐
  • 서버에서는 cartCount=0인데, 클라이언트에서 3으로 복원됨

핵심은 간단합니다. 서버는 스토리지를 못 읽고, 클라이언트는 읽는다. 따라서 persist 상태를 그대로 SSR 출력에 섞으면 mismatch가 날 확률이 높습니다.

해결 전략 1: “rehydrated 이후에만” UI를 그린다(게이팅)

가장 안전하고 예측 가능한 방법은, persist가 복원되기 전에는 UI를 확정하지 않는 것입니다. 즉, “복원 완료 플래그”를 스토어에 두고, 그 전에는 로딩 UI(또는 SSR과 동일한 플레이스홀더)만 보여줍니다.

스토어: hasHydrated 플래그 추가

// stores/useUserStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

type UserState = {
  token: string | null
  hasHydrated: boolean
  setToken: (token: string | null) => void
  setHasHydrated: (v: boolean) => void
}

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      token: null,
      hasHydrated: false,
      setToken: (token) => set({ token }),
      setHasHydrated: (v) => set({ hasHydrated: v }),
    }),
    {
      name: 'user-store',
      storage: createJSONStorage(() => localStorage),
      onRehydrateStorage: () => (state) => {
        // persist 복원이 끝난 시점
        state?.setHasHydrated(true)
      },
      partialize: (state) => ({ token: state.token }),
    }
  )
)
  • onRehydrateStorage는 rehydrate 전/후 훅을 제공합니다.
  • partialize필요한 값만 저장하면 예상치 못한 UI 변동 범위를 줄일 수 있습니다.

컴포넌트: 복원 완료 전에는 UI를 고정

'use client'

import { useUserStore } from '@/stores/useUserStore'

export function AuthButton() {
  const { token, hasHydrated } = useUserStore()

  // 복원 전에는 SSR과 동일한 출력(또는 skeleton)만 렌더링
  if (!hasHydrated) {
    return <button disabled>Loading...</button>
  }

  return token ? <button>Logout</button> : <button>Login</button>
}

이 방식의 장점은 “SSR 출력과 hydrate 직후 출력이 동일”해지도록 강제할 수 있다는 점입니다. 단점은 복원 전 짧은 순간에 로딩 UI가 보일 수 있다는 점인데, 로그인/테마/장바구니 같은 상태에서는 오히려 UX적으로 자연스러운 편입니다.

해결 전략 2: skipHydration으로 자동 복원을 막고 수동 rehydrate

Zustand persist에는 skipHydration 옵션이 있습니다. 이를 켜면 스토어 생성 시점에 자동으로 복원하지 않습니다. 그 다음 원하는 타이밍(예: useEffect)에 수동으로 rehydrate()를 호출하면, SSR과 hydrate의 불일치를 더 정교하게 제어할 수 있습니다.

스토어: skipHydration: true

// stores/useSettingsStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

type SettingsState = {
  theme: 'light' | 'dark'
  hasHydrated: boolean
  setTheme: (t: 'light' | 'dark') => void
  setHasHydrated: (v: boolean) => void
}

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'light',
      hasHydrated: false,
      setTheme: (theme) => set({ theme }),
      setHasHydrated: (v) => set({ hasHydrated: v }),
    }),
    {
      name: 'settings-store',
      storage: createJSONStorage(() => localStorage),
      skipHydration: true,
      onRehydrateStorage: () => (state) => {
        state?.setHasHydrated(true)
      },
      partialize: (s) => ({ theme: s.theme }),
    }
  )
)

루트(또는 레이아웃)에서 한 번만 rehydrate 호출

'use client'

import { useEffect } from 'react'
import { useSettingsStore } from '@/stores/useSettingsStore'

export function SettingsHydrator() {
  useEffect(() => {
    // 타입상 접근은 다음 형태를 많이 씁니다.
    void useSettingsStore.persist.rehydrate()
  }, [])

  return null
}

그리고 app/layout.tsx 또는 특정 페이지의 클라이언트 래퍼에 SettingsHydrator를 포함합니다.

이 접근은 “어떤 컴포넌트가 먼저 그려지든, rehydrate는 내가 정한 시점에만 발생”하게 만들기 때문에, mismatch를 잡는 데 매우 강력합니다.

해결 전략 3: SSR에 섞이면 안 되는 상태를 분리한다

모든 상태가 SSR에 영향을 주는 건 아닙니다. 예를 들어 다음은 SSR과 충돌 위험이 큽니다.

  • 테마(dark/light)로 className이 바뀌는 경우
  • 로그인 여부로 메뉴 구조가 바뀌는 경우
  • 언어/지역 설정으로 텍스트가 달라지는 경우

반면 다음은 상대적으로 안전합니다.

  • “클릭 이후에만” 드러나는 모달 열림 여부
  • 클라이언트에서만 쓰는 캐시(검색 자동완성 캐시 등)

따라서 SSR에 영향을 주는 persist 상태는 별도 스토어로 분리하고, 그 스토어는 위의 게이팅 또는 skipHydration을 의무적으로 적용하는 식으로 운영하면 사고가 줄어듭니다.

해결 전략 4: 초기값을 “서버와 동일”하게 설계한다

어떤 경우에는 로딩 UI를 넣기 어렵습니다. 예를 들어 상단 네비게이션이 레이아웃 전체를 밀어내는 구조라면, 로딩 버튼이 레이아웃 점프를 만들 수 있습니다.

이때는 초기값을 서버와 클라이언트가 동일하게 만들고, rehydrate 이후에도 DOM 구조가 크게 바뀌지 않게 설계합니다.

예:

  • 로그인 버튼/로그아웃 버튼을 아예 교체하지 말고, 동일한 버튼에 label만 바꾸기
  • 카운트는 숫자 텍스트만 바꾸고, 주변 DOM 구조는 유지

하지만 이 방식은 “UI가 바뀌는 폭을 줄이는” 최적화일 뿐, 근본적으로는 rehydrate 타이밍 제어(게이팅)가 더 안정적입니다.

Next.js App Router에서의 실전 팁

1) 스토어를 쓰는 컴포넌트는 반드시 use client

Zustand는 클라이언트 상태 라이브러리이므로 App Router에서 스토어를 직접 읽는 컴포넌트는 use client가 필요합니다.

특히 “서버 컴포넌트에서 스토어 값을 읽어 SSR에 반영”하려는 시도는 구조적으로 불가능하거나(스토리지 접근 불가) 다른 형태의 불일치로 이어집니다.

2) createJSONStorage를 써서 SSR 환경에서 안전하게

SSR 환경에서는 localStorage가 없습니다. createJSONStorage(() => localStorage)는 클라이언트에서만 평가되도록 도와주지만, 코드가 실행되는 위치에 따라 여전히 문제가 될 수 있습니다.

가장 안전한 패턴은:

  • 스토어 파일은 클라이언트에서만 import되는 경로에서 사용
  • 혹은 skipHydration + 클라이언트에서만 rehydrate 호출

3) 개발 모드에서 경고가 더 자주 보일 수 있음

React Strict Mode 및 개발 환경에서는 렌더링이 2번 실행되는 등 타이밍이 달라 보일 수 있습니다. 하지만 hydration mismatch는 프로덕션에서도 실제로 사용자에게 영향을 줄 수 있으니, “개발 모드만 그런가 보다”로 넘기지 않는 편이 좋습니다.

예제: 테마 적용까지 포함한 안전한 패턴

테마는 mismatch의 단골입니다. className이 바뀌면 React가 민감하게 반응합니다. 아래는 hasHydrated 이후에만 document.documentElement에 테마 클래스를 적용하는 예시입니다.

'use client'

import { useEffect } from 'react'
import { useSettingsStore } from '@/stores/useSettingsStore'

export function ThemeApplier() {
  const theme = useSettingsStore((s) => s.theme)
  const hasHydrated = useSettingsStore((s) => s.hasHydrated)

  useEffect(() => {
    if (!hasHydrated) return

    const root = document.documentElement
    root.dataset.theme = theme
  }, [theme, hasHydrated])

  return null
}
  • SSR에서는 data-theme를 고정값으로 두거나 아예 비워둡니다.
  • 클라이언트에서는 rehydrate 이후에만 실제 테마를 반영합니다.

자주 하는 실수 체크리스트

  • persist 상태를 읽어 곧바로 조건부 렌더링을 해놓고, rehydrate 전/후 DOM 구조가 바뀜
  • partialize 없이 스토어 전체를 저장해 UI 변동 범위가 커짐
  • 여러 스토어가 각각 rehydrate되며 서로 다른 시점에 UI를 뒤집음(가능하면 상위에서 순서를 통제)
  • 서버 컴포넌트에서 클라이언트 상태를 섞어 렌더링하려고 시도

정리

Zustand persist로 인한 hydration mismatch는 “서버는 스토리지를 모르고, 클라이언트는 안다”는 구조적 차이에서 출발합니다. 해결의 핵심은 rehydrate 타이밍과 UI 확정 시점을 일치시키는 것입니다.

추천 우선순위는 다음과 같습니다.

  1. hasHydrated 게이팅으로 복원 전 UI를 고정한다
  2. 더 정교한 제어가 필요하면 skipHydration + 수동 rehydrate를 쓴다
  3. SSR에 민감한 상태(테마/인증)는 별도 스토어로 분리하고 저장 범위를 partialize로 최소화한다

타입 안정성도 함께 챙기고 싶다면, 상태 설계 시 undefined 가능성을 줄이는 패턴을 같이 적용하는 것이 좋습니다. 관련해서는 TS 5.5에서 Object is possibly undefined 줄이기도 함께 참고하면 도움이 됩니다.

또한 클라이언트/서버 경계에서 “언제 어떤 값이 확정되는가”를 관리하는 습관은, 상태 라이브러리뿐 아니라 스트리밍 렌더링/데이터 패칭 전반에 영향을 줍니다. 운영 환경에서의 미묘한 불일치를 줄이는 관점으로 접근해 보세요.