Claude Code로 Zustand 상태 관리 설계하기
Claude Code로 Zustand store, selector, persist, async action, 테스트와 리뷰 프롬프트를 설계합니다.
먼저 상태의 경계를 정한다
Zustand는 React에서 쓰기 쉬운 가벼운 상태 관리 라이브러리입니다. 상태 관리란 여러 화면과 컴포넌트가 함께 써야 하는 값을 정리하고, 그 값을 바꾸는 action을 한곳에서 관리하는 일입니다. Claude Code에게 “Zustand store를 만들어 줘”라고만 말하면 코드는 빠르게 나오지만, 경계를 주지 않으면 임시 검색어, 인증 정보, API 응답, toast, 장바구니가 모두 하나의 global store에 들어갈 수 있습니다.
이 글은 Claude Code를 단순 코드 생성기가 아니라 상태 설계 파트너로 쓰는 방법을 다룹니다. 예시는 관리 화면 필터, 장바구니, 인증 UI 상태, modal/toast, 낙관적 업데이트를 포함합니다. TypeScript store, selector, persist 부분 저장, 비동기 action, Vitest 테스트, Claude 리뷰 지시문을 복사 가능한 형태로 제공합니다.
기준 문서는 Zustand 공식 소개, persist middleware 문서, useShallow 가이드입니다. 서버 데이터 캐시는 TanStack Query 가이드를 함께 보고, 더 작은 단위의 상태는 Jotai atoms 글과 비교하면 판단하기 쉽습니다.
Zustand에 넣을 상태와 빼야 할 상태
기준은 단순합니다. 여러 멀리 떨어진 컴포넌트가 같은 값을 써야 하거나, 업데이트 규칙을 한곳에서 테스트하고 싶거나, URL만으로 표현하기 어려운 UI 상태일 때 Zustand를 사용합니다. 반대로 상품 목록, 사용자 목록, 검색 결과처럼 서버에서 온 데이터는 캐시, 재요청, stale 처리, 에러 복구가 중요하므로 TanStack Query 같은 서버 상태 도구에 맡기는 편이 안전합니다.
| 사용 사례 | Zustand에 넣기 | 넣지 않기 | Claude Code 지시 |
|---|---|---|---|
| 관리 화면 필터 | keyword, status, page, pageSize | 전체 API 응답 | URL 동기화 값과 UI 전용 값을 나누기 |
| 장바구니 | SKU, 수량, 표시 가격 | 결제 session, 실제 재고 | 낮은 위험 필드만 persist |
| 인증 UI | 로그인 dialog, checking 표시 | access token, 이메일, 주소 | PII와 secret 저장 금지 |
| Modal/toast | activeModal, 짧은 toast queue | 감사 로그, 긴 에러 | UI 표시용 짧은 값만 저장 |
| 낙관적 업데이트 | requestId, 이전 화면 상태 | 서버의 최종 사실 | rollback과 경쟁 규칙 정의 |
Masa가 관리 화면을 고칠 때 겪은 문제는 “Zustand를 쓰는 것” 자체를 목표로 둔 점이었습니다. 첫 구현은 검색 입력, URL query, 가져온 테이블 행, 선택 상태, toast를 하나로 묶었습니다. 페이지 이동 후에도 오래된 필터와 선택 행이 남아 다음 화면을 흔들었습니다. 해결책은 더 많은 middleware가 아니라, global로 남아도 되는 상태 목록을 먼저 만드는 일이었습니다.
flowchart LR
UI[React components] --> Selectors[Selectors and useShallow]
Selectors --> Store[Zustand store]
Store --> Actions[Actions]
Actions --> API[API and server state]
Store --> Persist[persist partialize]
Persist --> Storage[localStorage safe fields]
TypeScript store 구현
아래 예시는 범위를 좁힌 UI store입니다. 관리 화면 필터, 장바구니, 인증 UI, modal, toast를 포함합니다. 실제 프로젝트에서는 파일을 나눌 수 있지만, 처음에는 Claude Code에게 이처럼 작고 완성된 slice를 만들게 하면 리뷰가 쉽습니다.
import { create, type StateCreator } from "zustand";
export type OrderStatus = "all" | "paid" | "refunded" | "failed";
export type AuthUiStatus = "anonymous" | "checking" | "signedIn";
export type ModalId = "invite-user" | "cart-drawer" | "delete-order" | null;
export interface AdminFilters {
keyword: string;
status: OrderStatus;
page: number;
pageSize: number;
}
export interface CartLine {
id: string;
name: string;
price: number;
quantity: number;
}
export interface AuthUi {
status: AuthUiStatus;
loginDialogOpen: boolean;
}
export interface Toast {
id: string;
kind: "success" | "error" | "info";
message: string;
}
export interface CommerceUiState {
filters: AdminFilters;
cart: CartLine[];
auth: AuthUi;
activeModal: ModalId;
toasts: Toast[];
setFilters: (patch: Partial<AdminFilters>) => void;
resetFilters: () => void;
addToCart: (line: Omit<CartLine, "quantity">) => void;
removeFromCart: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
setAuthStatus: (status: AuthUiStatus) => void;
setLoginDialogOpen: (open: boolean) => void;
openModal: (modal: Exclude<ModalId, null>) => void;
closeModal: () => void;
pushToast: (toast: Omit<Toast, "id">) => void;
dismissToast: (id: string) => void;
checkoutTotal: () => number;
}
export const initialFilters: AdminFilters = {
keyword: "",
status: "all",
page: 1,
pageSize: 25,
};
const createToastId = () =>
globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
export const createCommerceUiSlice: StateCreator<CommerceUiState> = (set, get) => ({
filters: initialFilters,
cart: [],
auth: { status: "anonymous", loginDialogOpen: false },
activeModal: null,
toasts: [],
setFilters: (patch) =>
set((state) => ({
filters: {
...state.filters,
...patch,
page: patch.page ?? 1,
},
})),
resetFilters: () => set({ filters: initialFilters }),
addToCart: (line) =>
set((state) => {
const current = state.cart.find((item) => item.id === line.id);
if (!current) {
return { cart: [...state.cart, { ...line, quantity: 1 }] };
}
return {
cart: state.cart.map((item) =>
item.id === line.id ? { ...item, quantity: item.quantity + 1 } : item,
),
};
}),
removeFromCart: (id) =>
set((state) => ({
cart: state.cart.filter((item) => item.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
cart: state.cart
.map((item) =>
item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item,
)
.filter((item) => item.quantity > 0),
})),
setAuthStatus: (status) =>
set((state) => ({
auth: { ...state.auth, status },
})),
setLoginDialogOpen: (open) =>
set((state) => ({
auth: { ...state.auth, loginDialogOpen: open },
})),
openModal: (modal) => set({ activeModal: modal }),
closeModal: () => set({ activeModal: null }),
pushToast: (toast) =>
set((state) => ({
toasts: [...state.toasts, { ...toast, id: createToastId() }],
})),
dismissToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
})),
checkoutTotal: () =>
get().cart.reduce((total, item) => total + item.price * item.quantity, 0),
});
export const useCommerceUiStore = create<CommerceUiState>()(createCommerceUiSlice);
핵심은 인증 자체가 아니라 인증 UI만 저장한다는 점입니다. access token, 이메일, 주소, 실명, 결제 session은 이 store에 넣지 않습니다. Claude Code에게 persist를 요청할 때는 PII, 즉 개인을 식별할 수 있는 정보는 저장하지 말라고 명시해야 합니다.
selector로 렌더링 범위를 줄인다
Zustand hook은 쓰기 쉽지만, useCommerceUiStore()처럼 전체 store를 구독하면 작은 변경도 많은 컴포넌트를 다시 렌더링합니다. toast 하나가 추가됐는데 장바구니 badge나 필터 폼까지 다시 그려질 수 있습니다. selector는 store에서 필요한 조각만 고르는 함수입니다.
import { useShallow } from "zustand/react/shallow";
import {
useCommerceUiStore,
type CommerceUiState,
} from "./commerce-ui-store";
export const selectCartCount = (state: CommerceUiState) =>
state.cart.reduce((sum, item) => sum + item.quantity, 0);
export const selectCartTotal = (state: CommerceUiState) => state.checkoutTotal();
export function CartBadge() {
const count = useCommerceUiStore(selectCartCount);
return <button type="button">Cart ({count})</button>;
}
export function AdminFilterSummary() {
const { filters, setFilters, resetFilters } = useCommerceUiStore(
useShallow((state) => ({
filters: state.filters,
setFilters: state.setFilters,
resetFilters: state.resetFilters,
})),
);
return (
<form>
<input
value={filters.keyword}
placeholder="Search orders"
onChange={(event) => setFilters({ keyword: event.currentTarget.value })}
/>
<select
value={filters.status}
onChange={(event) =>
setFilters({ status: event.currentTarget.value as CommerceUiState["filters"]["status"] })
}
>
<option value="all">All</option>
<option value="paid">Paid</option>
<option value="refunded">Refunded</option>
<option value="failed">Failed</option>
</select>
<output>Page {filters.page}</output>
<button type="button" onClick={resetFilters}>
Reset
</button>
</form>
);
}
Claude Code에게는 디버그용 컴포넌트를 제외하고 store 전체 구독을 금지한다고 말합니다. selector가 객체를 반환한다면 useShallow를 쓰거나 primitive selector로 나눕니다. 작은 데모에서는 차이가 작아도, 실제 관리 화면에서 테이블, 사이드바, 알림, 필터가 동시에 움직이면 이 규칙이 성능을 지킵니다.
persist는 안전한 필드만 저장한다
persist middleware는 새로고침 뒤에도 상태를 유지할 수 있어 편리합니다. 하지만 저장 범위를 넓히면 개인정보 문제가 됩니다. 장바구니와 필터는 저장 후보이고, modal, toast, 로그인 dialog, requestId, token, 개인 정보는 저장하지 않습니다. partialize로 저장 대상을 제한합니다.
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createCommerceUiSlice,
type CommerceUiState,
} from "./commerce-ui-store";
const noopStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
type PersistedCommerceUiState = Pick<CommerceUiState, "filters" | "cart">;
export const usePersistedCommerceUiStore = create<CommerceUiState>()(
persist(createCommerceUiSlice, {
name: "commerce-ui-v1",
version: 1,
storage: createJSONStorage(() =>
typeof window === "undefined" ? noopStorage : window.localStorage,
),
partialize: (state): PersistedCommerceUiState => ({
filters: state.filters,
cart: state.cart,
}),
}),
);
SSR 환경에서는 hydration 불일치도 확인해야 합니다. 서버는 localStorage를 읽지 못하지만 브라우저는 저장된 장바구니나 필터를 복원합니다. 서버 HTML에는 0개였는데 브라우저에서 3개로 바뀌면 UI가 흔들릴 수 있습니다. 이런 값은 mounted 후 표시하거나 SSR에 민감한 영역에서 분리합니다.
비동기 action과 낙관적 업데이트
낙관적 업데이트는 서버 성공을 기다리지 않고 화면을 먼저 바꾸는 방식입니다. 팔로우, 좋아요, 낮은 위험의 장바구니 수량 변경에 잘 맞습니다. 문제는 오래된 요청이 나중에 돌아와 새 상태를 덮어쓸 수 있다는 점입니다. requestId와 이전 상태 스냅샷을 함께 저장해 rollback할 수 있어야 합니다.
import { create } from "zustand";
interface Profile {
id: string;
name: string;
isFollowing: boolean;
}
interface ProfileState {
profiles: Record<string, Profile>;
followRequestIds: Record<string, string>;
setProfile: (profile: Profile) => void;
followOptimistically: (profileId: string) => Promise<void>;
}
const removeKey = <T,>(record: Record<string, T>, key: string) => {
const { [key]: _removed, ...rest } = record;
return rest;
};
async function updateFollowOnServer(profileId: string, follow: boolean) {
const response = await fetch(`/api/profiles/${profileId}/follow`, {
method: follow ? "PUT" : "DELETE",
});
if (!response.ok) {
throw new Error(`Follow update failed: ${response.status}`);
}
}
export const useProfileStore = create<ProfileState>((set, get) => ({
profiles: {},
followRequestIds: {},
setProfile: (profile) =>
set((state) => ({
profiles: { ...state.profiles, [profile.id]: profile },
})),
followOptimistically: async (profileId) => {
const before = get().profiles[profileId];
if (!before) {
throw new Error("Profile not found");
}
const requestId = `${profileId}-${Date.now()}`;
set((state) => ({
profiles: {
...state.profiles,
[profileId]: { ...before, isFollowing: true },
},
followRequestIds: {
...state.followRequestIds,
[profileId]: requestId,
},
}));
try {
await updateFollowOnServer(profileId, true);
} catch (error) {
set((state) => {
if (state.followRequestIds[profileId] !== requestId) return state;
return {
profiles: { ...state.profiles, [profileId]: before },
followRequestIds: removeKey(state.followRequestIds, profileId),
};
});
throw error;
}
set((state) => {
if (state.followRequestIds[profileId] !== requestId) return state;
return {
followRequestIds: removeKey(state.followRequestIds, profileId),
};
});
},
}));
Claude Code에는 API 경로뿐 아니라 제품 규칙을 줘야 합니다. 연속 클릭을 허용하는지, 실패 시 무엇을 되돌리는지, toast를 보여줄지, 나중 요청이 이기는지까지 적습니다. 그렇지 않으면 happy path만 통과하는 깔끔한 async/await 코드가 나올 수 있습니다.
Vitest로 store 테스트하기
Zustand action은 React 컴포넌트를 렌더링하지 않아도 테스트할 수 있습니다. 테스트마다 초기 상태로 되돌리고, 동작과 설계 규칙을 함께 검증합니다.
import { beforeEach, describe, expect, it } from "vitest";
import { useCommerceUiStore } from "./commerce-ui-store";
const initialState = useCommerceUiStore.getInitialState();
beforeEach(() => {
useCommerceUiStore.setState(initialState, true);
});
describe("commerce ui store", () => {
it("increments cart quantity and calculates total", () => {
const store = useCommerceUiStore.getState();
store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
expect(useCommerceUiStore.getState().cart[0]?.quantity).toBe(2);
expect(useCommerceUiStore.getState().checkoutTotal()).toBe(2400);
});
it("resets the page when a filter changes", () => {
useCommerceUiStore.getState().setFilters({ page: 4 });
useCommerceUiStore.getState().setFilters({ keyword: "refund" });
expect(useCommerceUiStore.getState().filters).toMatchObject({
keyword: "refund",
page: 1,
});
});
it("keeps auth UI and toasts explicit", () => {
useCommerceUiStore.getState().setAuthStatus("signedIn");
useCommerceUiStore.getState().pushToast({
kind: "success",
message: "Saved",
});
expect(useCommerceUiStore.getState().auth.status).toBe("signedIn");
expect(useCommerceUiStore.getState().toasts).toHaveLength(1);
});
});
테스트 이름은 상태 계약을 설명해야 합니다. 필터 변경 시 page가 1로 돌아가는지, 수량 0이면 행이 삭제되는지, persist 대상이 제한되는지, 낙관적 업데이트 실패 시 rollback되는지를 테스트에 남기면 Claude Code의 후속 수정도 안정됩니다.
Claude Code 리뷰 지시문
구현 뒤에는 Claude Code에게 다시 리뷰를 시킵니다. 다만 “review this”라고만 쓰면 느슨합니다. 상태 관리 리뷰는 경계, 개인정보, 렌더링, 비동기 경쟁을 의도적으로 찾아야 합니다.
이 diff의 Zustand 상태 관리 설계만 리뷰해 주세요.
확인할 항목:
1. component local state로 충분한 값이 global store로 이동하지 않았는가
2. React 컴포넌트가 store 전체가 아니라 selector를 쓰는가
3. persist partialize가 PII, token, modal, toast, requestId를 저장하지 않는가
4. SSR/hydration 불일치가 처리되었는가
5. async action이 오래된 응답, 연속 클릭, 실패 rollback을 처리하는가
6. devtools나 immer가 명확한 이유 없이 추가되지 않았는가
7. Vitest가 주요 action과 하나 이상의 실패 경로를 검증하는가
반환 형식:
- Blocking issues
- Suggested patches
- Missing tests
- Questions before merge
이 프롬프트는 구현이 끝난 뒤 별도 메시지로 보내는 편이 좋습니다. 같은 문맥에서 생성과 리뷰를 동시에 맡기면 Claude가 방금 만든 설계를 긍정하기 쉽습니다. 체크리스트를 좁히면 selector 누락, 위험한 persist, rollback 없는 낙관적 업데이트를 더 잘 찾습니다.
피해야 할 함정
첫째, 모든 것을 global store로 옮기는 것입니다. 작은 입력값, hover, 한 컴포넌트 안에서 끝나는 popover는 local state가 더 낫습니다. 둘째, selector가 부족하면 무관한 업데이트가 많은 컴포넌트를 다시 렌더링합니다. 셋째, persist로 PII가 새는 문제입니다. localStorage에는 token, 이메일, 주소, 회사명, 문의 내용, 결제 session을 저장하지 않습니다.
넷째, SSR/hydration 불일치입니다. 서버는 브라우저 storage를 모릅니다. 다섯째, 비동기 action 경쟁입니다. 느린 네트워크와 연속 클릭은 오래된 응답 문제를 만듭니다. 여섯째, devtools와 immer 남용입니다. middleware는 이유가 있을 때만 추가해야 합니다.
Claude Code Lab 상담과 검증 메모
Claude Code Lab의교육 및 도입 상담에서는 기존 React 앱을 기준으로 어떤 상태를 Zustand로 옮길지, 어떤 서버 상태를 TanStack Query에 남길지, 어떤 값을 persist할지, 어떤 리뷰 프롬프트를 팀 규칙으로 만들지 함께 정리할 수 있습니다.
2026년 6월 2일 기준으로 이 글은 Zustand 공식 문서의 create, persist, useShallow, testing 내용을 확인해 작성했습니다. 실제 프로젝트에서는 먼저 “persist해도 되는 필드” 표를 만든 팀일수록 localStorage의 PII 삭제와 hydration 문제 수정에 쓰는 시간이 줄었습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.