Zustand persist 미들웨어에서 `partialize`로 저장 범위를 선택하고, `merge`로 중첩 객체를 안전하게 복원하는 방법
상태 관리 라이브러리를 쓰다 보면 꼭 한 번은 이런 상황을 마주치게 됩니다. 로그인 상태는 새로고침해도 유지되어야 하는데, 사이드바 열림 여부까지 localStorage에 남아 있어서 페이지를 닫았다 열면 UI가 이상한 상태로 복원되는 것이죠. 저도 처음엔 "그냥 전체 스토어를 저장하면 되지 않나?" 싶었는데, 실무에서는 이 차이가 꽤 큰 사용자 경험 버그로 이어지더라고요.
이 글은 Zustand의 persist 미들웨어 기본 사용 경험이 있고, 슬라이스 패턴에 어느 정도 익숙한 분들을 대상으로 합니다. Next.js App Router 예시도 포함되니 참고해두시면 좋을 것 같습니다. 이 글을 읽고 나면 partialize 필터 작성 → merge 전략 선택 → SSR hydration 처리까지 세 단계를 독립적으로 설정할 수 있습니다.
persist 미들웨어는 이 문제를 해결할 수 있는 두 가지 옵션을 제공합니다. partialize로 어떤 상태를 저장할지 선택하고, merge로 저장된 상태를 현재 상태와 어떻게 합칠지 제어하는 것이죠. 단순해 보이지만, 이 두 옵션의 동작 방식을 제대로 이해하지 못하면 배포 후에 원인 파악도 어려운 조용한 버그들이 생깁니다.
핵심 개념
partialize — 무엇을 저장할지 선택하는 필터
partialize는 스토어 상태를 스토리지에 기록하기 전에 실행되는 함수입니다. 이 함수가 반환하는 객체만 직렬화되어 저장됩니다.
persist(storeCreator, {
name: 'app-store',
partialize: (state) => ({
user: state.user,
theme: state.theme,
// sidebarOpen, modalVisible, cache 같은 UI 상태는 여기서 제외
}),
})여기서 한 가지 알아두면 좋은 점이 있습니다. partialize는 저장 대상을 필터링하는 것이지, 구독(subscription) 자체를 바꾸지는 않습니다. 즉, sidebarOpen이 바뀌어도 직렬화 로직은 실행됩니다—단지 그 결과물에서 sidebarOpen이 빠질 뿐이죠. 성능에 큰 영향은 없지만, 고빈도로 상태가 바뀌는 슬라이스(예: 드래그 좌표 같은)가 있다면 인지해두면 좋습니다.
partialize의 역할: 전체 상태 중 스토리지에 기록할 부분 집합을 반환하는 순수 함수입니다. 반환값 외의 상태는 메모리에만 존재하고 스토리지에는 기록되지 않습니다.
merge — 저장된 상태를 현재 상태와 어떻게 합칠지
페이지를 새로고침하면 Zustand는 스토리지에서 저장된 상태를 불러와 현재 초기 상태와 합칩니다. 이 병합(merge) 방식이 기본적으로 **얕은 병합(shallow merge)**이라는 점이 중요합니다.
// 기본 동작 — 내부적으로 이렇게 동작합니다
{ ...currentState, ...persistedState }평평한(flat) 상태 구조라면 이게 문제없이 동작합니다. 하지만 중첩 객체가 있으면 얘기가 달라집니다.
// 현재 초기 상태 (코드에서 새로 추가된 필드)
currentState.settings = {
theme: { color: 'blue', font: 'sans' },
notifications: { email: true, push: true } // push는 이번 배포에서 추가됨
}
// 스토리지에 저장된 상태 (이전 버전, push 필드 없음)
persistedState.settings = {
theme: { color: 'red' },
notifications: { email: false }
}
// 얕은 병합 결과: settings 객체 자체가 교체됨
// → notifications.push가 사라집니다!
{ ...currentState, settings: persistedState.settings }솔직히 이 함정에 한 번쯤은 빠져보게 됩니다. 앱을 처음 배포할 때는 저장된 데이터가 없으니 문제가 없어 보이다가, 기존 사용자에게만 새 필드가 undefined로 나타나서 원인 파악이 한참 걸리는 패턴이죠.
import { merge as deepMerge } from 'lodash-es'
persist(storeCreator, {
name: 'settings-store',
merge: (persistedState, currentState) =>
deepMerge({}, currentState, persistedState),
// currentState의 기본값을 기반으로, persistedState 값으로 덮어씌우는 방식
// 신규 필드는 currentState의 기본값이 보존됩니다
})얕은 병합 vs 깊은 병합: 얕은 병합은 최상위 키만 합칩니다.
{ a: { x: 1 } }와{ a: { y: 2 } }를 얕게 병합하면{ a: { y: 2 } }가 됩니다—x가 사라지죠. 깊은 병합은 중첩 객체를 재귀적으로 합쳐{ a: { x: 1, y: 2 } }를 만들어냅니다.
한 가지 구분해두면 좋은 게 있는데, merge 옵션은 스토리지에서 복원할 때 어떻게 병합할지를 정하는 것입니다. 아래 예시 2에서 나오는 updateSettings 액션 내부의 deepMerge는 별개로, 액션으로 상태를 업데이트할 때 기존 중첩 값을 보존하는 방식입니다. 같은 함수를 쓰지만 동작하는 레이어가 다릅니다.
실전 적용
예시 1: 슬라이스별 선택적 영속화
도메인별로 슬라이스를 분리하고, partialize로 저장할 상태를 명시적으로 지정하는 가장 기본적인 패턴입니다. StateCreator 타입을 붙여두면 슬라이스 간 타입 안전성도 확보됩니다.
import type { StateCreator } from 'zustand'
// userSlice.ts
interface UserSlice {
user: User | null
token: string | null
setUser: (user: User) => void
clearUser: () => void
}
export const createUserSlice: StateCreator<AppState, [], [], UserSlice> = (set) => ({
user: null,
token: null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null, token: null }),
})
// uiSlice.ts
interface UiSlice {
sidebarOpen: boolean
theme: 'light' | 'dark'
activeModal: string | null
setSidebarOpen: (open: boolean) => void
setTheme: (theme: 'light' | 'dark') => void
}
export const createUiSlice: StateCreator<AppState, [], [], UiSlice> = (set) => ({
sidebarOpen: false,
theme: 'light',
activeModal: null,
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setTheme: (theme) => set({ theme }),
})
// store.ts
type AppState = UserSlice & UiSlice
export const useStore = create<AppState>()(
persist(
(...a) => ({
...createUserSlice(...a),
...createUiSlice(...a),
}),
{
name: 'app-store',
partialize: (state) => ({
user: state.user,
token: state.token,
theme: state.theme,
// sidebarOpen, activeModal은 제외 — 휘발성 UI 상태
}),
}
)
)...a는 (set, get, api) 튜플로, 슬라이스 생성자에 Zustand 스토어 API를 전달하는 슬라이스 패턴의 관용구입니다. 슬라이스 패턴이 처음이라면 Zustand 공식 슬라이스 가이드를 먼저 읽어보시면 좋습니다.
| 상태 키 | 저장 여부 | 이유 |
|---|---|---|
user |
✅ | 로그인 유지 필요 |
token |
✅ | 인증 토큰 유지 |
theme |
✅ | 사용자 설정 보존 |
sidebarOpen |
❌ | 매 방문마다 초기 상태로 시작 |
activeModal |
❌ | 모달은 닫힌 상태로 시작해야 자연스러움 |
예시 2: 중첩 객체를 가진 settings 슬라이스에서 안전한 병합
앱 설정이 중첩 구조를 가질 때, merge 없이 배포하면 새로 추가된 설정 필드가 기존 사용자에게 undefined로 나타날 수 있습니다. lodash나 가벼운 deepmerge 라이브러리로 이를 방지할 수 있습니다.
import { merge as deepMerge } from 'lodash-es'
// lodash 없이 가볍게 쓰고 싶다면: import deepMerge from 'deepmerge'
export const useSettingsStore = create(
persist(
(set) => ({
settings: {
theme: { color: 'blue', font: 'system-ui', fontSize: 14 },
notifications: { email: true, push: true, slack: false },
editor: { tabSize: 2, wordWrap: true },
},
// 이 deepMerge는 액션 내부에서 상태를 업데이트할 때 중첩 값을 보존하는 것
// 아래 merge 옵션과는 동작하는 시점이 다릅니다
updateSettings: (partial) =>
set((state) => ({
settings: deepMerge({}, state.settings, partial),
})),
}),
{
name: 'settings-store',
partialize: (state) => ({ settings: state.settings }),
// 이 merge는 스토리지에서 복원할 때 currentState와 병합하는 방식을 정의합니다
merge: (persistedState, currentState) =>
deepMerge({}, currentState, persistedState),
// 순서가 중요합니다:
// currentState 기본값을 먼저 깔고 persistedState로 덮어씌워야
// 새로 추가된 필드의 기본값이 보존됩니다
}
)
)주의:
deepMerge(currentState, persistedState)순서를 지켜야 합니다. 반대로deepMerge(persistedState, currentState)로 작성하면 저장된 사용자 설정이 초기값으로 덮어씌워지는 반대 상황이 생깁니다.
예시 3: Next.js SSR 환경에서 hydration 지연 처리
인증 상태까지 올바르게 설정했다면, 이제 Next.js 환경에서 발생하는 또 다른 문제를 살펴볼 차례입니다.
App Router를 쓰다 보면 SSR hydration 불일치 경고를 꽤 자주 만나게 됩니다. 실제로 이 패턴을 도입하기 전에 저는 hydration 경고를 "개발 환경 경고니까 나중에 보자"라고 무시했다가, 프로덕션에서 로그인 상태가 순간적으로 노출되는 문제를 경험했습니다. 서버는 초기 상태로 렌더링하는데, 클라이언트는 localStorage에서 복원된 상태로 렌더링하니 당연히 불일치가 발생합니다.
skipHydration + 클라이언트 컴포넌트에서의 명시적 rehydrate() 호출로 해결할 수 있습니다.
// store.ts
export const useStore = create<AppState>()(
persist(storeCreator, {
name: 'app-store',
skipHydration: true, // 자동 복원을 건너뜁니다
})
)// components/HydrationGate.tsx
'use client'
import { useEffect } from 'react'
import { useStore } from '@/store'
export function HydrationGate({ children }: { children: React.ReactNode }) {
useEffect(() => {
// 클라이언트에서만 실행되므로 SSR/CSR 불일치가 생기지 않습니다
useStore.persist.rehydrate()
}, [])
return <>{children}</>
}// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<HydrationGate>
{children}
</HydrationGate>
</body>
</html>
)
}예시 4: onRehydrateStorage로 복원 완료 시점 감지
SSR 처리까지 했다면, 이제 복원이 완료되기 전에 저장된 데이터에 의존하는 UI가 렌더링되는 문제를 다룰 차례입니다. onRehydrateStorage 콜백으로 복원 완료 플래그를 관리하면 로딩 중 깜빡임을 깔끔하게 처리할 수 있습니다.
// store.ts
interface AppState {
user: User | null
_hasHydrated: boolean
setHasHydrated: (val: boolean) => void
}
export const useStore = create<AppState>()(
persist(
(set) => ({
user: null,
_hasHydrated: false,
setHasHydrated: (val) => set({ _hasHydrated: val }),
}),
{
name: 'app-store',
partialize: (state) => ({ user: state.user }), // _hasHydrated는 저장 제외
onRehydrateStorage: () => (state, error) => {
if (!error) {
state?.setHasHydrated(true)
}
},
}
)
)// components/AuthGuard.tsx
'use client'
import { useStore } from '@/store'
export function AuthGuard({ children }: { children: React.ReactNode }) {
const hasHydrated = useStore((s) => s._hasHydrated)
const user = useStore((s) => s.user)
// LoadingSpinner, LoginPage는 각자 구현이 필요합니다
if (!hasHydrated) return <LoadingSpinner />
if (!user) return <LoginPage />
return <>{children}</>
}예시 5: 버전 마이그레이션과 partialize 조합
스키마가 바뀔 때 version + migrate 옵션을 함께 사용하면 기존 사용자의 저장 데이터를 새 구조로 안전하게 올릴 수 있습니다. 직접 변이(mutation) 대신 structuredClone을 써서 불변성을 유지하는 방식이 안전합니다.
persist(storeCreator, {
name: 'app-store',
version: 2,
partialize: (state) => ({ user: state.user, preferences: state.preferences }),
migrate: (persistedState: any, version: number) => {
if (version === 1) {
// v1: user.name → v2: user.displayName
const state = structuredClone(persistedState)
if (state.user?.name) {
state.user.displayName = state.user.name
delete state.user.name
}
return state
}
return persistedState
},
})마이그레이션 실행 시점:
version이 저장된 값과 다를 때migrate가 실행됩니다.partialize로 저장된 구조만 마이그레이션 대상이 되므로, 저장 대상이 변경될 때는version을 올려주는 것을 권장합니다.
장단점 분석
실무에서 제가 이 옵션들의 한계를 가장 실감한 건, 배포 후 기존 사용자 클레임이 들어오면서였습니다. "저는 설정을 바꾼 적이 없는데 알림이 꺼져있어요"—바로 얕은 병합 함정이었죠.
장점
| 항목 | 내용 |
|---|---|
| 세밀한 저장 범위 제어 | partialize로 민감 정보(토큰)와 휘발성 UI 상태를 선택적으로 제외할 수 있습니다 |
| 스토리지 용량 절감 | 필요한 상태만 저장하므로 localStorage 5MB 한도를 효율적으로 사용할 수 있습니다 |
| 병합 전략 커스터마이징 | merge로 중첩 객체도 신규 필드 손실 없이 안전하게 복원할 수 있습니다 |
| 슬라이스 패턴과 자연스러운 통합 | 영속화 범위를 한 곳(partialize)에서 명시적으로 관리할 수 있습니다 |
| 버전 마이그레이션 지원 | version + migrate로 스키마 변경에 유연하게 대응할 수 있습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 기본 얕은 병합의 함정 | 중첩 객체 복원 시 신규 필드가 사라질 수 있습니다 | merge에 deepMerge 함수를 명시적으로 전달합니다 |
partialize는 구독을 필터링하지 않음 |
저장 대상이 아닌 상태 변경에도 직렬화가 실행됩니다 | 고빈도 상태 변경 슬라이스에서만 인지가 필요하며, 일반적인 경우 성능 영향은 미미합니다 |
| 함수는 직렬화 불가 | 액션(함수)을 partialize 반환값에 포함하면 복원이 깨집니다 |
partialize에서 함수를 반드시 제외하고, merge에서 currentState의 함수로 복원합니다 |
| SSR hydration 불일치 | Next.js에서 자동 복원 시 서버/클라이언트 상태가 달라집니다 | skipHydration: true + 클라이언트에서 명시적 rehydrate() 호출을 권장합니다 |
| 단일 스토어 내 슬라이스별 독립 persist 불가 | 하나로 결합된 스토어 내에서 슬라이스마다 서로 다른 persist 설정을 부여하는 것은 공식 API 수준에서 지원되지 않습니다 |
도메인 스토어를 아예 분리해 각각에 persist를 적용하거나, 슬라이스 단위 관리를 돕는 zustand-slices(공식 pmndrs 산하)를 활용할 수 있습니다 |
실무에서 가장 흔한 실수
-
partialize에 함수(액션)를 포함시키는 것:setUser,clearUser같은 함수를 반환값에 넣으면 직렬화 오류가 발생하거나 복원 후 함수가undefined가 됩니다.partialize는 순수 데이터 필드만 반환해야 합니다. -
중첩 객체가 있는 슬라이스에서
merge를 설정하지 않는 것: 처음 배포할 때는 문제없어 보이다가, 기존 사용자 중 저장된 데이터가 있는 경우에만 신규 필드가undefined로 나타나서 원인 파악이 늦어지는 패턴입니다. -
Next.js에서
skipHydration없이persist를 사용하는 것: 개발 환경에서는 경고만 출력되지만, 프로덕션에서 인증 상태 같은 민감한 UI가 순간적으로 노출되는 문제가 생길 수 있습니다.
마치며
partialize와 merge를 명시적으로 설정하는 것은 단순한 기능 설정이 아닙니다. 어떤 상태가 사용자의 것이고 어떤 상태가 앱의 것인지를 코드로 선언하는 행위입니다. 이 경계를 명확히 그을수록 배포 후 예상치 못한 사용자 경험 버그를 사전에 막을 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
-
기존
persist설정에partialize를 추가해볼 수 있습니다.partialize: (state) => ({ ...state })처럼 전체를 반환하는 형태로 시작한 뒤, 하나씩 필드를 제거하면서 어떤 상태가 실제로 영속화가 필요한지 점검해보시면 좋습니다. -
만약 중첩 객체가 있다면,
merge에deepMerge를 연결하는 것을 권장합니다.pnpm add lodash-es또는pnpm add deepmerge로 설치한 뒤,merge: (persisted, current) => deepMerge({}, current, persisted)형태로 설정하면 됩니다. -
Next.js를 사용 중이라면
skipHydration: true와HydrationGate패턴의 도입을 고려해보실 수 있습니다. hydration 경고가 사라질 뿐 아니라,_hasHydrated플래그로 로딩 상태도 깔끔하게 관리할 수 있게 됩니다.
참고 자료
- persist - Zustand 공식 레퍼런스 —
partialize,merge,onRehydrateStorage등 모든 옵션의 최신 레퍼런스 - Persisting store data - Zustand 공식 통합 가이드 — 공식 문서 중 실제 사용 패턴이 가장 잘 정리된 페이지
- Slices Pattern - Zustand 공식 가이드 — 슬라이스 패턴을 처음 접한다면 이 글보다 먼저 읽어볼 것을 권장합니다
- Solving zustand persisted store re-hydration merging state issue | DEV Community —
merge함정을 실제 사례로 풀어낸 글로, 이 글의deepMerge예시에 영감을 줬습니다 - Using selective persist with multiple slices · GitHub Discussion #985 — 슬라이스별 독립 persist를 원하는 요청 스레드. 공식 팀의 답변도 확인할 수 있습니다
- Persist a single Slice in a Bounded store · GitHub Discussion #1630 — 결합 스토어에서 단일 슬라이스만 영속화하고 싶을 때의 논의
- Clarification on persist middleware's partialize option · GitHub Discussion #1273 —
partialize의 동작 방식에 대한 메인테이너 직접 설명 - zustand-slices GitHub Repository — Zustand 공식 메인테이너(Daishi Kato)가 만든 슬라이스 패턴 전용 유틸리티
- Fix Next.js 14 hydration error with Zustand | Medium —
skipHydration패턴의 실제 적용 예시를 다룬 글