Zustand persist에 저장한 상태 구조를 바꿀 때 이전 데이터를 어떻게 옮기나요?
persist를 쓰면 새 코드가 실행되어도 localStorage에는 과거 구조의 값이 남아 있을 수 있습니다. 필드 이름이나 중첩 구조를 바꿀 때 version과 migrate로 이전 데이터를 새 형태로 변환합니다.
적용 조건·범위: 실행 검증: 2026-09-28 Node.js에서 위 세 출력과 잘못된 필터·배열 입력까지 5개 경우를 확인했습니다. persist 저장소 실제 복원은 별도 확인 대상입니다.
본문 근거: zustand.docs.pmnd.rs/integrations/persisting-store-data · zustand.docs.pmnd.rs/middlewares/persist
이 기술만 따로 실행하기
이 개념 직접 실행
상태를 바꾸고 화면에 반영되는 결과를 확인합니다.
예제 크게 보기 (새 탭)이전 앱이나 localStorage를 바꾸지 않고 저장 구조 변환부터 확인합니다. 외부 패키지가 필요 없는 Node.js 독립 예제입니다. migration-demo.mjs로 저장합니다.
function migrateUi(input, version) {
const data = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
const candidate = version < 2 ? data.filter : data.taskFilter;
const taskFilter = ['all', 'active', 'completed'].includes(candidate) ? candidate : 'all';
const preferences = data.preferences && typeof data.preferences === 'object' ? data.preferences : {};
const list = preferences.list && typeof preferences.list === 'object' ? preferences.list : {};
return { taskFilter, preferences: { list: { compact: typeof list.compact === 'boolean' ? list.compact : false } } };
}
const samples = [
[{ filter: 'completed' }, 1],
[{ taskFilter: 'active', preferences: { list: { compact: true } } }, 2],
[null, 1],
];
for (const [input, version] of samples) {
console.log(JSON.stringify(migrateUi(input, version)));
}
node migration-demo.mjs
기대 결과: 첫 입력은 taskFilter: completed와 compact: false, 둘째는 active와 true, 셋째는 all과 false입니다. 이 함수는 예제에서 정한 UI 저장 필드만 반환합니다. 다른 필드까지 보존하는 범용 마이그레이션이 아닙니다.
자가 확인: 첫 입력의 filter를 invalid로 바꾸면 all로 정규화되어야 합니다. 프로젝트에 연결할 때는 같은 검증 함수를 persist의 migrate에 연결하고 version: 2를 설정합니다. 저장소 복원과 디스크 재저장은 별도로 시험해야 합니다.
실행 검증: 2026-09-28 Node.js에서 위 세 출력과 잘못된 필터·배열 입력까지 5개 경우를 확인했습니다. persist 저장소 실제 복원은 별도 확인 대상입니다.
저장 구조 변경 예시
기존 v1 데이터가 다음 형태라고 가정합니다.
{
"filter": "active",
"selectedTaskId": "task-1"
}
새 버전에서는 filter라는 이름을 taskFilter로 바꾸고 preferences 기본값을 추가하려고 합니다.
{
"taskFilter": "active",
"selectedTaskId": "task-1",
"preferences": {
"list": {
"compact": false
}
}
}
코드 타입만 바꾸면 기존 사용자의 localStorage에는 filter가 그대로 남아 있습니다.
version
persist 옵션에 버전을 지정합니다.
{
name: 'taskflow-ui',
version: 2,
}
저장된 버전과 현재 버전이 다르면 migrate를 통해 상태를 변환할 수 있습니다.
migrate
migrate: (persistedState, version) => {
const state =
persistedState as Record<string, unknown>;
if (version === 1) {
return {
taskFilter:
state.filter ?? 'all',
selectedTaskId:
state.selectedTaskId ?? null,
preferences: {
list: {
compact: false,
},
},
};
}
return persistedState;
},
실제 migration에서는 타입을 안전하게 좁히고 입력 데이터가 예상과 다를 때 사용할 기본값을 준비합니다.
여러 버전을 건너뛰는 경우
서비스가 오래 운영되면 사용자가 v1에서 바로 v4로 올라올 수도 있습니다.
이때 migration은 단계별 변환을 명확히 만드는 편이 관리하기 쉽습니다.
v1
→ v2
→ v3
→ v4
또는 현재 코드가 지원하는 가장 오래된 저장 구조를 기준으로 한 번에 새 구조로 정규화할 수 있습니다.
중요한 것은 어떤 과거 입력이 들어와도 최종 store shape를 보장하는 것입니다.
clearStorage가 더 나은 경우
모든 저장 데이터를 반드시 migration할 필요는 없습니다.
다음 조건이면 저장값 초기화가 더 단순할 수 있습니다.
- 저장 정보의 중요도가 낮음
- migration 비용이 지나치게 큼
- 구조가 완전히 바뀜
- 복원 실패 시 사용자 영향이 작음
반대로 사용자가 직접 설정한 중요한 값이라면 migration을 우선 검토합니다.
migration 테스트
다음 순서로 테스트합니다.
- 이전 버전 앱에서 저장값 생성
- localStorage 값 확인
- 새 migration 코드 적용
- 앱 새로고침
- 새 shape로 복원되는지 확인
- 다시 저장한 값의 version 확인
현재 코드로만 테스트하면 migration 문제를 놓치기 쉽습니다.
학습 완료 체크리스트
- persist version이 필요한 이유를 설명할 수 있습니다.
- migrate 함수로 과거 state를 새 구조로 변환할 수 있습니다.
- 필드명 변경과 기본값 추가를 안전하게 처리할 수 있습니다.
- migration과 저장값 초기화 중 무엇이 적절한지 판단할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 여러 화면이 공유하는 상태를 관리합니다.
TaskFlow persist 구조를 v2로 올리고 filter를 taskFilter로 변경합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 직접 실행
filter를 taskFilter로 바꾸고 모든 읽기·호출부·Watcher를 함께 갱신합니다. 저장 version 2와 안전한 migrate를 적용해 이전 filter/selectedTaskId/preferences를 보존합니다. docs/migration-fixture.md는 개인 값과 분리된 테스트 절차입니다.
예제 크게 보기 (새 탭)중간부터 시작하는 전체 실습 파일
기존 React Context TaskFlow의 작업 목록·추가·완료·삭제 화면을 이어서 확장합니다.
이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.
시작본에서 직접 바꾸기
시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.
docs/migration-fixture.md— 이전 단계에 없던 파일을 만듭니다.src/components/TaskFilter.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskFilterWatcher.tsx— 기존 파일의 같은 위치를 갱신합니다.src/components/TaskList.tsx— 기존 파일의 같은 위치를 갱신합니다.src/store/migrateUi.ts— 이전 단계에 없던 파일을 만듭니다.src/store/useTaskStore.ts— 기존 파일의 같은 위치를 갱신합니다.
변경 이유와 결과: filter를 taskFilter로 바꾸고 모든 읽기·호출부·Watcher를 함께 갱신합니다. 저장 version 2와 안전한 migrate를 적용해 이전 filter/selectedTaskId/preferences를 보존합니다. docs/migration-fixture.md는 개인 값과 분리된 테스트 절차입니다.
완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.
docs/migration-fixture.md 전체 파일
# v0 → v2 테스트
개인 저장소와 분리한 브라우저 프로필의 taskflow-ui 키에만 다음 JSON을 저장하고 새로고침합니다.
```json
{"state":{"filter":"completed","selectedTaskId":"task-1","preferences":{"list":{"compact":true,"showCompleted":false}}},"version":0}
```
기대: taskFilter completed·선택 task-1·compact true·showCompleted false 보존. 모달은 닫힘, tasks는 메모리 초기값입니다. 알 수 없는 필터 값은 all로 정규화합니다.
src/components/TaskFilter.tsx 전체 파일
import {
useTaskStore,
type TaskFilter as TaskFilterValue,
} from '../store/useTaskStore';
const options: TaskFilterValue[] = [
'all',
'active',
'completed',
];
export default function TaskFilter() {
console.count('TaskFilter render');
const filter = useTaskStore((state) => state.taskFilter);
const setFilter = useTaskStore((state) => state.setFilter);
return (
<section>
<h2>필터</h2>
<select
value={filter}
onChange={(event) =>
setFilter(event.target.value as TaskFilterValue)
}
>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</section>
);
}
src/components/TaskFilterWatcher.tsx 전체 파일
import { useEffect } from 'react';
import { useTaskStore } from '../store/useTaskStore';
export default function TaskFilterWatcher() {
useEffect(() => {
const unsubscribe =
useTaskStore.subscribe(
(state) => state.taskFilter,
(nextFilter, previousFilter) => {
console.log(
'filter:',
previousFilter,
'→',
nextFilter,
);
},
);
return unsubscribe;
}, []);
return null;
}
src/components/TaskList.tsx 전체 파일
import { useTaskStore } from '../store/useTaskStore';
import TaskItem from './TaskItem';
export default function TaskList() {
const tasks = useTaskStore(
(state) => state.tasks,
);
const filter = useTaskStore(
(state) => state.taskFilter,
);
const preferences = useTaskStore((state) => state.preferences);
const visibleTasks = tasks.filter((task) => {
if (!preferences.list.showCompleted && task.completed) return false;
if (filter === 'active') {
return !task.completed;
}
if (filter === 'completed') {
return task.completed;
}
return true;
});
if (visibleTasks.length === 0) {
return <p>표시할 작업이 없습니다.</p>;
}
return (
<ul style={{ lineHeight: preferences.list.compact ? 1.4 : 2 }}>
{visibleTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
/>
))}
</ul>
);
}
src/store/migrateUi.ts 전체 파일
type Filter = 'all' | 'active' | 'completed';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
export function migrateUi(value: unknown, _version: number) {
const data = isRecord(value) ? value : {};
const candidate = data.taskFilter ?? data.filter;
const taskFilter: Filter = candidate === 'active' || candidate === 'completed' ? candidate : 'all';
const preferences = isRecord(data.preferences) ? data.preferences : {};
const list = isRecord(preferences.list) ? preferences.list : {};
return {
taskFilter,
selectedTaskId: typeof data.selectedTaskId === 'string' ? data.selectedTaskId : null,
preferences: { list: { compact: list.compact === true, showCompleted: list.showCompleted !== false } },
};
}
src/store/useTaskStore.ts 전체 파일
import { create } from 'zustand';
import { combine, persist, devtools, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type { Task } from '../types/task';
import { migrateUi } from './migrateUi';
export type TaskFilter = 'all' | 'active' | 'completed';
export type { Task } from '../types/task';
const initialState = {
tasks: [] as Task[],
taskFilter: 'all' as TaskFilter,
selectedTaskId: null as string | null,
isTaskModalOpen: false,
preferences: { list: { compact: false, showCompleted: true } },
};
const creator = persist(
combine(initialState, immer((set) => ({
addTask: (title: string) => {
const value = title.trim();
if (!value) return;
set((state) => { state.tasks.push({ id: crypto.randomUUID(), title: value, completed: false }); });
},
toggleTask: (id: string) => set((state) => {
const task = state.tasks.find((item) => item.id === id);
if (task) task.completed = !task.completed;
}),
deleteTask: (id: string) => set((state) => {
state.tasks = state.tasks.filter((task) => task.id !== id);
if (state.selectedTaskId === id) state.selectedTaskId = null;
}),
selectTask: (selectedTaskId: string | null) => set({ selectedTaskId }),
openTaskModal: () => set({ isTaskModalOpen: true }),
closeTaskModal: () => set({ isTaskModalOpen: false }),
setFilter: (taskFilter: TaskFilter) => set({ taskFilter }),
toggleCompact: () => set((state) => { state.preferences.list.compact = !state.preferences.list.compact; }),
toggleShowCompleted: () => set((state) => { state.preferences.list.showCompleted = !state.preferences.list.showCompleted; }),
}))),
{
name: 'taskflow-ui',
version: 2,
partialize: (state) => ({ taskFilter: state.taskFilter, selectedTaskId: state.selectedTaskId, preferences: state.preferences }),
migrate: (state, version) => migrateUi(state, version),
},
);
export const useTaskStore = create(devtools(subscribeWithSelector(creator), { name: 'TaskFlowStore' }));
완료 확인
- 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤
npm ci로 잠금 파일의 의존성을 설치합니다. npm run dev실행 후 터미널의 localhost 주소를 엽니다.- filter를 taskFilter로 바꾸고 모든 읽기·호출부·Watcher를 함께 갱신합니다. 저장 version 2와 안전한 migrate를 적용해 이전 filter/selectedTaskId/preferences를 보존합니다. docs/migration-fixture.md는 개인 값과 분리된 테스트 절차입니다.
npm run typecheck와npm run build가 성공하면 다음 프로젝트 단계로 갑니다.
이 체크포인트는 TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과했습니다. 별도 실제 브라우저 화면 검수는 이 파일 검증 범위에 포함하지 않았습니다.
아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.
누적 실습은 이 절의 프로젝트 단계 링크를 따라 진행합니다. 페이지 끝 학습 순서는 기술 주제 읽기 순서입니다.
이전 프로젝트 단계: Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습의 적용 결과에서 시작합니다. 아래 체크포인트 이름은 단계 식별자이며 다운로드 파일명이나 Git 명령이 아닙니다.
src/store/useTaskStore.ts에서 필터 이름을 taskFilter로 바꾸고 모든 selector/호출부도 맞춥니다. persist version과 migrate를 함께 변경하고 실제 프로젝트 저장 필드 전체를 보존하는지 대조합니다. 아래 기존 테스트 데이터는 학습용 별도 저장소에서 사용하며 개인 설정이 있는 taskflow-ui를 무심코 덮어쓰지 않습니다.
이전 체크포인트
zustand-step-12-subscribe-devtools
이번 목표
TaskFlow persist 구조를 v2로 올리고 filter를 taskFilter로 변경합니다.
변경 전 v1
type TaskStore = {
filter: TaskFilter;
selectedTaskId: string | null;
};
변경 후 v2
type TaskStore = {
taskFilter: TaskFilter;
selectedTaskId: string | null;
preferences: {
list: {
compact: boolean;
};
};
};
persist 설정
persist(
(set) => ({
taskFilter: 'all',
selectedTaskId: null,
preferences: {
list: {
compact: false,
},
},
setFilter: (taskFilter) =>
set({ taskFilter }),
}),
{
name: 'taskflow-ui',
version: 2,
partialize: (state) => ({
taskFilter: state.taskFilter,
selectedTaskId:
state.selectedTaskId,
preferences:
state.preferences,
}),
migrate: (
persistedState,
version,
) => {
const previous =
persistedState as {
filter?: TaskFilter;
taskFilter?: TaskFilter;
selectedTaskId?:
string | null;
preferences?: {
list?: {
compact?: boolean;
};
};
};
if (version < 2) {
return {
taskFilter:
previous.filter ?? 'all',
selectedTaskId:
previous.selectedTaskId ??
null,
preferences: {
list: {
compact:
previous.preferences
?.list
?.compact ??
false,
},
},
};
}
return persistedState;
},
},
)
테스트
migration을 보기 위해 이전 저장 데이터를 준비합니다.
{
"state": {
"filter": "completed",
"selectedTaskId": "task-1"
},
"version": 1
}
새 코드를 실행한 뒤 기대 상태:
taskFilter
→ completed
selectedTaskId
→ task-1
preferences.list.compact
→ false
실행 확인
npm run dev
npm run build
새로고침 후 localStorage가 최신 구조로 다시 저장되는지 확인합니다.
현재 확장 단계
✓ 1~12. Store·persist·devtools
→ 13. persist migration
○ 14. 실무 설계 점검
○ 15. 최종 통합
완료 체크포인트
zustand-step-13-migration
다음 학습
다음 글에서는 지금까지 만든 store를 실제 유지보수 관점에서 다시 보고, 과도한 전역화·거대한 store·서버 상태 복제 같은 실수를 점검합니다.
다음 프로젝트 단계: Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수
공식 문서
- Persisting store data: https://zustand.docs.pmnd.rs/integrations/persisting-store-data
- persist middleware: https://zustand.docs.pmnd.rs/middlewares/persist
글 소개·학습 준비·전체 목차

Zustand · 13 / 15 · 고급 학습 · persist migration
persist를 적용한 서비스는 store 코드만 바꾸면 끝나지 않습니다.
사용자의 localStorage에는 예전 버전의 상태가 남아 있을 수 있습니다. 필드 이름을 바꾸거나 중첩 구조를 변경하면 새 코드와 과거 저장 데이터가 충돌할 수 있습니다.
Zustand persist는 version과 migrate 옵션을 이용해 저장된 상태 구조를 단계적으로 변환할 수 있습니다.
이번 글에서 배우는 내용
- persist 데이터 버전이 필요한 이유
- version 옵션
- migrate 함수
- 필드명 변경
- 기본값 추가
- migration과 clearStorage 선택 기준
- TaskFlow 저장 구조 v1 → v2 전환
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 15개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.