검증 기준: 2026-07-19, Zustand 5.0.14와 현재 persist 공식 문서를 기준으로 확인했습니다. 저장 스키마를 바꾸기 전에는 프로젝트의 실제 설치 버전과 기존 localStorage 표본을 함께 확인하세요.
- Zustand persist 마이그레이션이 필요한 이유
- 상태 구조 변경을 판단하는 기준
- 대표적인 변경 사례
- version과 migrate 설계 방법
- 실무에서 확인해야 할 체크리스트
- 마이그레이션 부담을 줄이는 설계 습관
- 결론과 다음 학습 경로
Zustand persist 마이그레이션이 필요한 이유

Zustand persist를 사용할 때 가장 쉽게 놓치는 점은 코드와 저장 데이터의 생명주기가 다르다는 것입니다. 코드는 배포와 함께 바뀌지만, 사용자의 브라우저에 저장된 localStorage나 sessionStorage 값은 그대로 남아 있습니다. 개발 중에는 저장소를 지우고 새로고침하면 문제가 사라지기 때문에 구조 변경의 위험이 잘 드러나지 않습니다. 하지만 운영 환경에서는 이전 버전의 persisted state가 새 store 코드와 함께 로드됩니다.
예를 들어 예전에는 theme: "dark"처럼 저장하던 값을 새 코드에서 preferences.theme으로 읽도록 바꾸었다고 가정해 보겠습니다. TypeScript 타입은 새 구조에 맞게 수정되어 있고, 컴포넌트도 새 타입을 기준으로 작성되어 있을 수 있습니다. 그러나 실제 사용자의 localStorage에는 여전히 flat한 구조의 값이 남아 있습니다. 이때 새 코드가 state.preferences.theme에 접근하면 preferences가 없어서 런타임 오류가 발생할 수 있습니다.
TypeScript는 현재 코드 안에서의 타입 일관성을 도와주지만, 과거에 저장된 JSON 값까지 보장하지는 않습니다. persisted state는 앱 바깥에 직렬화된 데이터로 남아 있는 값입니다. 타입 정의를 바꿨다고 해서 이미 저장된 데이터가 함께 변환되는 것은 아닙니다. Zustand persist의 migration은 바로 이 간극을 메우는 장치입니다.
따라서 질문은 “상태 타입을 바꿨으니 migrate를 써야 하나?”가 아니라 “이전 저장값을 새 코드가 읽어도 안전한가?”에 가깝습니다. 이전 저장값이 없어도 앱이 정상 동작하고, 기본값 병합만으로 충분하다면 마이그레이션이 필요 없을 수 있습니다. 반대로 이전 저장값이 새 코드의 가정과 충돌한다면 작은 변경이라도 migration 기준을 세워야 합니다.
상태 구조 변경을 판단하는 기준
상태 구조 변경은 모두 같은 위험도를 갖지 않습니다. 단순히 새 필드를 추가하는 것과 기존 필드의 의미를 바꾸는 것은 전혀 다른 문제입니다. 판단의 출발점은 기존 저장값을 그대로 hydrate했을 때 현재 store의 액션, selector, 컴포넌트가 기대하는 형태와 맞는지 확인하는 것입니다.
기본값으로 안전하게 흡수되는 변화
마이그레이션이 거의 필요 없는 변경은 기존 저장값이 없어도 기본값으로 자연스럽게 채워지는 경우입니다. 예를 들어 사용자 설정 store에 compactMode라는 선택 필드를 추가하고 기본값을 false로 둔다면, 이전 저장값에 해당 필드가 없더라도 앱이 안전하게 동작할 수 있습니다. 이 경우 현재 persist 병합 방식이 기본 상태를 유지하면서 저장값을 덮어쓰는 구조라면 version을 올리지 않아도 충분할 때가 많습니다.
이름·위치·타입·의미가 달라지는 변화
마이그레이션을 고려해야 하는 변경은 저장된 값이 새 코드에서 해석되는 방식에 영향을 주는 경우입니다. 필드명을 바꾸거나, 저장 범위를 줄이거나, 이전에는 문자열이던 값을 객체로 바꾸는 변경이 여기에 해당합니다. 이런 변경은 겉보기에는 작아 보여도 기존 사용자의 저장값이 새 코드에서 누락되거나 잘못 해석될 수 있습니다.
반드시 마이그레이션이 필요한 변경은 이전 저장값을 그대로 쓰면 오류가 나거나 잘못된 사용자 경험으로 이어지는 경우입니다. 예를 들어 인증 상태에서 isLoggedIn boolean을 authStatus: "authenticated" | "anonymous" | "expired" 같은 enum 성격의 값으로 바꾸었다면, 기존 boolean을 새 상태로 명확히 변환해야 합니다. 단순히 기본값으로 덮어쓰면 사용자가 갑자기 로그아웃되거나, 반대로 만료된 세션이 로그인 상태처럼 보일 수 있습니다.
- 기존 저장값이 새 코드에서 그대로 읽혀도 안전하면 migration 없이 진행할 수 있습니다.
- 기존 필드를 새 필드로 옮겨야 한다면 version을 올리고 migrate를 작성하는 편이 안전합니다.
- 값의 의미가 바뀌었거나 잘못 해석될 가능성이 있다면 migration을 기준 작업으로 봐야 합니다.
- 저장된 데이터가 오래 남을수록 migrate 함수는 방어적으로 작성해야 합니다.
대표적인 변경 사례
필드 추가와 필드명 변경
필드 추가는 가장 흔한 변경입니다. 새 필드가 없어도 기본값으로 처리할 수 있다면 마이그레이션이 필요하지 않을 수 있습니다. 예를 들어 sidebarCollapsed를 추가하고 초기 상태에 false를 넣었다면, 이전 저장값에는 이 필드가 없어도 대부분 문제가 없습니다. 다만 해당 필드가 없을 때 컴포넌트가 예외를 던지거나, 기본값이 사용자 데이터와 결합되어 중요한 의미를 갖는다면 별도 처리가 필요합니다.
필드명 변경은 마이그레이션을 고려해야 하는 대표 사례입니다. username을 displayName으로 바꾸면 현재 코드에서는 displayName만 읽습니다. 그러나 기존 저장값에는 username만 남아 있을 수 있습니다. 이 상태를 방치하면 화면에 이름이 비거나, 사용자가 설정한 값이 사라진 것처럼 보일 수 있습니다. 이때 migrate는 이전 필드의 값을 새 필드로 옮긴 뒤 더 이상 쓰지 않는 필드를 제거하는 역할을 합니다.
중첩 구조와 값 의미 변경
중첩 구조 변경은 더 조심해야 합니다. flat한 상태를 nested 구조로 바꾸면 단순한 필드 누락보다 오류 가능성이 커집니다. 예전에는 theme, language, timezone이 루트에 있었는데 새 코드에서 preferences.theme처럼 읽는다면, 이전 저장값에는 preferences 객체 자체가 없습니다. 이 경우 기본값 병합이 얕게 이루어지는지, 깊은 병합이 필요한지까지 확인해야 합니다.
값의 의미 변경도 위험합니다. boolean 플래그를 enum 형태로 바꾸는 경우가 대표적입니다. isDraft: true를 status: "draft"로 바꾸는 정도라면 비교적 명확하게 변환할 수 있습니다. 하지만 false가 과거에는 “발행됨”을 의미했고, 새 구조에서는 “임시 저장 아님” 정도로만 해석된다면 단순 매핑이 맞지 않을 수 있습니다. migration은 데이터 변환뿐 아니라 의미 변환이기도 합니다.
partialize로 저장 범위를 바꿀 때
저장 범위 변경도 놓치기 쉽습니다. partialize로 저장 대상을 줄이면 앞으로는 특정 필드가 저장되지 않겠지만, 이미 localStorage에 남아 있는 이전 필드는 그대로 존재할 수 있습니다. 새 코드가 그 필드를 무시한다면 문제가 없을 수 있습니다. 그러나 persist가 rehydrate할 때 이전 필드가 현재 상태를 덮어쓰거나, 삭제한 필드가 다시 살아나는 듯한 동작을 만든다면 migration에서 제거하는 편이 낫습니다.
type OldState = {
username?: string;
theme?: "light" | "dark";
};
type NewState = {
displayName: string;
preferences: {
theme: "light" | "dark";
};
};
version과 migrate 설계 방법

