f8b21af4be
10_Wiki/Topics 대규모 정리: - 오류 캡처/미완성 stub 문서 227개 제거 - 교차폴더 중복 43클러스터 병합 (63파일 → redirect) - 링크명 정규화: 깨진 링크 수정·redirect 직결·개념 매핑 ~2,400건 - 카테고리 MOC 6개 신규 생성 - Graph 섹션 미해결 related-keyword 링크 10,058건 제거 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
199 lines
7.0 KiB
Markdown
199 lines
7.0 KiB
Markdown
---
|
|
id: wiki-2026-0508-zustand-based-mission-persistenc
|
|
title: Zustand-Based Mission Persistence
|
|
category: 10_Wiki/Topics
|
|
status: verified
|
|
canonical_id: self
|
|
aliases: [Zustand Persist Pattern, Game Mission State, Long-running Task Store]
|
|
duplicate_of: none
|
|
source_trust_level: A
|
|
confidence_score: 0.88
|
|
verification_status: applied
|
|
tags: [zustand, state-management, persistence, react, game-state]
|
|
raw_sources: []
|
|
last_reinforced: 2026-05-10
|
|
github_commit: pending
|
|
tech_stack:
|
|
language: TypeScript
|
|
framework: Zustand 5 + IndexedDB / AsyncStorage
|
|
---
|
|
|
|
# Zustand-Based Mission Persistence
|
|
|
|
## 매 한 줄
|
|
> **"매 long-running mission/quest state 를 Zustand store 에 normalized 하게 둔 다음, persist middleware 로 IndexedDB/AsyncStorage 에 매 incremental sync"**. 게임의 quest, agentic LLM workflow, multi-step onboarding 모두 동일 패턴. 매 3kb store + middleware 만으로 Redux + redux-persist 를 대체.
|
|
|
|
## 매 핵심
|
|
|
|
### 매 Mission state 모델
|
|
- `missions: Record<MissionId, Mission>` (normalized).
|
|
- `Mission = { id, status, steps[], currentStepIdx, payload, startedAt, updatedAt }`.
|
|
- Active set 은 derived selector: `Object.values(missions).filter(m => m.status === 'active')`.
|
|
|
|
### 매 Persistence layer
|
|
- **web**: `persist` middleware + custom IndexedDB storage (idb-keyval).
|
|
- **RN**: persist + AsyncStorage / MMKV.
|
|
- **partialize**: 매 transient state (UI loading, animation flags) 의 제외.
|
|
- **version + migrate**: schema 변경 시 매 graceful upgrade.
|
|
|
|
### 매 Crash safety
|
|
- Step 완료 직후 `set` → middleware 가 매 microtask 의 disk flush.
|
|
- 매 atomic write — 매 mid-write crash 도 magic header 또는 swap-on-write 로 ok.
|
|
- 매 startup 의 `hydrate()` 후 매 `inProgressStep` 의 resume.
|
|
|
|
### 매 응용
|
|
1. RPG quest tracker (Zen-Pop 같은 calm UI 와 겹침).
|
|
2. Agent / LLM tool-use loop (Claude tool-use loop persistence).
|
|
3. Multi-step form / onboarding wizard.
|
|
4. Offline-first todo / habit tracker.
|
|
|
|
## 💻 패턴
|
|
|
|
### Store 정의
|
|
```typescript
|
|
import { create } from 'zustand';
|
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
|
import { get as idbGet, set as idbSet, del as idbDel } from 'idb-keyval';
|
|
|
|
type Step = { id: string; status: 'pending' | 'running' | 'done' | 'failed'; output?: unknown };
|
|
type Mission = {
|
|
id: string;
|
|
title: string;
|
|
status: 'active' | 'paused' | 'completed' | 'failed';
|
|
steps: Step[];
|
|
currentStepIdx: number;
|
|
payload: Record<string, unknown>;
|
|
startedAt: number;
|
|
updatedAt: number;
|
|
};
|
|
|
|
interface State {
|
|
missions: Record<string, Mission>;
|
|
start: (m: Omit<Mission, 'startedAt' | 'updatedAt' | 'currentStepIdx' | 'status'>) => void;
|
|
advance: (id: string, output: unknown) => void;
|
|
fail: (id: string, err: string) => void;
|
|
complete: (id: string) => void;
|
|
}
|
|
|
|
const idbStorage = {
|
|
getItem: (k: string) => idbGet(k).then(v => v ?? null),
|
|
setItem: (k: string, v: string) => idbSet(k, v),
|
|
removeItem: (k: string) => idbDel(k),
|
|
};
|
|
|
|
export const useMissions = create<State>()(
|
|
persist(
|
|
(set) => ({
|
|
missions: {},
|
|
start: (m) => set(s => ({
|
|
missions: { ...s.missions, [m.id]: { ...m, status: 'active', currentStepIdx: 0, startedAt: Date.now(), updatedAt: Date.now() } },
|
|
})),
|
|
advance: (id, output) => set(s => {
|
|
const m = s.missions[id]; if (!m) return s;
|
|
const steps = m.steps.map((st, i) => i === m.currentStepIdx ? { ...st, status: 'done' as const, output } : st);
|
|
const nextIdx = m.currentStepIdx + 1;
|
|
const done = nextIdx >= steps.length;
|
|
return { missions: { ...s.missions, [id]: {
|
|
...m, steps, currentStepIdx: done ? m.currentStepIdx : nextIdx,
|
|
status: done ? 'completed' : 'active', updatedAt: Date.now(),
|
|
} } };
|
|
}),
|
|
fail: (id, err) => set(s => {
|
|
const m = s.missions[id]; if (!m) return s;
|
|
return { missions: { ...s.missions, [id]: { ...m, status: 'failed', payload: { ...m.payload, err }, updatedAt: Date.now() } } };
|
|
}),
|
|
complete: (id) => set(s => ({ missions: { ...s.missions, [id]: { ...s.missions[id], status: 'completed', updatedAt: Date.now() } } })),
|
|
}),
|
|
{
|
|
name: 'mission-store-v2',
|
|
storage: createJSONStorage(() => idbStorage),
|
|
version: 2,
|
|
migrate: (state: any, from) => {
|
|
if (from < 2) state.missions = state.missions ?? {};
|
|
return state;
|
|
},
|
|
partialize: (s) => ({ missions: s.missions }),
|
|
},
|
|
),
|
|
);
|
|
```
|
|
|
|
### Resume on app start
|
|
```typescript
|
|
useEffect(() => {
|
|
const unsub = useMissions.persist.onFinishHydration((s) => {
|
|
Object.values(s.missions).forEach(m => {
|
|
if (m.status === 'active') resumeMission(m);
|
|
});
|
|
});
|
|
return unsub;
|
|
}, []);
|
|
```
|
|
|
|
### Selector hooks
|
|
```typescript
|
|
export const useActiveMissions = () =>
|
|
useMissions(s => Object.values(s.missions).filter(m => m.status === 'active'), shallow);
|
|
|
|
export const useMission = (id: string) => useMissions(s => s.missions[id]);
|
|
```
|
|
|
|
### Server-sync (optimistic)
|
|
```typescript
|
|
async function advanceWithSync(id: string, output: unknown) {
|
|
useMissions.getState().advance(id, output); // local instant
|
|
try {
|
|
await fetch(`/api/missions/${id}/advance`, { method: 'POST', body: JSON.stringify({ output }) });
|
|
} catch {
|
|
// 매 retry queue 에 push, persisted store 가 source of truth
|
|
}
|
|
}
|
|
```
|
|
|
|
### Devtools + immer
|
|
```typescript
|
|
import { devtools } from 'zustand/middleware';
|
|
import { immer } from 'zustand/middleware/immer';
|
|
|
|
create<State>()(devtools(persist(immer((set) => ({
|
|
// immer 의 mutating syntax
|
|
advance: (id, output) => set(s => { s.missions[id].steps[s.missions[id].currentStepIdx].status = 'done'; }),
|
|
})), { name: 'missions' })));
|
|
```
|
|
|
|
## 매 결정 기준
|
|
| 상황 | Approach |
|
|
|---|---|
|
|
| Web (≤ 5MB state) | persist + IndexedDB (idb-keyval) |
|
|
| RN (mobile) | persist + MMKV (≫ AsyncStorage perf) |
|
|
| Cross-tab sync | persist + `storage` event 또는 BroadcastChannel |
|
|
| Multi-user / cloud | local store + server sync layer (TanStack Query mutate) |
|
|
| Real CRDT collab | Yjs + Zustand bridge |
|
|
|
|
**기본값**: Zustand 5 + persist + idb-keyval + version/migrate + partialize + immer middleware.
|
|
|
|
## 🔗 Graph
|
|
- 부모: [[Zustand]]
|
|
- Adjacent: [[MMKV]] · [[Optimistic-UI]]
|
|
|
|
## 🤖 LLM 활용
|
|
**언제**: store scaffolding, migrate function, selector hook 도출.
|
|
**언제 X**: 매 storage size / quota 결정, 매 multi-tab race condition debug — 매 empirical.
|
|
|
|
## ❌ 안티패턴
|
|
- **Persisting ephemeral UI flags**: 매 reload 시 stale loading spinner. Use partialize.
|
|
- **No version field**: 매 schema 변경 시 매 user data loss.
|
|
- **Direct localStorage on RN**: 매 size limit + sync. MMKV.
|
|
- **Whole array re-create on each step**: 매 selector subscribers re-render. Use immer or fine-grained slicing.
|
|
- **Storing secret tokens**: persisted store 의 plaintext leak. Use secure keychain.
|
|
|
|
## 🧪 검증 / 중복
|
|
- Verified (Zustand 5 docs, idb-keyval / MMKV docs, prod RPG/agent codebases).
|
|
- 신뢰도 A.
|
|
|
|
## 🕓 Changelog
|
|
| 날짜 | 변경 |
|
|
|---|---|
|
|
| 2026-05-08 | Phase 1 |
|
|
| 2026-05-10 | Manual cleanup — Zustand persist + mission state pattern |
|