상태 관리 라이브러리를 고를 때 Redux, Recoil, Jotai, Zustand 사이에서 고민하는 경우가 많다. 하나의 전역 스토어에 모든 상태를 넣을지, 아토믹하게 쪼갤지, 아니면 그 중간 어딘가를 선택할지는 프로젝트 규모와 팀 상황에 따라 다르다.
pixelDiff를 개발하면서 Zustand를 선택하고, "도메인 기반 복합 스토어" 패턴을 적용했다. 단순한 투두 앱이 아니라 캔버스 에디터 수준의 복잡도를 가진 프로젝트에서 상태 관리를 어떻게 설계했는지 정리한다.
왜 Zustand인가
상태 관리 라이브러리 선택지를 비교해보면 이렇다.
| 라이브러리 | 특징 | 적합한 상황 |
|---|---|---|
| Redux | 단일 스토어, 보일러플레이트 많음 | 대규모 팀, 엄격한 구조 필요 |
| Recoil | 아토믹, React 종속 | Facebook 생태계, Suspense 활용 |
| Jotai | 아토믹, 미니멀 | 작은 단위 상태, 컴포넌트 로컬 |
| Zustand | 유연한 스토어, 보일러플레이트 적음 | 중소규모, 빠른 프로토타이핑 |
pixelDiff는 캔버스 조작, 레이어 관리, 모드 전환, 키보드 단축키 등 여러 도메인이 얽혀 있다. 이런 프로젝트에서 아토믹 방식은 상태 간 관계 추적이 어렵고, 단일 거대 스토어는 관심사 분리가 안 된다.
Zustand는 "필요한 만큼만 분리하고, 필요할 때 조합한다"는 중간 지점을 제공한다. 보일러플레이트 없이 도메인별 스토어를 만들고, 필요하면 여러 스토어를 조합해서 쓸 수 있다.
도메인 기반 스토어 분리
pixelDiff의 스토어 구조는 이렇다.
// lib/stores/index.ts
export { useLayerStore } from './layerStore'; // 레이어 CRUD + undo/redo
export { useCanvasStore } from './canvasStore'; // pan, zoom, preset
export { useModeStore } from './modeStore'; // comparison/edit/diff mode
export { useSelectionStore } from './selectionStore'; // 선택 상태
export { useUIStore } from './uiStore'; // UI 임시 상태
분리 기준은 "누가 이 상태를 소유하는가"다.
| 스토어 | 소유 도메인 | 영속성 |
|---|---|---|
| LayerStore | 캔버스 위 레이어들 | DB 저장 |
| CanvasStore | 뷰포트 조작 | DB 저장 |
| ModeStore | 비교/편집 모드 | DB 저장 |
| SelectionStore | 현재 선택된 요소 | 세션 |
| UIStore | 드롭다운, 패널 열림 | 세션 |
DB에 저장할 상태와 세션에서만 유지할 상태를 명확히 나눠두면, 영속성 로직이 단순해진다.
단일 스토어의 기본 구조
각 스토어는 동일한 패턴을 따른다. Types, State, Actions를 명확히 분리한다.
// lib/stores/canvasStore.ts
import { create } from 'zustand';
// ──────────────────────────────────────
// Types
// ──────────────────────────────────────
interface CanvasState {
pan: { x: number; y: number };
zoom: number;
preset: { id: string; width: number };
}
interface CanvasActions {
setPan: (pan: Partial<{ x: number; y: number }>) => void;
setZoom: (zoom: number) => void;
setTransform: (transform: { x?: number; y?: number; zoom?: number }) => void;
reset: () => void;
}
// ──────────────────────────────────────
// Initial State
// ──────────────────────────────────────
const initialState: CanvasState = {
pan: { x: 0, y: 0 },
zoom: 1,
preset: { id: 'desktop-fhd', width: 1920 },
};
// ──────────────────────────────────────
// Store
// ──────────────────────────────────────
export const useCanvasStore = create<CanvasState & CanvasActions>()(
(set, get) => ({
...initialState,
setPan: (pan) =>
set((state) => ({ pan: { ...state.pan, ...pan } })),
setZoom: (zoom) => set({ zoom }),
setTransform: (transform) =>
set((state) => ({
pan: {
x: transform.x ?? state.pan.x,
y: transform.y ?? state.pan.y,
},
zoom: transform.zoom ?? state.zoom,
})),
reset: () => set(initialState),
})
);
이 구조의 장점은 세 가지다.
- 타입 안전성 - State와 Actions 인터페이스가 분리되어 있어 자동완성이 잘 된다
- 초기화 용이 - initialState를 분리해두면 reset 구현이 간단하다
- 테스트 용이 - 순수 함수처럼 동작해서 테스트하기 쉽다
스토어 간 상호작용
복잡한 앱에서는 스토어 간 조합이 필수다. 두 가지 패턴을 사용한다.
패턴 1: 훅에서 조합
여러 스토어의 상태를 읽어서 하나의 기능을 구현하는 경우다.
// hooks/canvas/usePixiDragPan.ts
export function usePixiDragPan(containerRef, stage) {
// 여러 스토어에서 필요한 것만 구독
const dragTarget = useSelectionStore((s) => s.dragTarget);
const effectiveTool = useUIStore(selectEffectiveTool);
const setPan = useCanvasStore((s) => s.setPan);
const setDraggingCanvas = useUIStore((s) => s.setDraggingCanvas);
// 조합해서 기능 구현
const handleMouseDown = useCallback((e) => {
if (effectiveTool === 'hand') {
setDraggingCanvas(true);
// ...
}
}, [effectiveTool, setDraggingCanvas]);
// ...
}
핵심은 필요한 상태만 구독하는 것이다. useUIStore() 전체를 구독하면 관계없는 상태 변경에도 리렌더된다. 셀렉터 함수를 써서 필요한 값만 뽑아야 한다.
패턴 2: Selector로 파생 상태 정의
여러 상태를 조합한 파생 값이 여러 곳에서 필요할 때다.
// lib/stores/index.ts
type UIStoreState = ReturnType<typeof useUIStore.getState>;
/**
* 실제 적용되는 캔버스 도구
* Space 홀드 중이면 hand, 아니면 선택된 도구
*/
export const selectEffectiveTool = (state: UIStoreState): 'move' | 'hand' | 'diff' =>
state.isSpaceHolding ? 'hand' : state.canvasTool;
// 사용
const tool = useUIStore(selectEffectiveTool);
Selector를 스토어 파일에 정의해두면, 파생 로직이 한 곳에 모이고 재사용이 쉬워진다.
History 패턴 (Undo/Redo)
캔버스 에디터에서 Undo/Redo는 필수다. Zustand에서 구현하는 방법이다.
// lib/stores/layerStore.ts
interface HistoryState {
past: Record<string, Layer>[];
future: Record<string, Layer>[];
}
interface LayerState {
items: Record<string, Layer>;
_history: HistoryState;
}
export const useLayerStore = create<LayerState & LayerActions>()((set, get) => ({
items: {},
_history: { past: [], future: [] },
// ────── History Helper ──────
_pushHistory: () => {
const { items, _history } = get();
set({
_history: {
past: [..._history.past.slice(-49), structuredClone(items)],
future: [],
},
});
},
// ────── Undoable Action ──────
setPosition: (id, position, options) => {
if (!options?.skipHistory) {
get()._pushHistory();
}
set((state) => ({
items: {
...state.items,
[id]: { ...state.items[id], position },
},
}));
},
// ────── Undo/Redo ──────
undo: () => {
const { items, _history } = get();
if (_history.past.length === 0) return;
const previous = _history.past[_history.past.length - 1];
set({
items: previous,
_history: {
past: _history.past.slice(0, -1),
future: [structuredClone(items), ..._history.future],
},
});
},
redo: () => {
const { items, _history } = get();
if (_history.future.length === 0) return;
const next = _history.future[0];
set({
items: next,
_history: {
past: [..._history.past, structuredClone(items)],
future: _history.future.slice(1),
},
});
},
}));
주목할 점이 있다.
skipHistory 옵션 - 드래그 중에는 매 프레임마다 히스토리를 쌓으면 안 된다. 드래그 시작 시에만 pushHistory를 호출하고, 중간 업데이트는 skipHistory: true로 처리한다.
structuredClone - 깊은 복사로 스냅샷을 저장한다. 참조를 그대로 저장하면 이후 변경에 영향받는다.
past 제한 -
.slice(-49)로 히스토리 길이를 제한한다. 무한히 쌓으면 메모리 문제가 생긴다.
외부 동기화 (영속성)
Zustand 상태를 DB에 저장하고 복원하는 패턴이다.
// hooks/stores/usePersistence.ts
export function usePersistence(projectId: string, options = {}) {
const { initialState, layers } = options;
// ────── 초기 로드: DB → Zustand ──────
useEffect(() => {
if (!initialState || !layers) return;
// 여러 스토어에 상태 분배
useLayerStore.getState().setItems(restoredLayers);
useCanvasStore.getState().setPan(initialState.workspace.canvas.pan);
useCanvasStore.getState().setZoom(initialState.workspace.canvas.zoom);
useModeStore.getState().setUrl(initialState.active.url);
// ...
}, [initialState, layers]);
// ────── 저장: Zustand → DB ──────
const getPersistedState = useCallback(() => {
// 스토어에서 직접 최신 상태 읽기
const currentLayers = useLayerStore.getState().items;
const currentCanvas = useCanvasStore.getState();
const currentMode = useModeStore.getState();
return {
active: { url: currentMode.url, mode: currentMode.comparisonMode },
workspace: {
preset: currentCanvas.preset,
canvas: { pan: currentCanvas.pan, zoom: currentCanvas.zoom },
},
layers: extractLayerStates(currentLayers),
};
}, []);
const saveState = useCallback(async () => {
const state = getPersistedState();
await fetch(`/api/projects/${projectId}/state`, {
method: 'PUT',
body: JSON.stringify({ state }),
});
}, [projectId, getPersistedState]);
return { saveState };
}
핵심은 getState()로 스토어에 직접 접근하는 것이다. 리액트 훅 밖에서도 스토어 상태를 읽을 수 있다. 저장할 때마다 모든 스토어에서 필요한 값을 모아서 하나의 API 호출로 처리한다.
영속성 대상과 세션 상태를 명확히 분리해두면, 어떤 값을 저장할지 판단하기 쉬워진다.
| 영속성 | 세션 |
|---|---|
| 레이어 위치/크기/투명도 | 현재 선택된 레이어 |
| 캔버스 pan/zoom | 드래그 중 여부 |
| 비교 모드/URL | 열린 드롭다운 |
Multi-Selection과 Backward Compatibility
기능이 확장되면서 기존 API를 유지해야 할 때가 있다. 단일 선택에서 다중 선택으로 확장한 사례다.
// lib/stores/selectionStore.ts
interface SelectionState {
/** Multi-selection: all selected layer IDs */
selectedIds: Set<string>;
/** Primary selected layer */
primarySelectedId: string | null;
/** @deprecated Use selectedIds. Kept for backward compatibility */
dragTarget: string | null;
}
interface SelectionActions {
select: (id: string) => void;
addToSelection: (id: string) => void;
toggleSelection: (id: string) => void;
selectMultiple: (ids: string[]) => void;
clearSelection: () => void;
/** @deprecated Use select/clearSelection */
setDragTarget: (id: string | null) => void;
}
export const useSelectionStore = create<SelectionState & SelectionActions>()(
(set, get) => ({
selectedIds: new Set(),
primarySelectedId: null,
dragTarget: null, // backward compatibility
select: (id) =>
set({
selectedIds: new Set([id]),
primarySelectedId: id,
dragTarget: id, // backward compatibility
}),
// Legacy: 새 API로 위임
setDragTarget: (id) => {
if (id === null) {
get().clearSelection();
} else {
get().select(id);
}
},
// ...
})
);
새 기능(다중 선택)을 추가하면서 기존 API(dragTarget)를 유지했다. 레거시 API 호출은 내부적으로 새 API로 위임한다. 기존 코드를 한꺼번에 수정하지 않아도 점진적으로 마이그레이션할 수 있다.
테스트 전략
Zustand 스토어는 순수 함수처럼 동작해서 테스트하기 쉽다.
// lib/stores/selectionStore.test.ts
import { useSelectionStore } from './selectionStore';
describe('SelectionStore', () => {
beforeEach(() => {
useSelectionStore.getState().reset();
});
it('select()는 단일 선택으로 교체한다', () => {
const { select, selectedIds } = useSelectionStore.getState();
select('layer-1');
expect(useSelectionStore.getState().selectedIds.has('layer-1')).toBe(true);
select('layer-2');
expect(useSelectionStore.getState().selectedIds.size).toBe(1);
expect(useSelectionStore.getState().selectedIds.has('layer-2')).toBe(true);
});
it('addToSelection()은 기존 선택에 추가한다', () => {
const { select, addToSelection } = useSelectionStore.getState();
select('layer-1');
addToSelection('layer-2');
const ids = useSelectionStore.getState().selectedIds;
expect(ids.size).toBe(2);
expect(ids.has('layer-1')).toBe(true);
expect(ids.has('layer-2')).toBe(true);
});
});
getState()로 직접 액션을 호출하고, 변경된 상태를 검증한다. React 컴포넌트 없이 순수하게 로직만 테스트할 수 있다.
핵심 정리
Zustand로 복잡한 상태를 관리할 때 적용한 패턴을 정리하면 이렇다.
- 도메인 기반 분리 - 소유자가 명확한 단위로 스토어를 나눈다
- Types/State/Actions 분리 - 파일 내에서 구조를 명확히 한다
- Selector로 파생 상태 - 여러 곳에서 쓰는 조합 로직은 한 곳에서 정의한다
- 훅에서 조합 - 여러 스토어를 조합한 기능은 커스텀 훅으로 캡슐화한다
- 영속성 분리 - DB 저장 대상과 세션 상태를 명확히 나눈다
- Backward Compatibility - 레거시 API는 새 API로 위임하며 점진적으로 마이그레이션한다
이 패턴이 적합한 조건은 "여러 도메인이 얽혀 있지만, 각 도메인의 소유권이 명확한 프로젝트"다. 모든 상태가 서로 강하게 결합되어 있다면 단일 스토어가 나을 수 있고, 완전히 독립적인 상태들이라면 아토믹 방식이 나을 수 있다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd > React' 카테고리의 다른 글
| React 조건부 렌더링 최적화: visibility 기반 캐싱으로 모드 전환 개선하기 (0) | 2026.02.26 |
|---|---|
| React Query 낙관적 업데이트, 두 가지 패턴 (0) | 2026.02.17 |
| Next.js에서 Prisma 연결 풀 최적화하기 (0) | 2026.02.12 |
| NextAuth.js 세션 전략: DB에서 JWT로 전환하여 400ms → 5ms 달성하기 (0) | 2026.02.03 |
| Next.js로만 백엔드, 프론트엔드 구축하기 (0) | 2026.01.24 |
