Zustand persist 마이그레이션 기준: 저장된 상태 구조가 바뀔 때

··설명 약 9분·작성: 해비·블로그 소개

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 테스트

다음 순서로 테스트합니다.

  1. 이전 버전 앱에서 저장값 생성
  2. localStorage 값 확인
  3. 새 migration 코드 적용
  4. 앱 새로고침
  5. 새 shape로 복원되는지 확인
  6. 다시 저장한 값의 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와 입력 타입을 포함한 전체 코드가 있습니다.

  1. docs/migration-fixture.md — 이전 단계에 없던 파일을 만듭니다.
  2. src/components/TaskFilter.tsx — 기존 파일의 같은 위치를 갱신합니다.
  3. src/components/TaskFilterWatcher.tsx — 기존 파일의 같은 위치를 갱신합니다.
  4. src/components/TaskList.tsx — 기존 파일의 같은 위치를 갱신합니다.
  5. src/store/migrateUi.ts — 이전 단계에 없던 파일을 만듭니다.
  6. 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' }));

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 터미널의 localhost 주소를 엽니다.
  3. filter를 taskFilter로 바꾸고 모든 읽기·호출부·Watcher를 함께 갱신합니다. 저장 version 2와 안전한 migrate를 적용해 이전 filter/selectedTaskId/preferences를 보존합니다. docs/migration-fixture.md는 개인 값과 분리된 테스트 절차입니다.
  4. 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가 복잡해질 때 피할 실수

공식 문서

글 소개·학습 준비·전체 목차
Zustand persist: 저장 상태 이전 표지

Zustand · 13 / 15 · 고급 학습 · persist migration

persist를 적용한 서비스는 store 코드만 바꾸면 끝나지 않습니다.

사용자의 localStorage에는 예전 버전의 상태가 남아 있을 수 있습니다. 필드 이름을 바꾸거나 중첩 구조를 변경하면 새 코드와 과거 저장 데이터가 충돌할 수 있습니다.

Zustand persist는 version과 migrate 옵션을 이용해 저장된 상태 구조를 단계적으로 변환할 수 있습니다.

이번 글에서 배우는 내용

  • persist 데이터 버전이 필요한 이유
  • version 옵션
  • migrate 함수
  • 필드명 변경
  • 기본값 추가
  • migration과 clearStorage 선택 기준
  • TaskFlow 저장 구조 v1 → v2 전환

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 15개 · 필수 14개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기