Redux의 보일러플레이트에 지쳤다면 Zustand가 해답입니다. 2KB의 작은 크기로 강력한 상태 관리를 제공하며, 2026년 현재 가장 빠르게 성장하는 상태 관리 라이브러리입니다.
Zustand vs Redux vs Context
| 항목 | Zustand | Redux Toolkit | Context API |
|---|---|---|---|
| 번들 크기 | 2KB | 40KB+ | 0KB (내장) |
| 보일러플레이트 | 최소 | 중간 | 낮음 |
| 러닝 커브 | 매우 낮음 | 높음 | 낮음 |
| 미들웨어 | 지원 | 지원 | 없음 |
| DevTools | 지원 | 지원 | 제한적 |
| 리렌더링 최적화 | 자동 | 수동 | 없음 |
| TypeScript | 우수 | 우수 | 좋음 |
설치 및 기본 사용
설치
npm install zustand
첫 스토어 만들기
// stores/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 }),
}))
컴포넌트에서 사용
// components/Counter.tsx
import { useCounterStore } from '@/stores/counter'
export function Counter() {
const { count, increment, decrement, reset } = useCounterStore()
return (
<div>
<p>카운트: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>초기화</button>
</div>
)
}
선택적 구독 (성능 최적화)
// 전체 상태 구독 (비추천)
const { count, increment } = useCounterStore()
// 필요한 것만 구독 (추천)
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
// 여러 값 선택
const { count, total } = useCounterStore((state) => ({
count: state.count,
total: state.total,
}))
실전 패턴
1. 비동기 액션
// stores/users.ts
import { create } from 'zustand'
interface User {
id: number
name: string
email: string
}
interface UsersState {
users: User[]
loading: boolean
error: string | null
fetchUsers: () => Promise<void>
addUser: (user: Omit<User, 'id'>) => Promise<void>
}
export const useUsersStore = create<UsersState>((set, get) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/users')
const users = await res.json()
set({ users, loading: false })
} catch (error) {
set({ error: '사용자를 불러오는데 실패했습니다', loading: false })
}
},
addUser: async (userData) => {
set({ loading: true })
try {
const res = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(userData),
})
const newUser = await res.json()
set((state) => ({
users: [...state.users, newUser],
loading: false,
}))
} catch (error) {
set({ error: '사용자 추가에 실패했습니다', loading: false })
}
},
}))
2. 슬라이스 패턴 (대규모 앱)
// stores/slices/authSlice.ts
import { StateCreator } from 'zustand'
export interface AuthSlice {
user: User | null
isAuthenticated: boolean
login: (credentials: Credentials) => Promise<void>
logout: () => void
}
export const createAuthSlice: StateCreator<AuthSlice> = (set) => ({
user: null,
isAuthenticated: false,
login: async (credentials) => {
const user = await authApi.login(credentials)
set({ user, isAuthenticated: true })
},
logout: () => set({ user: null, isAuthenticated: false }),
})
// stores/slices/cartSlice.ts
export interface CartSlice {
items: CartItem[]
addItem: (item: Product) => void
removeItem: (id: string) => void
clearCart: () => void
}
export const createCartSlice: StateCreator<CartSlice> = (set) => ({
items: [],
addItem: (item) =>
set((state) => ({
items: [...state.items, { ...item, quantity: 1 }],
})),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
clearCart: () => set({ items: [] }),
})
// stores/index.ts - 슬라이스 조합
import { create } from 'zustand'
import { AuthSlice, createAuthSlice } from './slices/authSlice'
import { CartSlice, createCartSlice } from './slices/cartSlice'
type StoreState = AuthSlice & CartSlice
export const useStore = create<StoreState>()((...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a),
}))
3. 계산된 값 (Computed/Derived State)
interface CartState {
items: CartItem[]
// getter로 계산된 값
get totalItems(): number
get totalPrice(): number
}
export const useCartStore = create<CartState>((set, get) => ({
items: [],
// 메서드로 구현
get totalItems() {
return get().items.reduce((sum, item) => sum + item.quantity, 0)
},
get totalPrice() {
return get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
},
}))
// 또는 selector 패턴
const totalItems = useCartStore((state) =>
state.items.reduce((sum, item) => sum + item.quantity, 0)
)
미들웨어
devtools (Redux DevTools 연동)
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
export const useStore = create<State>()(
devtools(
(set) => ({
// 상태 및 액션
}),
{ name: 'MyStore' } // DevTools에 표시될 이름
)
)
persist (로컬 스토리지 저장)
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface SettingsState {
theme: 'light' | 'dark'
language: string
setTheme: (theme: 'light' | 'dark') => void
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'light',
language: 'ko',
setTheme: (theme) => set({ theme }),
}),
{
name: 'settings-storage', // localStorage 키
partialize: (state) => ({ theme: state.theme }), // 일부만 저장
}
)
)
immer (불변성 쉽게)
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface TodoState {
todos: Todo[]
addTodo: (text: string) => void
toggleTodo: (id: string) => void
}
export const useTodoStore = create<TodoState>()(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
// immer로 직접 수정 가능
state.todos.push({
id: crypto.randomUUID(),
text,
completed: false,
})
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
}),
}))
)
미들웨어 조합
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
export const useStore = create<State>()(
devtools(
persist(
immer((set) => ({
// 상태 및 액션
})),
{ name: 'app-storage' }
),
{ name: 'AppStore' }
)
)
React 외부에서 사용
// 스토어 외부에서 직접 접근
const state = useCounterStore.getState()
console.log(state.count)
// 직접 업데이트
useCounterStore.setState({ count: 10 })
// 구독
const unsubscribe = useCounterStore.subscribe(
(state) => console.log('상태 변경:', state.count)
)
// 구독 해제
unsubscribe()
서버 사이드에서 사용
// 서버 액션에서
import { useCartStore } from '@/stores/cart'
export async function addToCart(productId: string) {
const { addItem } = useCartStore.getState()
// API 호출 후
addItem(product)
}
TypeScript 패턴
타입 안전한 스토어
import { create } from 'zustand'
import { StateCreator } from 'zustand'
// 상태 타입
interface BearState {
bears: number
increase: (by: number) => void
}
// StateCreator 사용
const createBearSlice: StateCreator<BearState> = (set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
})
// 스토어 생성
export const useBearStore = create<BearState>()(createBearSlice)
제네릭 스토어 팩토리
// 재사용 가능한 스토어 팩토리
function createEntityStore<T extends { id: string }>() {
return create<{
entities: T[]
add: (entity: T) => void
remove: (id: string) => void
update: (id: string, updates: Partial<T>) => void
}>((set) => ({
entities: [],
add: (entity) =>
set((state) => ({ entities: [...state.entities, entity] })),
remove: (id) =>
set((state) => ({
entities: state.entities.filter((e) => e.id !== id),
})),
update: (id, updates) =>
set((state) => ({
entities: state.entities.map((e) =>
e.id === id ? { ...e, ...updates } : e
),
})),
}))
}
// 사용
const useProductStore = createEntityStore<Product>()
const useUserStore = createEntityStore<User>()
테스트
// __tests__/counter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
// 각 테스트 전 상태 초기화
beforeEach(() => {
useCounterStore.setState({ count: 0 })
})
it('증가 테스트', () => {
const { result } = renderHook(() => useCounterStore())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('감소 테스트', () => {
useCounterStore.setState({ count: 5 })
const { result } = renderHook(() => useCounterStore())
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
})
마이그레이션 가이드 (Redux → Zustand)
Before (Redux)
// actions
const INCREMENT = 'INCREMENT'
const increment = () => ({ type: INCREMENT })
// reducer
const counterReducer = (state = { count: 0 }, action) => {
switch (action.type) {
case INCREMENT:
return { ...state, count: state.count + 1 }
default:
return state
}
}
// store
const store = createStore(counterReducer)
// component
const count = useSelector((state) => state.count)
const dispatch = useDispatch()
dispatch(increment())
After (Zustand)
// 전부 한 파일에
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
// component
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
increment()
마치며
Zustand 선택 이유:
- 심플함: 보일러플레이트 거의 없음
- 크기: 2KB로 초경량
- 유연함: React 외부에서도 사용 가능
- 타입스크립트: 완벽한 타입 추론
- 성능: 자동 리렌더링 최적화
Redux가 필요한 경우: 매우 복잡한 상태 로직, 타임트래블 디버깅 필수, 대규모 팀
Zustand가 적합한 경우: 대부분의 React 프로젝트, 빠른 개발, 간단한 상태 관리
2026년, Zustand는 이미 "작은 프로젝트용"을 넘어 프로덕션 표준으로 자리 잡았습니다.