Zustand v5를 사용하여 클라이언트 상태를 간단하고 효율적으로 관리하는 방법을 학습합니다.
클라이언트 상태는 브라우저에서만 존재하고 서버와 동기화할 필요가 없는 상태입니다. 서버 상태(TanStack Query)와 구분하여 관리해야 합니다.
🎨 UI 상태
모달 열림/닫힘, 사이드바 토글, 탭 선택, 드롭다운
🌙 테마 설정
다크 모드, 폰트 크기, 언어 설정
📝 폼 입력 중 상태
제출 전 임시 데이터, 유효성 검사 결과
🛒 장바구니 (로그인 전)
비회원 장바구니, 로컬 저장
| 구분 | 서버 상태 | 클라이언트 상태 |
|---|---|---|
| 출처 | 서버/DB | 브라우저 |
| 동기화 | 서버와 동기화 필요 | 동기화 불필요 |
| 캐싱 | 복잡 (stale, refetch) | 단순 |
| 예시 | 상품 목록, 사용자 정보 | 모달 상태, 테마 |
| 도구 | TanStack Query | Zustand, Jotai |
간단한 API
Redux 대비 보일러플레이트 90% 감소
작은 번들 크기
~1KB (gzipped), Redux Toolkit ~11KB
TypeScript 지원
완벽한 타입 추론
미들웨어 지원
persist, devtools, immer 등
상태 관리 선택 기준
Zustand는 간단한 API로 전역 상태를 관리합니다. create 함수로 스토어를 만들고, 훅처럼 사용합니다.
npm install zustand
// store/counter.ts
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// 컴포넌트에서 사용
'use client';
import { useCounterStore } from '@/store/counter';
export function Counter() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}// 전체 상태 구독 (비권장 - 모든 변경에 리렌더링)
const { count, increment } = useCounterStore();
// 선택적 구독 (권장 - 필요한 것만)
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
// 여러 값을 한번에 선택 (shallow 비교 필요)
import { shallow } from 'zustand/shallow';
const { count, increment } = useCounterStore(
(state) => ({ count: state.count, increment: state.increment }),
shallow
);
// 또는 useShallow 훅 사용 (Zustand 4.4+)
import { useShallow } from 'zustand/react/shallow';
const { count, increment } = useCounterStore(
useShallow((state) => ({ count: state.count, increment: state.increment }))
);// set과 get 모두 사용
export const useCartStore = create<CartState>((set, get) => ({
items: [],
addItem: (item) => {
const currentItems = get().items;
const existingItem = currentItems.find(i => i.id === item.id);
if (existingItem) {
// 이미 있으면 수량 증가
set({
items: currentItems.map(i =>
i.id === item.id
? { ...i, quantity: i.quantity + 1 }
: i
),
});
} else {
// 새 상품 추가
set({ items: [...currentItems, { ...item, quantity: 1 }] });
}
},
// Computed value (getter 함수)
getTotalPrice: () => {
return get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
getTotalItems: () => {
return get().items.reduce((sum, item) => sum + item.quantity, 0);
},
}));// 컴포넌트 외부에서 상태 접근
const currentCount = useCounterStore.getState().count;
// 컴포넌트 외부에서 상태 변경
useCounterStore.getState().increment();
// 상태 변경 구독 (컴포넌트 외부)
const unsubscribe = useCounterStore.subscribe(
(state) => console.log('Count changed:', state.count)
);
// 구독 해제
unsubscribe();Zustand 핵심 포인트
Zustand 미들웨어로 상태 영속화, 개발 도구 연동, 불변성 관리 등을 쉽게 구현합니다.
// 장바구니를 localStorage에 저장
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface CartState {
items: CartItem[];
addItem: (item: CartItem) => void;
clearCart: () => void;
}
export const useCartStore = create<CartState>()(
persist(
(set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item],
})),
clearCart: () => set({ items: [] }),
}),
{
name: 'cart-storage', // localStorage 키
storage: createJSONStorage(() => localStorage),
// 저장할 상태만 선택 (선택적)
partialize: (state) => ({ items: state.items }),
// 버전 관리 (마이그레이션)
version: 1,
migrate: (persistedState, version) => {
if (version === 0) {
// v0 → v1 마이그레이션
return { ...persistedState, items: [] };
}
return persistedState;
},
}
)
);// Redux DevTools 연동
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export const useCartStore = create<CartState>()(
devtools(
(set) => ({
items: [],
addItem: (item) => set(
(state) => ({ items: [...state.items, item] }),
false,
'addItem' // 액션 이름 (DevTools에 표시)
),
removeItem: (id) => set(
(state) => ({ items: state.items.filter(i => i.id !== id) }),
false,
'removeItem'
),
}),
{
name: 'CartStore', // DevTools에 표시될 스토어 이름
enabled: process.env.NODE_ENV === 'development',
}
)
);// immer로 복잡한 상태 업데이트 간소화
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface CartState {
items: CartItem[];
updateQuantity: (id: string, quantity: number) => void;
}
export const useCartStore = create<CartState>()(
immer((set) => ({
items: [],
// immer 없이 (복잡)
// updateQuantity: (id, quantity) => set((state) => ({
// items: state.items.map(item =>
// item.id === id ? { ...item, quantity } : item
// ),
// })),
// immer 사용 (간단)
updateQuantity: (id, quantity) => set((state) => {
const item = state.items.find(i => i.id === id);
if (item) {
item.quantity = quantity; // 직접 수정 가능!
}
}),
// 중첩 객체도 쉽게
updateItemOption: (id, optionKey, value) => set((state) => {
const item = state.items.find(i => i.id === id);
if (item) {
item.options[optionKey] = value;
}
}),
}))
);// 여러 미들웨어 함께 사용
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export const useCartStore = create<CartState>()(
devtools(
persist(
immer((set) => ({
items: [],
addItem: (item) => set((state) => {
state.items.push(item);
}),
updateQuantity: (id, quantity) => set((state) => {
const item = state.items.find(i => i.id === id);
if (item) item.quantity = quantity;
}),
})),
{
name: 'cart-storage',
}
),
{
name: 'CartStore',
enabled: process.env.NODE_ENV === 'development',
}
)
);
// 미들웨어 순서: devtools > persist > immer > 스토어
// 바깥쪽부터 안쪽으로 감싸는 구조미들웨어 선택 가이드
실제 이커머스에서 사용하는 장바구니 스토어를 Zustand로 구현합니다. 재고 검증, 수량 제한, 로컬 저장 등 실전 기능을 포함합니다.
// types/cart.ts
export interface CartItem {
productId: string;
name: string;
price: number;
originalPrice?: number;
image: string;
quantity: number;
maxQuantity: number; // 재고 수량
options?: {
size?: string;
color?: string;
};
}
export interface CartState {
items: CartItem[];
isLoading: boolean;
// Actions
addItem: (item: Omit<CartItem, 'quantity'> & { quantity?: number }) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
// Computed
getTotalItems: () => number;
getTotalPrice: () => number;
getDiscountAmount: () => number;
isInCart: (productId: string) => boolean;
}// store/cart.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import { CartState, CartItem } from '@/types/cart';
export const useCartStore = create<CartState>()(
devtools(
persist(
(set, get) => ({
items: [],
isLoading: false,
addItem: (newItem) => {
set((state) => {
const existingIndex = state.items.findIndex(
item => item.productId === newItem.productId
);
if (existingIndex !== -1) {
// 이미 있으면 수량 증가
const updatedItems = [...state.items];
const existing = updatedItems[existingIndex];
const newQuantity = Math.min(
existing.quantity + (newItem.quantity || 1),
newItem.maxQuantity
);
updatedItems[existingIndex] = {
...existing,
quantity: newQuantity,
};
return { items: updatedItems };
}
// 새 상품 추가
return {
items: [...state.items, {
...newItem,
quantity: newItem.quantity || 1,
}],
};
}, false, 'addItem');
},
removeItem: (productId) => {
set(
(state) => ({
items: state.items.filter(item => item.productId !== productId),
}),
false,
'removeItem'
);
},
updateQuantity: (productId, quantity) => {
set((state) => {
const item = state.items.find(i => i.productId === productId);
if (!item) return state;
// 0 이하면 삭제
if (quantity <= 0) {
return {
items: state.items.filter(i => i.productId !== productId),
};
}
// 최대 수량 제한
const validQuantity = Math.min(quantity, item.maxQuantity);
return {
items: state.items.map(i =>
i.productId === productId
? { ...i, quantity: validQuantity }
: i
),
};
}, false, 'updateQuantity');
},
clearCart: () => set({ items: [] }, false, 'clearCart'),
// Computed values
getTotalItems: () => {
return get().items.reduce((sum, item) => sum + item.quantity, 0);
},
getTotalPrice: () => {
return get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
getDiscountAmount: () => {
return get().items.reduce((sum, item) => {
if (item.originalPrice) {
return sum + (item.originalPrice - item.price) * item.quantity;
}
return sum;
}, 0);
},
isInCart: (productId) => {
return get().items.some(item => item.productId === productId);
},
}),
{
name: 'cart-storage',
partialize: (state) => ({ items: state.items }),
}
),
{ name: 'CartStore' }
)
);// components/cart/CartItem.tsx
'use client';
import Image from 'next/image';
import { Minus, Plus, Trash2 } from 'lucide-react';
import { useCartStore } from '@/store/cart';
import { CartItem as CartItemType } from '@/types/cart';
import { formatPrice } from '@/lib/format';
interface CartItemProps {
item: CartItemType;
}
export function CartItem({ item }: CartItemProps) {
const { updateQuantity, removeItem } = useCartStore();
const handleDecrease = () => {
updateQuantity(item.productId, item.quantity - 1);
};
const handleIncrease = () => {
if (item.quantity < item.maxQuantity) {
updateQuantity(item.productId, item.quantity + 1);
}
};
return (
<div className="flex gap-4 py-4 border-b">
<div className="relative w-24 h-24 flex-shrink-0">
<Image
src={item.image}
alt={item.name}
fill
className="object-cover rounded"
/>
</div>
<div className="flex-1">
<h3 className="font-medium">{item.name}</h3>
{item.options && (
<p className="text-sm text-muted-foreground">
{item.options.size && `사이즈: ${item.options.size}`}
{item.options.color && ` / 색상: ${item.options.color}`}
</p>
)}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-2">
<button
onClick={handleDecrease}
className="p-1 border rounded hover:bg-gray-100"
>
<Minus className="h-4 w-4" />
</button>
<span className="w-8 text-center">{item.quantity}</span>
<button
onClick={handleIncrease}
disabled={item.quantity >= item.maxQuantity}
className="p-1 border rounded hover:bg-gray-100 disabled:opacity-50"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="text-right">
<p className="font-bold">{formatPrice(item.price * item.quantity)}</p>
{item.originalPrice && (
<p className="text-sm text-gray-400 line-through">
{formatPrice(item.originalPrice * item.quantity)}
</p>
)}
</div>
</div>
</div>
<button
onClick={() => removeItem(item.productId)}
className="p-2 text-gray-400 hover:text-red-500"
>
<Trash2 className="h-5 w-5" />
</button>
</div>
);
}장바구니 구현 포인트
모달, 사이드바, 토스트 등 UI 상태를 Zustand로 관리합니다. 전역 UI 상태를 중앙에서 제어하여 일관된 UX를 제공합니다.
// store/modal.ts
import { create } from 'zustand';
type ModalType = 'login' | 'cart' | 'search' | 'confirm' | null;
interface ModalData {
title?: string;
message?: string;
onConfirm?: () => void;
onCancel?: () => void;
}
interface ModalState {
type: ModalType;
data: ModalData | null;
isOpen: boolean;
openModal: (type: ModalType, data?: ModalData) => void;
closeModal: () => void;
}
export const useModalStore = create<ModalState>((set) => ({
type: null,
data: null,
isOpen: false,
openModal: (type, data = null) => set({
type,
data,
isOpen: true,
}),
closeModal: () => set({
type: null,
data: null,
isOpen: false,
}),
}));
// 사용 예시
// useModalStore.getState().openModal('login');
// useModalStore.getState().openModal('confirm', {
// title: '삭제 확인',
// message: '정말 삭제하시겠습니까?',
// onConfirm: () => deleteItem(),
// });// components/modal/ModalProvider.tsx
'use client';
import { useModalStore } from '@/store/modal';
import { LoginModal } from './LoginModal';
import { CartModal } from './CartModal';
import { SearchModal } from './SearchModal';
import { ConfirmModal } from './ConfirmModal';
export function ModalProvider() {
const { type, isOpen, data, closeModal } = useModalStore();
if (!isOpen) return null;
const modals = {
login: <LoginModal onClose={closeModal} />,
cart: <CartModal onClose={closeModal} />,
search: <SearchModal onClose={closeModal} />,
confirm: <ConfirmModal data={data} onClose={closeModal} />,
};
return (
<div className="fixed inset-0 z-50">
{/* 배경 오버레이 */}
<div
className="absolute inset-0 bg-black/50"
onClick={closeModal}
/>
{/* 모달 컨텐츠 */}
<div className="absolute inset-0 flex items-center justify-center p-4">
{type && modals[type]}
</div>
</div>
);
}
// app/layout.tsx에 추가
// <ModalProvider />// store/toast.ts
import { create } from 'zustand';
type ToastType = 'success' | 'error' | 'warning' | 'info';
interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
interface ToastState {
toasts: Toast[];
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
}
export const useToastStore = create<ToastState>((set, get) => ({
toasts: [],
addToast: (toast) => {
const id = Math.random().toString(36).slice(2);
const newToast = { ...toast, id };
set((state) => ({
toasts: [...state.toasts, newToast],
}));
// 자동 제거
const duration = toast.duration || 3000;
setTimeout(() => {
get().removeToast(id);
}, duration);
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter(t => t.id !== id),
}));
},
}));
// 편의 함수
export const toast = {
success: (message: string) =>
useToastStore.getState().addToast({ type: 'success', message }),
error: (message: string) =>
useToastStore.getState().addToast({ type: 'error', message }),
warning: (message: string) =>
useToastStore.getState().addToast({ type: 'warning', message }),
info: (message: string) =>
useToastStore.getState().addToast({ type: 'info', message }),
};
// 사용 예시
// toast.success('장바구니에 추가되었습니다');
// toast.error('결제에 실패했습니다');// store/sidebar.ts
import { create } from 'zustand';
interface SidebarState {
isOpen: boolean;
toggle: () => void;
open: () => void;
close: () => void;
}
export const useSidebarStore = create<SidebarState>((set) => ({
isOpen: false,
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
}));
// 컴포넌트에서 사용
function Header() {
const toggle = useSidebarStore((state) => state.toggle);
return (
<button onClick={toggle}>
<Menu className="h-6 w-6" />
</button>
);
}
function Sidebar() {
const { isOpen, close } = useSidebarStore();
if (!isOpen) return null;
return (
<div className="fixed inset-y-0 left-0 w-64 bg-white shadow-lg">
<button onClick={close}>닫기</button>
{/* 사이드바 내용 */}
</div>
);
}UI 상태 관리 팁
대규모 프로젝트에서 Zustand 스토어를 효과적으로 구성하는 패턴을 알아봅니다. 슬라이스 패턴, 스토어 분리, 타입 안전성 등을 다룹니다.
// 큰 스토어를 슬라이스로 분리
// store/slices/cartSlice.ts
import { StateCreator } from 'zustand';
export interface CartSlice {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
}
export const createCartSlice: StateCreator<
CartSlice & UserSlice, // 전체 스토어 타입
[],
[],
CartSlice
> = (set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item],
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id),
})),
});
// store/slices/userSlice.ts
export interface UserSlice {
user: User | null;
setUser: (user: User | null) => void;
}
export const createUserSlice: StateCreator<
CartSlice & UserSlice,
[],
[],
UserSlice
> = (set) => ({
user: null,
setUser: (user) => set({ user }),
});
// store/index.ts - 슬라이스 합치기
import { create } from 'zustand';
import { createCartSlice, CartSlice } from './slices/cartSlice';
import { createUserSlice, UserSlice } from './slices/userSlice';
type StoreState = CartSlice & UserSlice;
export const useStore = create<StoreState>()((...args) => ({
...createCartSlice(...args),
...createUserSlice(...args),
}));| 방식 | 장점 | 단점 | 사용 시점 |
|---|---|---|---|
| 분리된 스토어 | 독립적, 테스트 용이 | 스토어 간 통신 어려움 | 독립적인 기능 |
| 슬라이스 패턴 | 코드 분리, 상호 참조 가능 | 타입 복잡 | 연관된 기능 |
// 복잡한 액션을 별도 파일로 분리
// store/cart/actions.ts
import { useCartStore } from './store';
import { CartItem } from './types';
export async function addToCartWithValidation(item: CartItem) {
const store = useCartStore.getState();
// 재고 확인
const response = await fetch(`/api/products/${item.productId}/stock`);
const { stock } = await response.json();
if (stock < item.quantity) {
throw new Error('재고가 부족합니다');
}
// 장바구니에 추가
store.addItem(item);
// 서버와 동기화 (로그인 상태일 때)
if (store.isLoggedIn) {
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify(item),
});
}
}
// 컴포넌트에서 사용
import { addToCartWithValidation } from '@/store/cart/actions';
async function handleAddToCart() {
try {
await addToCartWithValidation(product);
toast.success('장바구니에 추가되었습니다');
} catch (error) {
toast.error(error.message);
}
}// 권장 폴더 구조
store/
├── cart/
│ ├── store.ts # 장바구니 스토어
│ ├── types.ts # 타입 정의
│ ├── actions.ts # 복잡한 액션
│ └── index.ts # export
├── auth/
│ ├── store.ts # 인증 스토어
│ ├── types.ts
│ └── index.ts
├── ui/
│ ├── modal.ts # 모달 상태
│ ├── toast.ts # 토스트 상태
│ ├── sidebar.ts # 사이드바 상태
│ └── index.ts
└── index.ts # 전체 export
// store/index.ts
export { useCartStore } from './cart';
export { useAuthStore } from './auth';
export { useModalStore, useToastStore, useSidebarStore } from './ui';선택적 구독 사용
필요한 상태만 구독하여 불필요한 리렌더링 방지
액션에 이름 부여
devtools에서 디버깅 용이
타입 안전성 확보
StateCreator 타입으로 슬라이스 정의
서버 상태와 분리
서버 데이터는 TanStack Query, 클라이언트 상태만 Zustand
Zustand 핵심 정리