Zustand persist 공식 레퍼런스에서 version은 저장된 상태 구조의 버전을 나타냅니다. 현재 코드의 version과 저장소에 기록된 version이 다르면 persist는 migrate 함수를 통해 이전 상태를 현재 형태로 변환할 기회를 줍니다. 중요한 점은 version을 앱 릴리스 번호처럼 올리는 것이 아니라, persisted state의 구조나 의미가 바뀔 때 올리는 값으로 다루는 것입니다.
version은 앱 버전이 아니라 저장 스키마 버전입니다
version을 올리는 기준은 “기존 저장값에 변환이 필요한가?”입니다. 단순 UI 코드 변경, 액션 내부 구현 변경, 저장되지 않는 임시 상태 변경이라면 version을 올릴 필요가 없습니다. 반면 저장된 필드의 이름, 위치, 타입, 의미, 저장 범위가 바뀐다면 version을 올릴 후보입니다. 특히 인증, 결제 흐름, 작성 중인 문서, 사용자 설정처럼 사용자의 기대와 직접 연결되는 데이터라면 보수적으로 판단하는 편이 좋습니다.
migrate는 과거 값을 런타임에서 검증합니다
migrate 함수에서 해야 할 일은 이전 persisted state를 현재 store가 기대하는 최소한의 정상 형태로 바꾸는 것입니다. 아래 코드는 store 전체를 다시 만드는 예제가 아니라 저장된 데이터의 구조를 변환하는 예시입니다. 액션 함수는 store 초기화 코드에서 정의되고, migrate 반환값은 직렬화되어 남아 있던 상태 값을 현재 구조에 맞게 정리하는 데 집중합니다. 알 수 없는 값은 기본값으로 돌리고, 의미를 확정할 수 있는 값만 새 구조로 옮기는 방식이 실무적으로 안정적입니다.
type Theme = "light" | "dark";
type SettingsState = {
displayName: string;
preferences: { theme: Theme };
setTheme: (theme: Theme) => void;
};
const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
displayName: "",
preferences: {
theme: "light",
},
setTheme: (theme) =>
set((state) => ({
preferences: {
...state.preferences,
theme,
},
})),
}),
{
name: "settings",
partialize: (state) => ({
displayName: state.displayName,
preferences: state.preferences,
}),
version: 2,
migrate: (persistedState, version) => {
const state = persistedState as {
username?: unknown;
theme?: unknown;
displayName?: unknown;
preferences?: { theme?: unknown };
};
if (version < 2) {
return {
displayName: typeof state.username === "string" ? state.username : "",
preferences: {
theme:
state.theme === "dark" || state.theme === "light"
? state.theme
: "light",
},
};
}
return {
displayName:
typeof state.displayName === "string"
? state.displayName
: "",
preferences: {
theme:
state.preferences?.theme === "dark" ||
state.preferences?.theme === "light"
? state.preferences.theme
: "light",
},
};
},
}
)
);
위 예시는 version 0과 1이 모두 같은 OldState 구조였다는 전제에서, 과거의 username을 현재의 displayName으로 옮기고 루트에 있던 theme을 preferences.theme으로 이동합니다. 구버전마다 구조가 달랐다면 version < 2로 한 번에 묶지 말고 각 version을 단계별로 변환해야 합니다. 핵심은 과거 저장값을 현재 타입으로 곧바로 단정하지 않는 것입니다. persistedState는 신뢰 경계의 값으로 보고 필요한 필드만 좁게 검사하며, 예상과 다르면 기본값으로 복구해야 합니다. 예제도 any 대신 구버전에서 읽을 필드를 unknown으로 선언해 검사 범위를 드러냈습니다.
여러 구버전을 지원할 범위를 정합니다
여러 버전을 거쳐야 하는 앱이라면 migration을 단계적으로 작성하는 방식도 고려할 수 있습니다. version 1에서 2로, 2에서 3으로 순서대로 변환하면 오래된 사용자의 저장값도 현재 구조까지 올릴 수 있습니다. 다만 너무 오래된 버전까지 무리하게 보존하려고 하면 코드가 복잡해집니다. 서비스 특성상 오래된 저장값을 유지할 필요가 낮다면 특정 버전 이전은 초기화하는 정책도 가능합니다. 중요한 것은 이 정책을 우연에 맡기지 않는 것입니다.
실무에서 확인해야 할 체크리스트
마이그레이션을 작성한 뒤에는 새로 설치한 상태만 확인해서는 부족합니다. 실제로 검증해야 할 대상은 이전 버전에서 만들어진 저장값입니다. 가능하면 변경 전 앱에서 localStorage 값을 만든 뒤, 변경 후 코드로 실행해 rehydrate 과정이 정상적으로 끝나는지 확인해야 합니다. 개발자가 직접 저장소를 비운 상태에서는 migration의 실패를 발견하기 어렵습니다.
- 이전 persisted state를 넣은 상태에서 앱이 오류 없이 부팅되는지 확인합니다.
- 사용자 설정, 인증 상태, 필터 조건처럼 보존되어야 할 값이 의도대로 유지되는지 확인합니다.
- 삭제한 필드가 새 상태에 다시 섞여 들어오지 않는지 확인합니다.
- 잘못된 값, null, 빈 객체, 예상보다 오래된 버전 값이 들어와도 기본값으로 복구되는지 확인합니다.
- partialize로 저장 범위를 바꾼 경우 실제 저장 결과가 기대와 맞는지 확인합니다.
얕은 merge와 partialize 결과도 검증합니다
공식 persist 레퍼런스에서 기본 merge는 얕은 병합이라고 명시합니다. 기본값과 병합 방식도 함께 봐야 합니다. persist가 저장값을 현재 초기 상태와 합칠 때 얕은 병합만으로 충분한 구조인지, nested 객체가 통째로 덮어써져도 괜찮은지 확인해야 합니다. 예를 들어 preferences 안에 새 필드를 추가했는데 이전 저장값의 preferences 객체가 전체를 덮어쓴다면, 새 필드의 기본값이 사라질 수 있습니다. 이 경우 migration 또는 merge 전략을 통해 nested 기본값을 보존해야 합니다.
partialize, version, migrate, merge, hydration 옵션의 현재 정의는 persisting store data 공식 가이드에서도 함께 확인할 수 있습니다. 또 하나의 체크포인트는 액션과 저장 데이터의 분리입니다. Zustand store에는 함수 액션이 포함되지만 persist로 저장되는 것은 직렬화 가능한 상태여야 합니다. partialize를 사용해 저장할 값만 명확히 제한하면 migration 대상도 줄어듭니다. 반대로 store 전체를 무심코 저장하면 나중에 구조 변경의 영향 범위가 커지고, 저장하지 않아도 되는 값 때문에 migration 판단이 어려워집니다.
마이그레이션 부담을 줄이는 설계 습관
마이그레이션을 잘 작성하는 것만큼 중요한 것은 마이그레이션이 자주 필요하지 않도록 저장 구조를 설계하는 것입니다. 화면에서만 필요한 임시 상태, 서버에서 다시 받을 수 있는 데이터, 계산 가능한 값은 persist 대상에서 제외하는 편이 좋습니다. 저장 데이터가 적을수록 구조 변경의 영향도 줄어듭니다.
구현 세부보다 오래 유지될 도메인 값만 저장합니다
저장할 상태는 가능하면 안정적인 도메인 의미를 기준으로 두는 것이 좋습니다. 컴포넌트 구현에 가까운 이름이나 일시적인 UI 구조를 그대로 저장하면 리팩터링 때마다 persisted state가 흔들립니다. 예를 들어 isModalStepTwoOpen 같은 구현 중심 값보다, 정말 보존해야 하는 사용자 선택값이 무엇인지 먼저 구분해야 합니다.
새 필드가 없는 사용자를 기본 테스트로 둡니다
필드를 추가할 때는 “이 값이 없는 이전 사용자”를 항상 떠올리는 습관이 도움이 됩니다. 새 필드가 undefined일 때도 안전한지, 기본값이 어디에서 보장되는지, nested 객체가 없을 때 접근 오류가 나지 않는지 확인해야 합니다. 이 질문에 명확히 답할 수 없다면 migration을 작성하지 않더라도 최소한 런타임 방어 코드는 필요합니다.
필드명을 바꾸거나 구조를 옮길 때는 이전 이름을 곧바로 잊지 말아야 합니다. 코드에서는 더 이상 쓰지 않는 이름이라도 저장소에는 남아 있을 수 있습니다. 정리하면 Zustand persist 마이그레이션의 기준은 상태 타입 변경 여부가 아니라 기존 저장값과 새 코드의 호환성입니다. version은 저장 구조 변경의 표식이고, migrate는 과거 데이터를 현재 앱이 이해할 수 있는 형태로 바꾸는 통로입니다.
결론과 다음 학습 경로
첫째, version은 배포 번호가 아니라 persisted state의 호환성이 깨지는 순간에 올립니다. 둘째, migrate는 과거 저장값을 현재 타입이라고 가정하지 않고 런타임에서 필요한 필드만 검증합니다. 셋째, 이전 localStorage 표본, 누락 필드, 잘못된 값, 오래된 version을 넣은 재수화 테스트가 통과해야 배포할 수 있습니다.
현재 store의 값 읽기와 action 구조부터 점검하려면 Zustand state 사용법을 먼저 보고, 상태 변경 뒤 화면이 갱신되지 않는다면 Zustand 리렌더링 문제 해결 순서로 이어가세요. selector 렌더 기준은 Zustand selector 최적화 가이드에서 더 깊게 확인할 수 있습니다.
업데이트 기록: 2026-07-19 — Zustand 5.0.14 persist 공식 문서 기준으로 version·migrate·merge·partialize 근거를 추가하고, any 기반 예제를 구버전 필드 런타임 검사 방식으로 교정했습니다.