학습 목표·선수 지식: 구버전 변환과 모든 버전의 읽기 검증을 나누어 null·잘못된 JSON·중첩 누락에도 action과 기본값을 유지합니다. 선수 지식: persist, TypeScript unknown과 타입 좁히기.
실습 경로: 현재 ZIP → 전체 코드. 먼저 설명에서 지정한 파일만 읽고, 설정·테스트 파일은 필요한 때 확인하세요.
검증 기준: 2026-09-12, Zustand 5.0.15와 현재 persist 공식 문서를 기준으로 확인했습니다. 저장 스키마를 바꾸기 전에는 프로젝트의 실제 설치 버전과 기존 localStorage 표본을 함께 확인하세요.
- Zustand persist 마이그레이션이 필요한 이유
- 상태 구조 변경을 판단하는 기준
- 대표적인 변경 사례
- version과 migrate 설계 방법
- 실무에서 확인해야 할 체크리스트
- 마이그레이션 부담을 줄이는 설계 습관
- 결론과 다음 학습 경로
Zustand persist 마이그레이션이 필요한 이유

Zustand persist를 사용할 때 가장 쉽게 놓치는 점은 코드와 저장 데이터의 생명주기가 다르다는 것입니다. 코드는 배포와 함께 바뀌지만, 사용자의 브라우저에 저장된 localStorage나 sessionStorage 값은 그대로 남아 있습니다. 개발 중에는 저장소를 지우고 새로고침하면 문제가 사라지기 때문에 구조 변경의 위험이 잘 드러나지 않습니다. 하지만 운영 환경에서는 이전 버전의 persisted state가 새 store 코드와 함께 로드됩니다.
예전 저장값이 루트의 theme를 갖고 새 코드가 preferences.theme을 읽는다면, 기본 preferences가 있는 store에서도 사용자의 테마가 옮겨지지 않아 기본값으로 보일 수 있습니다. 저장된 preferences가 null이거나 일부 중첩 필드만 있는 경우에는 얕은 병합으로 현재 기본값이 덮여 접근 오류나 필드 누락이 생길 수 있습니다.
TypeScript는 현재 코드 안에서의 타입 일관성을 도와주지만, 과거에 저장된 JSON 값까지 보장하지는 않습니다. persisted state는 앱 바깥에 직렬화된 데이터로 남아 있는 값입니다. 타입 정의를 바꿨다고 해서 이미 저장된 데이터가 함께 변환되는 것은 아닙니다. Zustand persist의 migration은 바로 이 간극을 메우는 장치입니다.
따라서 질문은 “상태 타입을 바꿨으니 migrate를 써야 하나?”가 아니라 “이전 저장값을 새 코드가 읽어도 안전한가?”에 가깝습니다. 이전 저장값이 없어도 앱이 정상 동작하고, 기본값 병합만으로 충분하다면 마이그레이션이 필요 없을 수 있습니다. 반대로 이전 저장값이 새 코드의 가정과 충돌한다면 작은 변경이라도 migration 기준을 세워야 합니다.
상태 구조 변경을 판단하는 기준
상태 구조 변경은 모두 같은 위험도를 갖지 않습니다. 단순히 새 필드를 추가하는 것과 기존 필드의 의미를 바꾸는 것은 전혀 다른 문제입니다. 판단의 출발점은 기존 저장값을 그대로 hydrate했을 때 현재 store의 액션, selector, 컴포넌트가 기대하는 형태와 맞는지 확인하는 것입니다.
기본값으로 안전하게 흡수되는 변화
마이그레이션이 거의 필요 없는 변경은 기존 저장값이 없어도 기본값으로 자연스럽게 채워지는 경우입니다. 예를 들어 사용자 설정 store에 compactMode라는 선택 필드를 추가하고 기본값을 false로 둔다면, 이전 저장값에 해당 필드가 없더라도 앱이 안전하게 동작할 수 있습니다. 이 경우 현재 persist 병합 방식이 기본 상태를 유지하면서 저장값을 덮어쓰는 구조라면 version을 올리지 않아도 충분할 때가 많습니다.
이름·위치·타입·의미가 달라지는 변화
마이그레이션을 고려해야 하는 변경은 저장된 값이 새 코드에서 해석되는 방식에 영향을 주는 경우입니다. 필드명을 바꾸거나, 저장 범위를 줄이거나, 이전에는 문자열이던 값을 객체로 바꾸는 변경이 여기에 해당합니다. 이런 변경은 겉보기에는 작아 보여도 기존 사용자의 저장값이 새 코드에서 누락되거나 잘못 해석될 수 있습니다.
저장값의 의미가 달라진다면 변환 정책을 명시해야 합니다. 예를 들어 boolean isDraft를 status로 옮길 때 false가 과거에 발행 완료를 뜻했는지 확인해야 합니다. 인증 상태는 저장값만으로 유효성을 판정하지 말고 서버 세션 확인 후 결정합니다.
- 기존 저장값이 새 코드에서 그대로 읽혀도 안전하면 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에서 제거하는 편이 낫습니다.
개념 부분 코드: src/examples/post-7273-1.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
type OldState = {
username?: string;
theme?: 'light' | 'dark';
};
type NewState = {
displayName: string;
preferences: {
theme: 'light' | 'dark';
};
};
version과 migrate 설계 방법
migrate와 merge를 함께 설계합니다. 숫자 version이 다를 때 migrate를 거치며 같은 버전에서는 migrate가 실행되지 않습니다. 아래 전체 프로젝트는 unknown의 object·null·array 여부를 검사한 뒤 허용 필드만 새 데이터 객체로 만듭니다. merge에서도 같은 검증기를 적용해 같은 버전의 잘못된 값과 action 이름 충돌을 차단합니다.

