Zustand persist의 version과 migrate로 프로덕션 localStorage 스키마를 안전하게 변경하기
이 글은 Zustand
persist를 이미 쓰고 있거나 도입을 검토 중인 분을 대상으로 합니다. Zustand 기초와 TypeScript에 어느 정도 익숙하다고 가정합니다.
프로덕션 서비스를 운영하다 보면 반드시 한 번은 이 상황을 만나게 됩니다. 상태 구조를 바꿔야 하는데, 이미 수천 명의 사용자 기기 localStorage에 이전 버전 데이터가 저장되어 있는 상황. 저도 처음엔 "설마 괜찮겠지" 했다가 조용히 앱이 깨지는 걸 경험했습니다. 기존 저장 데이터와 새 코드 구조가 맞지 않아 undefined 접근이 발생하거나, 아무 경고 없이 빈 값으로 덮어씌워지는 식으로요.
이 글을 읽고 나면 version으로 스키마 변경 이력을 추적하고 migrate로 버전 간 변환 로직을 선언적으로 관리해서, 배포 중에도 사용자 데이터를 잃지 않고 스키마를 진화시키는 방법을 바로 적용할 수 있게 됩니다. 특히 Zustand v5를 쓰고 있다면 더 주의가 필요합니다. v5에서 auto-persist 동작이 바뀌면서 기존 마이그레이션 코드가 예기치 않게 동작할 수 있거든요.
다중 버전 누적 마이그레이션, SSR 환경 대응, TypeScript 타입 처리, 그리고 실무에서 자주 밟는 함정까지 실제 코드와 함께 살펴보겠습니다.
목차
핵심 개념
persist 미들웨어가 상태를 저장하는 방식
persist를 적용하면 스토어 상태가 아래와 같은 형태로 스토리지에 저장됩니다.
{
"state": { "count": 5, "user": { "name": "철수" } },
"version": 2
}version 필드가 없으면 기본값은 0입니다. 앱이 로드될 때 persist 미들웨어는 스토리지에서 이 객체를 읽고, 코드에 명시된 version과 비교합니다.
스토리지 로드
→ 저장된 version 확인
→ 코드의 version과 비교
├─ 일치: 그대로 rehydrate
└─ 불일치: migrate() 실행 → 변환된 상태로 rehydrate → 새 version으로 스토리지 갱신rehydrate: 스토리지에 저장된 직렬화 데이터를 다시 읽어 메모리 상의 스토어 상태로 복원하는 과정입니다. 서버 렌더링에서 쓰는 'hydration'과 개념적으로 같습니다.
migrate가 호출되는 조건
migrate 함수의 시그니처는 다음과 같습니다.
migrate: (persistedState: unknown, fromVersion: number) => NewState | Promise<NewState>저장된 version이 코드의 version보다 낮을 때 호출됩니다. 마이그레이션이 완료되면 미들웨어는 변환된 상태와 새 버전 번호를 스토리지에 다시 기록하므로, 다음 로드 시에는 마이그레이션이 재실행되지 않습니다.
그렇다면 version을 명시하지 않은 채 스키마를 바꾸면 어떻게 될까요? 저장된 데이터가 새 구조와 맞지 않아도 미들웨어는 그냥 기존 데이터를 들고 rehydrate를 시도합니다. migrate가 없고 버전이 불일치한다면, Zustand는 스토리지에 저장된 상태를 무시하고 onRehydrateStorage의 실패 콜백을 호출한 뒤 초기값으로 시작합니다. 사용자 입장에선 갑자기 데이터가 사라지는 셈이죠.
한 가지 더 짚고 넘어갈 게 있습니다. migrate 함수가 예외를 던지면 어떻게 될까요? 이 경우도 Zustand는 저장된 상태를 포기하고 초기값으로 폴백합니다. 실무에서 migrate 내부에 try-catch를 감싸두고 예외 발생 시 안전한 기본값을 반환하도록 처리해두는 것을 권장하는 이유가 여기 있습니다.
Zustand v5에서 달라진 것
v5(2024~2025)에서 한 가지 중요한 동작이 바뀌었습니다. 이전에는 앱 최초 실행 시 아무 인터랙션이 없어도 초기 상태가 스토리지에 자동 저장됐는데, v5부터는 실제 상태 변경이 발생해야 스토리지에 기록됩니다. migrate 함수를 작성할 때 스토리지가 완전히 비어 있는 경우를 반드시 고려해야 합니다.
기존 코드를 그대로 두고 Zustand v5로 올렸다면, 신규 사용자의 첫 세션에서는 persistedState가 null이나 undefined로 들어올 수 있습니다. 방어 처리 없이 state.someField를 접근하면 런타임 에러가 발생합니다. v5를 쓰고 있다면 이 부분을 꼭 확인해보시길 권장합니다.
실전 적용
예시 1: 필드 이름 변경
가장 흔한 케이스입니다. bearCount라는 필드를 count로 이름을 바꿨습니다. 기존 사용자 기기엔 bearCount가 저장되어 있죠.
interface BearState {
count: number
increment: () => void
}
const useBearStore = create<BearState>()(
persist(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}),
{
name: 'bear-storage',
version: 1,
migrate: (persistedState: unknown, version: number): BearState => {
const { bearCount, ...rest } = persistedState as Record<string, unknown>
if (version === 0) {
return {
...rest,
count: (bearCount ?? 0) as number,
} as BearState
}
return rest as BearState
},
}
)
)| 코드 포인트 | 설명 |
|---|---|
version: 1 |
스키마가 바뀌었음을 명시. 구버전 사용자는 migrate 실행 |
version === 0 조건 |
v0에서 저장된 경우만 변환 적용 |
?? 0 |
방어적 처리. 빈 스토리지(v5 이후)나 비정상 데이터 대비 |
| 스프레드 패턴 | 원본 객체를 직접 수정하지 않고 새 객체를 생성해 반환 — 사이드이펙트 방지 |
예시 2: 다중 버전 누적 마이그레이션
실제 서비스를 운영하다 보면 v0 사용자도, v1 사용자도, v2 사용자도 동시에 존재하는 상황이 생깁니다. 서비스가 조금만 오래되면 필연적으로 마주치는 상황인데, version < N 패턴으로 조건을 쌓으면 어떤 버전에서 오더라도 최신 상태로 안전하게 도달할 수 있습니다. 저도 이 패턴을 처음 봤을 때 "이렇게 간단하게?"라고 생각했는데, 실제로 써보니 꽤 우아한 해법이더라고요.
type BearStateV3 = {
count: { value: number; unit: string }
lastUpdated: string | null
}
const useBearStore = create<BearStateV3>()(
persist(
(set) => ({
count: { value: 0, unit: 'bears' },
lastUpdated: null,
}),
{
name: 'bear-storage',
version: 3,
migrate: (persistedState: unknown, version: number): BearStateV3 => {
let state = persistedState as Record<string, unknown>
if (version < 1) {
// v0 → v1: bearCount를 count로 rename
const { bearCount, ...rest } = state
state = { ...rest, count: bearCount ?? 0 }
}
if (version < 2) {
// v1 → v2: count(number)를 { value, unit } 객체로 구조 변경
const { count, ...rest } = state
state = { ...rest, count: { value: (count ?? 0) as number, unit: 'bears' } }
}
if (version < 3) {
// v2 → v3: lastUpdated 필드 추가
state = { ...state, lastUpdated: null }
}
return state as BearStateV3
},
}
)
)핵심 패턴:
if (version === N)대신if (version < N)을 쓰는 이유가 있습니다. v0 사용자는 세 조건을 모두 통과하면서 최신 상태로 도달하고, v2 사용자는 마지막 조건만 통과합니다. 각 조건이 독립적으로 작동하기 때문에 코드 한 곳에서 모든 버전의 사용자를 처리할 수 있습니다.
이 예시에서 중첩 객체(count가 { value, unit } 구조)가 등장하는데, 여기서 shallow merge 함정을 조심해야 합니다.
shallow merge: 객체를 합칠 때 최상위 키만 덮어쓰는 방식입니다.
{ a: { b: 1 } }에{ a: { c: 2 } }를 shallow merge하면{ a: { c: 2 } }가 됩니다.b가 사라지죠. Zustandpersist의 기본 동작이 이 방식이라, 중첩 객체가 있는 스키마에서는merge옵션에 deep merge 함수를 별도로 지정하는 것이 안전합니다.
import deepmerge from 'deepmerge'
persist(
/* ...스토어 정의... */,
{
name: 'bear-storage',
merge: (persisted, current) =>
deepmerge(current as object, persisted as object),
}
)예시 3: partialize로 마이그레이션 범위 제한
partialize는 스토어 상태 중 일부만 스토리지에 저장하고 싶을 때 쓰는 옵션입니다. UI 임시 상태처럼 세션이 끝나면 버려도 되는 값은 스토리지에 저장할 필요가 없죠. 저장 대상 자체를 줄이면 마이그레이션 대상도 자연히 줄어듭니다.
interface AuthState {
user: User | null
token: string
tempUIState: boolean // 저장 불필요 — 세션 간 유지할 필요 없는 UI 상태
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: '',
tempUIState: false,
}),
{
name: 'auth-storage',
version: 2,
// user, token만 스토리지에 저장 → migrate 대상도 이 둘만
partialize: (state) => ({ user: state.user, token: state.token }),
migrate: (
persistedState: unknown,
version: number
): Pick<AuthState, 'user' | 'token'> => {
const state = persistedState as Record<string, unknown>
if (version < 2) {
const { accessToken, ...rest } = state
return {
...rest,
token: (accessToken ?? '') as string,
} as Pick<AuthState, 'user' | 'token'>
}
return state as Pick<AuthState, 'user' | 'token'>
},
}
)
)tempUIState는 스토리지에 저장되지 않습니다. "이러면 rehydration 후 tempUIState가 undefined가 되는 거 아닌가?" 하고 생각하실 수 있는데, 그렇지 않습니다. Zustand는 스토리지에서 읽은 값과 스토어의 초기값을 merge하기 때문에, tempUIState는 자동으로 초기값 false로 채워집니다.
비동기 마이그레이션이 필요한 경우도 있습니다. migrate는 Promise를 반환할 수 있어서 서버 API 기반 변환도 가능합니다.
migrate: async (persistedState: unknown, version: number): Promise<UserState> => {
const state = persistedState as Record<string, unknown>
if (version < 1 && state.userId) {
// 서버에서 최신 프로필 정보를 가져와 마이그레이션
const profile = await fetchUserProfile(state.userId as string)
return { ...state, profile } as UserState
}
return state as UserState
},다만 비동기 마이그레이션은 rehydration 완료까지 시간이 걸릴 수 있으니, 로딩 상태 처리를 함께 고려해두는 것이 좋습니다.
예시 4: SSR(Next.js App Router) 환경에서 안전한 rehydration
Next.js를 쓰지 않는다면 이 섹션은 건너뛰어도 됩니다. React 단독 환경에서는 localStorage를 클라이언트에서만 접근하므로 이 문제가 발생하지 않습니다.
서버에서 렌더링된 HTML과 클라이언트 localStorage 값이 다르면 hydration mismatch가 발생합니다. skipHydration으로 서버 렌더링 시 rehydration을 건너뛰고, 클라이언트에서만 수동으로 실행하는 패턴이 현재 사실상 표준으로 자리잡았습니다.
// store.ts
export const useAppStore = create<AppState>()(
persist(
(set) => ({ /* 초기 상태 */ }),
{
name: 'app-storage',
version: 1,
skipHydration: true, // 서버에서 자동 rehydration 비활성화
}
)
)// HydrationProvider.tsx
'use client'
import { useEffect } from 'react'
import { useAppStore } from './store'
export function HydrationProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// 클라이언트 마운트 이후 수동으로 rehydration 실행
useAppStore.persist.rehydrate()
}, [])
return <>{children}</>
}// layout.tsx (Server Component)
import { HydrationProvider } from './HydrationProvider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<HydrationProvider>{children}</HydrationProvider>
</body>
</html>
)
}장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 추가 의존성 없음 | Zustand 코어에 내장. 별도 패키지 설치 불필요 |
| 유연한 스토리지 백엔드 | localStorage, sessionStorage, IndexedDB, AsyncStorage, 쿠키 모두 지원 |
| 비동기 마이그레이션 | migrate가 Promise를 반환할 수 있어 서버 API 기반 변환도 가능 |
| 점진적 도입 | version: 0부터 시작하면 기존 코드 변경 없이 도입 가능 |
| graceful degradation | Private browsing 등 스토리지 접근 불가 시 경고 후 정상 동작 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 기본 merge가 shallow | 중첩 객체의 새 필드가 유실될 수 있음 | merge 옵션에 deepmerge 등 별도 지정 |
| TypeScript 타입 안전성 부재 | persistedState가 unknown, 반환 타입 추론 불완전 |
migrate에 명시적 반환 타입 선언 + 필요 시 zod로 런타임 검증 |
| 버전 누락 시 데이터 소실 | 스키마 변경 후 version 미증가 시 기존 데이터 무시됨 | TypeScript enum으로 버전 상수 관리, 린터 규칙으로 버전 증가 검사 |
| v5 초기값 auto-persist 제거 | 최초 실행 시 스토리지가 비어 있을 수 있음 | migrate에서 null, undefined 핸들링 추가 |
| 다중 버전 관리 복잡도 | 버전이 쌓일수록 migrate 함수가 비대해짐 |
future-proof 라이브러리 도입 검토 |
| SSR hydration mismatch | 서버/클라이언트 상태 불일치 발생 가능 | skipHydration + useEffect 패턴 적용 |
실무에서 가장 흔한 실수
솔직히 고백하자면, 아래 세 가지를 저는 각각 최소 한 번씩 직접 당해봤습니다.
-
version: 0없이 서비스를 시작하는 것. 나중에 추가하면 기존 사용자의 저장 데이터에 version 필드 자체가 없어서(undefined), 0과 같은지 아닌지 판단하는 로직이 꼬입니다. Day 1부터 명시해두면 이후 변경의 기반이 갖춰집니다. 코드 한 줄로 나중의 두통을 예방할 수 있습니다. -
migrate함수에서 빈 스토리지를 처리하지 않는 것. Zustand v5부터 최초 실행 시 스토리지가 비어 있을 수 있습니다.(state.count ?? 0)처럼 nullish coalescing으로 방어하지 않으면undefined가 상태로 들어옵니다. 저는 이걸 로컬에서는 못 잡고 배포 후 신규 사용자 리포트로 처음 알게 됐습니다. -
마이그레이션 로직을 테스트하지 않는 것. 특히 중간 버전을 건너뛰는 케이스(v0에서 v3으로 바로 업그레이드한 사용자)가 예상대로 동작하는지 단위 테스트로 검증해두는 것이 중요합니다.
migrate함수는 순수 함수에 가깝기 때문에 테스트하기가 아주 쉽습니다. 각 버전의 저장 상태를 fixture로 만들어두고migrate를 직접 호출해 결과를 확인해두면 됩니다. 배포 이후에는 실제 사용자 데이터로 재현하기가 어렵습니다.
마치며
지금 바로 시작해볼 수 있는 3단계를 소개합니다.
-
기존 스토어에
version: 0추가하기. 아직 스키마를 바꾸지 않았더라도persist옵션에 버전을 명시해두면 이후 변경의 기반이 생깁니다. 코드 한 줄로 충분합니다:version: 0. -
다음 스키마 변경 시
version을 1 올리고migrate작성하기. 새 버전에서 달라지는 필드 변환 로직을if (version < N)패턴으로 작성해두면, 지금 이후 어떤 구버전 사용자가 오더라도 안전하게 처리됩니다. -
migrate단위 테스트 작성하기. 각 버전별 입력 상태를 fixture로 만들어두고migrate함수를 직접 호출해서 출력이 예상과 같은지 확인해보시면 됩니다. v0 → 최신, 중간 버전 → 최신 케이스를 포함하는 것을 권장합니다.
version과 migrate는 persist를 쓰는 순간부터 함께 고려해두면 좋은 설계 요소입니다. 나중에 "이거 version 없이 시작했는데 어떡하지"라는 상황을 만나기 전에, 지금 바로 version: 0 하나 추가해두는 것만으로도 큰 차이가 납니다.
참고 자료
- Persisting store data | Zustand 공식 문서
- persist 미들웨어 API 레퍼런스 | Zustand 공식 문서
- Zustand v5 마이그레이션 가이드
- persist migrate 다중 버전 지원 이슈 #984 | GitHub pmndrs/zustand
- future-proof 라이브러리 제안 및 논의 #2082 | GitHub pmndrs/zustand
- migrate 반환 타입 논의 #2357 | GitHub pmndrs/zustand
- v4.5.5 초기 상태 자동 저장 제거 breaking change #2763 | GitHub pmndrs/zustand
- How to migrate Zustand local storage store to a new version | DEV.to
- Solving zustand persisted store re-hydration merging state issue | DEV.to
- persist Middleware DeepWiki | pmndrs/zustand