Zustand 심화 가이드 — 고급 상태 관리 패턴·Slice·미들웨어·성능·TypeScript
이 글의 핵심
이 글은 Zustand를 «카운터 예제»를 넘어 프로덕션 규모로 쓰기 위한 심화 가이드입니다. 스토어 설계·Slice 패턴·미들웨어 조합·셀렉터 기반 리렌더 최적화·TypeScript 고급 타입·Jotai·Recoil과의 선택 기준·대규모 앱에서의 상태 경계를 한 흐름으로 연결합니다.
솔직히 말하면 Redux 보일러플레이트 지쳐요. action type 상수, reducer 스위치, thunk 감싸기, 큰 팀에선 또 팀마다 다른 컨벤션… 다 의미는 있는데, 제품 속도 붙일 때는 “또 파일 세 개 열었네” 하는 날이 많았어요. Zustand는 API가 얄팍해 보여서 오히려 무서운데, 막상 쓰면 스토어 경계랑 액션만 팀이 합의하면 미들웨어 덧붙이는 재미가 있거든요. 이 글은 카운터 넘어서, “왜 여기서 버그 났지?” 하고 밤 새운 기준으로 적었어요.
예시 하나. 예전에 장바구니 화면에서 수량 올렸는데 가끔만 리스트 전체가 덜덜 리렌더되는 이슈가 있었어요. React DevTools로 보면 특정 행만 바뀌어야 하는데 부모까지 같이 도는 거예요. 원인은 전혀 장바구니 로직이 아니라, useStore 셀렉터가 { a: s.a, b: s.b } 같은 객체 리터럴을 매번 새로 만들고 있었던 것이었어요. 참조가 매번 달라지니까 Zustand 입장에선 “상태 바뀜”이고, 구독 컴포넌트는 그대로 따라가요. 그날 이후로 저는 “셀렉터는 원시값이면 원시값으로 빼자”를 팀에 소리 높여 말하게 됐… 죄송, 권하고 있어요.
같은 계열로 DevTools에 액션이 안 보이는 날도 있었어요. 익명 함수만 넘기고, 프로덕션 빌드에서만 devtools 꺼 둔 건지, 미들웨어 순서 바꿔서 persist가 먼저 직렬화해 버리는지… 한동안 “스토어는 맞는데 화면만 이상해” 같은 말만 반복했어요. 지금은 액션 이름을 cart/addItem처럼 네임스페이스 붙이고, 개발/스테이징에선 devtools 켜 두는 쪽으로 못 박았어요.
상태는 한 덩어리가 아니에요. 서버에서 온 거, URL이 주인인 거, 폼 임시 입력, 웹소켓, 권한 플래그가 동시에 있는데 전부 Zustand에 넣으면 결합만 늘고 리렌더는 폭발해요. 전역에는 “클라이언트가 주인인 UI 상태” 정도만 두고, 서버 데이터는 React Query 같은 데 맡기는 편이 마음이 편해요. persist로 서버 캐시까지 굳히면 “진실이 둘”이 돼서 나중에 진짜 재밌어져요(아님, 싫음).
스토어를 하나로 쓸지 여러 개로 쪼갤지는 팀 성향이에요. 단일 스토어는 DevTools 한 화면에 다 보여서 좋은데 PR 충돌이 늘고, 다중 스토어는 경계는 선명한데 스토어 간 동기화 레이어를 또 설계해야 해요. 저희는 처음엔 단일 + Slice로 갔다가, 라이프사이클이 완전히 다른 덩어리만 물리적으로 분리했어요.
Slice는 Redux Toolkit createSlice랑 비슷한 느낌으로, 기능 단위로 state랑 액션을 묶어서 합치는 패턴이에요. 아래는 세션 슬라이스랑 UI 슬라이스를 나눈 최소 예시예요.
import { create, type StateCreator } from 'zustand';
type SessionSlice = {
userId: string | null;
setUserId: (id: string | null) => void;
};
type UiSlice = {
sidebarOpen: boolean;
toggleSidebar: () => void;
};
const createSessionSlice: StateCreator<
SessionSlice,
[],
[],
SessionSlice
> = (set) => ({
userId: null,
setUserId: (userId) => set({ userId }),
});
const createUiSlice: StateCreator<UiSlice, [], [], UiSlice> = (set) => ({
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
});
type AppStore = SessionSlice & UiSlice;
export const useAppStore = create<AppStore>()((...args) => ({
...createSessionSlice(...args),
...createUiSlice(...args),
}));
이렇게 해두면 세션 쪽이 사이드바 키를 실수로 건드릴 일이 줄어요. 대신 같은 키 이름이 두 슬라이스에 뚝 들어가면 런타임에서야 알게 되는 경우가 있어서, 키 접두사라도 팀 룰로 잡는 걸 추천해요.
미들웨어는 devtools → persist → immer 이런 식으로 순서를 문서에 적어두는 게 좋아요. 순서 바꾸면 “동작은 하는데 로그가 이상해” / “저장된 JSON 모양이 달라져” 하거든요. persist 쓸 땐 partialize로 민감한 필드 빼고, version이랑 migrate 없이 스키마만 바꾸면 배포하고 나서 user 브라우저에 남은 깨진 스냅샷 때문에 욕 나와요(경험담).
얕은 갱신으로 장바구니 ID 맵만 바꾸는 예는 이렇게 쓸 수 있어요. 바뀐 ID만 참조가 새로 생기면 리스트 쪽에서 메모가 먹혀요.
import { create } from 'zustand';
type Item = { id: string; qty: number };
type CartState = {
itemsById: Record<string, Item>;
bump: (id: string) => void;
};
export const useCartStore = create<CartState>()((set, get) => ({
itemsById: {},
bump: (id) =>
set((s) => {
const cur = s.itemsById[id];
if (!cur) return s;
return {
itemsById: {
...s.itemsById,
[id]: { ...cur, qty: cur.qty + 1 },
},
};
}),
}));
셀렉터 이슈는 useShallow로 응급처치도 가능해요. 포지션 (x, y) 같이 묶어서 쓰고 싶을 때:
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
type Vec = { x: number; y: number };
type State = { position: Vec; setPosition: (p: Vec) => void };
export const useSceneStore = create<State>()((set) => ({
position: { x: 0, y: 0 },
setPosition: (position) => set({ position }),
}));
export function usePosition() {
return useSceneStore(useShallow((s) => s.position));
}
Jotai·Recoil이랑 비교하자면, 표로 정리하기 싫고 말로 할게요. Zustand는 중앙 스토어 + 셀렉터 사고에 익숙한 사람한테 편해요. Jotai는 atom 쪼개는 거 좋아하고 파생이 많은 실험 UI에 잘 맞고, Recoil은 조직에 이미 깔렸거나 비동기 셀렉터 그래프에 미련 있을 때요. Recoil 쪽은 로드맵이랑 “실험” 표기 이런 걸 꼭 한번 확인하세요. 원자 모델은 캐시·무효화 예쁘게 되는 대신, 팀 전체가 atom 분해 규칙을 이해해야 해서 온보딩 비용이 있어요. 반면 Zustand는 예전에 Redux만 하던 팀이 “액션 이름만 맞추자”로 빨리 수습하기 좋아요. 어디까지나 제 기준.
TypeScript 쓸 때 StateCreator에 미들웨어 겹겹 씌우면 제네릭이 가끔 터지는데, 그땐 create를 팀용 팩토리로 감싸서 한곳에 타입 고정하거나, AppStore 인터페이스를 먼저 박고 create<AppStore>()로 가는 수밖에 없어요. 바닐라 createStore는 React 안 쓰는 쪽(차트 콜백, 워커 브리지)이랑 로직 공유할 때 꽤 씀.
정리하자면, Zustand 고수 되는 법은 미들웨어 칭찬 늘리는 게 아니라 뭐를 전역에 둘지, 누가 구독할지, 변화가 어떤 경로로 가는지 팀끼리 말이 통하는지예요. 미들웨어만 늘리면 디버깅은 더 어려워져요. 전 저녁에 DevTools 뜯었던 날이 그 증거고요.
참고는 공식 쪽이 제일 낫고요. Zustand GitHub, Jotai, Recoil — 버전에 따라 import랑 미들웨어 시그니처가 살짝씩 다르니 프로젝트 package.json이랑 같이 봐 주세요. 예제는 v4/v5 계열 느낌이라 맞는지 한번만 대조하고 쓰면 돼요.
배포 전에는 git add → git commit → git push 한 다음 npm run deploy 순서 권해요. 장애 나면 로그에 상관 ID라도 박혀 있어야 밤이 짧아져요. 그리고 셀렉터 의심될 땐 일단 객체 만들고 있나부터 보면 절반은 끝이에요. 진짜로요.