Zustand persist 공식 레퍼런스에서 version은 저장된 상태 구조의 버전을 나타냅니다. 현재 코드의 version과 저장소에 기록된 version이 다르면 persist는 migrate 함수를 통해 이전 상태를 현재 형태로 변환할 기회를 줍니다. 중요한 점은 version을 앱 릴리스 번호처럼 올리는 것이 아니라, persisted state의 구조나 의미가 바뀔 때 올리는 값으로 다루는 것입니다.
version은 앱 버전이 아니라 저장 스키마 버전입니다
version을 올리는 기준은 “기존 저장값에 변환이 필요한가?”입니다. 단순 UI 코드 변경, 액션 내부 구현 변경, 저장되지 않는 임시 상태 변경이라면 version을 올릴 필요가 없습니다. 반면 저장된 필드의 이름, 위치, 타입, 의미, 저장 범위가 바뀐다면 version을 올릴 후보입니다. 특히 인증, 결제 흐름, 작성 중인 문서, 사용자 설정처럼 사용자의 기대와 직접 연결되는 데이터라면 보수적으로 판단하는 편이 좋습니다.
migrate는 과거 값을 런타임에서 검증합니다
migrate 함수에서 해야 할 일은 이전 persisted state를 현재 store가 기대하는 최소한의 정상 형태로 바꾸는 것입니다. 아래 코드는 store 전체를 다시 만드는 예제가 아니라 저장된 데이터의 구조를 변환하는 예시입니다. 액션 함수는 store 초기화 코드에서 정의되고, migrate 반환값은 직렬화되어 남아 있던 상태 값을 현재 구조에 맞게 정리하는 데 집중합니다. 알 수 없는 값은 기본값으로 돌리고, 의미를 확정할 수 있는 값만 새 구조로 옮기는 방식이 실무적으로 안정적입니다.
교체할 전체 구현은 src/store.ts입니다. readSettings가 허용 필드만 만들고 migrateSettings가 0·1을 변환하며 merge가 모든 읽기 결과를 검증합니다. store의 setTheme는 현재 코드에서 보존합니다. store.ts 전체 코드
전체 실습은 object인지, null과 배열은 아닌지 먼저 검사한 뒤 Record<string, unknown>으로 좁힙니다. 각 값도 문자열·boolean 또는 허용된 테마인지 검사합니다. as는 구조 검증을 대신하지 않으며 readSettings의 반환값에는 action이나 임시 UI 필드를 포함하지 않습니다.
여러 구버전을 지원할 범위를 정합니다
이 실습의 저장 스키마 0·1은 username과 루트 theme를 갖는 동일한 구조였다고 가정합니다. 2는 displayName과 preferences.theme·compact입니다. 0·1만 이전 구조로 변환하고 지원하지 않는 미래 version은 기본값으로 초기화합니다. version이 없거나 숫자가 아닌 경우에는 현재 필드만 읽는 merge 검증을 거치므로 이전 이름 보존을 기대하지 않습니다. 실제 앱의 저장 이력이 다르면 분기를 그 이력에 맞춰 작성하세요.
여러 버전을 거쳐야 하는 앱이라면 migration을 단계적으로 작성하는 방식도 고려할 수 있습니다. version 1에서 2로, 2에서 3으로 순서대로 변환하면 오래된 사용자의 저장값도 현재 구조까지 올릴 수 있습니다. 다만 너무 오래된 버전까지 무리하게 보존하려고 하면 코드가 복잡해집니다. 서비스 특성상 오래된 저장값을 유지할 필요가 낮다면 특정 버전 이전은 초기화하는 정책도 가능합니다. 중요한 것은 이 정책을 우연에 맡기지 않는 것입니다.
실무에서 확인해야 할 체크리스트
읽기 검증은 UI 설정의 데이터 경계입니다. localStorage의 로그인 표시나 마이그레이션된 authStatus는 서버 권한을 증명하지 않습니다. 세션 유효성과 접근 권한은 서버 응답으로 확인하고 저장된 인증 boolean을 authenticated로 단순 승격하지 않습니다.
마이그레이션을 작성한 뒤에는 새로 설치한 상태만 확인해서는 부족합니다. 실제로 검증해야 할 대상은 이전 버전에서 만들어진 저장값입니다. 가능하면 변경 전 앱에서 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는 과거 데이터를 현재 앱이 이해할 수 있는 형태로 바꾸는 통로입니다.
설치부터 확인까지: 전체 실행 프로젝트
Node.js 24와 npm을 준비하고 ZIP을 푼 프로젝트 폴더에서 아래 순서로 실행합니다. 여러 파일을 함께 실행하는 완성형 실습입니다. 뷰어의 파일 경로는 ZIP 프로젝트 루트 기준이며, 짧은 개념 예제와 별개입니다.
npm install
npm run build
npm test
npm run dev
Vite가 출력한 로컬 주소를 여세요. 서버·인증·외부 API 없이 실행하는 브라우저 SPA입니다.
현재 실습 ZIP
README.md
# Zustand settings 실습
Node.js 24 및 npm. 이 폴더에서 `npm install`, `npm run build`, `npm test`, `npm run dev` 순서로 실행합니다. Vite가 표시한 로컬 주소를 여세요.
Zustand 5.0.15 / React 19.3.0 / TypeScript 7.0.2 기준입니다.
독립 브라우저 SPA이며 서버·인증·외부 API는 포함하지 않습니다. 자동 테스트는 Vitest와 jsdom 또는 메모리 저장소를 사용합니다. 브라우저 실제 새로고침·기기 성능 검사는 별도로 수행해야 합니다.
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zustand 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
package.json
{
"name": "zustand-settings",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "vitest run"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"zustand": "5.0.15"
},
"devDependencies": {
"typescript": "7.0.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1"
}
}
src/App.tsx
import { useEffect, useState } from 'react';
import { useStore } from 'zustand';
import { createSettingsStore } from './store';
export default function App() {
const [message, setMessage] = useState('');
const [store] = useState(() =>
createSettingsStore(
{
getItem: (key) => window.localStorage.getItem(key),
setItem: (key, value) => window.localStorage.setItem(key, value),
removeItem: (key) => window.localStorage.removeItem(key),
},
() => setMessage('설정을 복원하지 못했습니다. 기본값을 사용합니다.'),
),
);
const [ready, setReady] = useState(false);
const theme = useStore(store, (state) => state.preferences.theme);
const name = useStore(store, (state) => state.displayName);
useEffect(() => {
let active = true;
void Promise.resolve(store.persist.rehydrate()).finally(() => {
if (active) setReady(true);
});
return () => {
active = false;
};
}, [store]);
if (!ready) return <p role="status">설정을 불러오는 중…</p>;
function toggle() {
try {
store.getState().setTheme(theme === 'light' ? 'dark' : 'light');
} catch {
setMessage('현재 화면에는 적용됐지만 저장에 실패했습니다.');
}
}
return (
<main>
<h1>설정 스키마 2</h1>
<p>이름: {name || '없음'}</p>
<p>테마: {theme}</p>
<button onClick={toggle}>테마 변경</button>
<p role="status">{message}</p>
</main>
);
}
src/main.tsx
import { createRoot } from 'react-dom/client';
import App from './App';
const root = document.getElementById('root');
if (!root) throw new Error('root 요소가 필요합니다.');
createRoot(root).render(<App />);
src/store.test.ts
import { expect, test, vi } from 'vitest';
import { createSettingsStore } from './store';
function memory(value: unknown, version = 2) {
let raw = JSON.stringify({ state: value, version });
return {
getItem: () => raw,
setItem: (_key: string, value: string) => {
raw = value;
},
removeItem: () => {
raw = '';
},
};
}
test.each([0, 1])('실제 hydration: 구버전 %i 변환과 새 버전 저장', async (version) => {
const storage = memory({ username: 'Ada', theme: 'dark' }, version);
const store = createSettingsStore(storage);
const action = store.getState().setTheme;
await store.persist.rehydrate();
expect(store.getState().displayName).toBe('Ada');
expect(store.getState().preferences).toEqual({ theme: 'dark', compact: false });
expect(store.getState().setTheme).toBe(action);
expect(JSON.parse(storage.getItem()).version).toBe(2);
});
test.each([
null,
[],
'broken',
42,
{},
{ preferences: null },
{ preferences: { theme: 'neon' } },
{ displayName: 8, preferences: { compact: 'yes' }, setTheme: 'attack' },
])('동일 버전도 merge에서 읽기 검증: %j', async (value) => {
const store = createSettingsStore(memory(value));
const migrate = vi.fn(store.persist.getOptions().migrate!);
store.persist.setOptions({ migrate });
const action = store.getState().setTheme;
await store.persist.rehydrate();
expect(migrate).not.toHaveBeenCalled();
expect(store.getState().displayName).toBe('');
expect(store.getState().preferences).toEqual({ theme: 'light', compact: false });
expect(store.getState().setTheme).toBe(action);
expect(store.persist.hasHydrated()).toBe(true);
});
test('동일 버전 정상값 및 중첩 누락 기본값, action 유지', async () => {
const store = createSettingsStore(
memory({ displayName: 'Lin', preferences: { theme: 'dark' }, setTheme: null }),
);
await store.persist.rehydrate();
expect(store.getState().preferences).toEqual({ theme: 'dark', compact: false });
expect(store.getState().displayName).toBe('Lin');
store.getState().setTheme('light');
expect(store.getState().preferences.theme).toBe('light');
});
test('잘못된 JSON은 오류 콜백으로 전달되고 기본값을 유지한다', async () => {
const error = vi.fn();
const store = createSettingsStore({ ...memory({}), getItem: () => '{broken' }, error);
await store.persist.rehydrate();
expect(error).toHaveBeenCalledOnce();
expect(store.persist.hasHydrated()).toBe(false);
expect(store.getState().preferences.theme).toBe('light');
});
src/store.ts
import { createStore } from 'zustand/vanilla';
import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware';
type Theme = 'light' | 'dark';
export type SettingsData = {
displayName: string;
preferences: { theme: Theme; compact: boolean };
};
type SettingsStore = SettingsData & { setTheme: (theme: Theme) => void };
function record(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export function readSettings(value: unknown): SettingsData {
const data = record(value);
const preferences = record(data.preferences);
return {
displayName: typeof data.displayName === 'string' ? data.displayName : '',
preferences: {
theme: preferences.theme === 'dark' ? 'dark' : 'light',
compact: typeof preferences.compact === 'boolean' ? preferences.compact : false,
},
};
}
export function migrateSettings(value: unknown, version: number): SettingsData {
if (version === 0 || version === 1) {
const old = record(value);
return readSettings({ displayName: old.username, preferences: { theme: old.theme } });
}
// 이 예제는 지원하지 않는 미래 버전을 기본값으로 버립니다.
return readSettings(undefined);
}
export function createSettingsStore(
storage: StateStorage,
onError: (error: unknown) => void = () => {},
) {
return createStore<SettingsStore>()(
persist(
(set) => ({
...readSettings(undefined),
setTheme: (theme) =>
set((state) => ({ preferences: { ...state.preferences, theme } })),
}),
{
name: 'settings',
version: 2,
skipHydration: true,
storage: createJSONStorage<SettingsData>(() => storage),
partialize: (state) => ({
displayName: state.displayName,
preferences: state.preferences,
}),
migrate: migrateSettings,
merge: (persisted, current) => ({ ...current, ...readSettings(persisted) }),
onRehydrateStorage: () => (_state, error) => {
if (error !== undefined) onError(error);
},
},
),
);
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true
},
"include": ["src"]
}
기대 화면과 확인 순서
테마 변경 뒤 새로고침하면 저장된 테마가 복원됩니다. 구버전 예시는 개발자 도구 Local Storage의 settings에 {“state”:{“username”:”Ada”,”theme”:”dark”},”version”:1}을 저장한 뒤 새로고침해 이름 Ada, 테마 dark를 확인합니다. 현재 버전의 null과 누락 preferences는 기본값으로 복원됩니다. 이 수동 절차는 독자의 실제 브라우저 확인용이며 자동 검사는 메모리 storage입니다.
검증 범위: npm 설치, TypeScript 타입 검사와 Vite production 빌드, Vitest 자동 테스트를 실행했습니다. persist 테스트는 메모리 storage의 실제 rehydrate 경로이며 브라우저 재시작·다중 탭·SSR hydration 검증은 포함하지 않습니다.
결론과 다음 학습 경로
첫째, version은 배포 번호가 아니라 persisted state의 호환성이 깨지는 순간에 올립니다. 둘째, migrate는 과거 저장값을 현재 타입이라고 가정하지 않고 런타임에서 필요한 필드만 검증합니다. 셋째, 이전 localStorage 표본, 누락 필드, 잘못된 값, 오래된 version을 넣은 재수화 테스트가 통과해야 배포할 수 있습니다.
현재 store의 값 읽기와 action 구조부터 점검하려면 Zustand state 사용법을 먼저 보고, 상태 변경 뒤 화면이 갱신되지 않는다면 Zustand 리렌더링 문제 해결 순서로 이어가세요. selector 렌더 기준은 Zustand selector 최적화 가이드에서 더 깊게 확인할 수 있습니다.
업데이트 기록: 2026-09-12 — unknown의 실제 구조 검사, 동일 버전 merge 검증, 중첩 기본값과 action 보존을 추가했습니다. 메모리 storage에 저장 JSON을 주입해 실제 persist.rehydrate 경로를 검사합니다.
공식 근거와 확인 범위
확인일: 2026-09-12. 실행 프로젝트는 Zustand 5.0.15, React 19.3.0, TypeScript 7.0.2로 검사했습니다. 공식 문서의 API 설명과 실제 설치 버전의 동작을 구분해 확인합니다.
이 글이 도움이 되었나요?
Zustand 학습 순서
필수 14개 · 전체 15개
읽음 기록 관리
전체 과정 목차 (15개)
- 필수 길잡이 · Zustand 학습 로드맵: store·action·selector·persist 순서
- 필수 길잡이 · React state vs Zustand: 전역 상태가 필요한 기준
- 필수 학습 · Zustand란? React 상태 관리 선택 기준과 기본 Store
- 필수 학습 · Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기
- 필수 학습 · Zustand state 사용법: 값 읽기와 변경 흐름 익히기
- 필수 학습 · Zustand action 사용법: 상태 변경 로직을 store로 분리하기
- 필수 학습 · Zustand selector 사용법: 필요한 상태만 가져와 리렌더링 줄이기
- 필수 학습 · Zustand 리렌더링 원리와 selector 최적화 방법
- 필수 학습 · Zustand persist 사용법: 새로고침 후 상태 저장하기
- 필수 학습 · Zustand persist 마이그레이션 기준: 저장된 상태 구조가 바뀔 때 현재 글
- 선택 참고 · Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법
- 필수 선수 · Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수
- 필수 학습 · Zustand combine·immer 실습: 타입 추론과 중첩 상태 불변성
- 필수 학습 · Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습
- 필수 학습 · Zustand Todo 완성 실습: actions·선택 훅·persist 연결
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.