Zustand 완벽 가이드: React 상태 관리를 단순하게 만드는 법
Redux 보일러플레이트와 Context 리렌더링 문제에 지쳤다면, 1.2KB 경량 상태 관리 라이브러리 Zustand의 핵심 API부터 미들웨어, 실전 패턴, 안티패턴까지 알아봅니다.
React 앱을 만들면서 상태 관리를 고민해본 적 없는 개발자는 드물 것입니다. Redux의 action type, action creator, reducer를 한 땀 한 땀 작성하다 보면 "이게 정말 필요한 건가?" 하는 의문이 들기도 합니다. Redux Toolkit이 보일러플레이트를 많이 줄여주었지만, 여전히 Provider로 앱을 감싸야 하고 설정 코드가 적지 않습니다.
그렇다면 React 내장 Context API는 어떨까요? 간단한 값 공유에는 편리하지만, 상태가 바뀔 때마다 해당 Context를 구독하는 모든 컴포넌트가 리렌더링되는 성능 문제가 숨어 있습니다. useMemo와 React.memo로 방어해야 하는 순간, 단순함이라는 장점은 사라집니다.
이런 고민 속에서 등장한 것이 Zustand 입니다. 독일어로 "상태"를 뜻하는 이 라이브러리는 2026년 현재 npm 주간 다운로드 약 2,000만 회를 기록하며 Redux Toolkit을 앞서는 가장 인기 있는 React 상태 관리 도구가 되었습니다. 번들 크기는 약 1.2KB(min+gzip)에 불과합니다.
오늘은 Zustand의 핵심 개념부터 TypeScript 활용, 미들웨어, 실전 패턴, 그리고 주의해야 할 안티패턴까지 함께 살펴보겠습니다.
Zustand란 무엇인가
Zustand는 pmndrs(Poimandres) 팀이 만든 React 상태 관리 라이브러리입니다. "A small, fast and scalable bearbones state-management solution"을 표방하며, 단순화된 flux 원칙 위에 훅 기반 API를 제공합니다.
핵심 철학은 명확합니다.
- 단순함: 스토어 하나 만들고, 훅으로 꺼내 쓴다. 그게 전부입니다
- 최소 보일러플레이트: Provider 래핑 불필요, action type 정의 불필요
- 성능: 셀렉터 기반 구독으로 필요한 컴포넌트만 리렌더링
- 유연성: persist, devtools, immer 등 미들웨어로 확장 가능
현재 최신 버전은 v5.0.12(2025년 3월 릴리스)이며, React 18 이상과 TypeScript 4.5 이상을 요구합니다. v5는 새 기능 추가보다 정리와 최적화 에 집중한 릴리스로, React 18의 네이티브 useSyncExternalStore를 사용하여 동시성 모드에서의 안정성을 확보하고 번들 크기를 더 줄였습니다.
설치 및 기본 사용법
설치는 한 줄이면 됩니다.
npm install zustand
가장 기본적인 스토어를 만들어 보겠습니다.
import { create } from 'zustand'
type CounterState = {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
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 }),
}))
create 함수에 콜백을 전달하면, 그 콜백이 상태와 액션을 정의하는 객체를 반환합니다. set 함수는 상태를 업데이트하며 기본적으로 얕은 병합(shallow merge)을 수행합니다. 반환값인 useCounterStore는 React 훅이므로 컴포넌트에서 바로 사용할 수 있습니다.
function Counter() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
const reset = useCounterStore((state) => state.reset)
return (
<div>
<h1>{count}</h1>
<button onClick={increment}>+1</button>
<button onClick={reset}>초기화</button>
</div>
)
}
여기서 주목할 점은 셀렉터 입니다. useCounterStore((state) => state.count)처럼 필요한 상태만 선택하면, count가 바뀔 때만 이 컴포넌트가 리렌더링됩니다. 다른 상태가 변경되어도 영향을 받지 않습니다. Provider로 감싸는 것도, action type을 정의하는 것도 필요 없습니다.
set과 get 함수
set 외에 get 함수도 제공됩니다. 액션 내에서 현재 상태를 읽어야 할 때 유용합니다.
const useStore = create<StoreState>((set, get) => ({
items: [],
totalCount: () => get().items.length,
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}))
스토어 외부에서도 상태에 접근할 수 있습니다. getState()와 setState()를 사용하면 React 컴포넌트가 아닌 곳에서도 상태를 읽고 쓸 수 있습니다.
// 유틸리티 함수, API 호출 로직 등에서 활용
const currentCount = useCounterStore.getState().count
useCounterStore.setState({ count: 0 })
TypeScript와 함께 쓰기
Zustand는 TypeScript와 잘 어울립니다. 상태와 액션의 타입을 분리하여 정의하면 코드의 의도가 명확해집니다.
import { create } from 'zustand'
// 상태와 액션을 분리하여 타입 정의
type State = {
user: { id: string; name: string; email: string } | null
theme: 'light' | 'dark'
notifications: number
}
type Actions = {
setUser: (user: State['user']) => void
toggleTheme: () => void
addNotification: () => void
clearNotifications: () => void
}
const useAppStore = create<State & Actions>((set) => ({
user: null,
theme: 'light',
notifications: 0,
setUser: (user) => set({ user }),
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
addNotification: () =>
set((state) => ({ notifications: state.notifications + 1 })),
clearNotifications: () => set({ notifications: 0 }),
}))
create<State & Actions>()처럼 제네릭으로 타입을 전달하면, set 함수와 셀렉터에서 자동 완성과 타입 검사가 동작합니다. 잘못된 상태 업데이트를 컴파일 시점에 잡아낼 수 있습니다.
미들웨어 활용
Zustand의 미들웨어 시스템은 스토어 생성 함수를 감싸는 방식으로 동작합니다. 가장 자주 쓰이는 세 가지를 살펴보겠습니다.
persist — 상태를 localStorage에 저장
새로고침해도 상태가 유지되어야 할 때 사용합니다.
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
type SettingsStore = {
theme: 'light' | 'dark'
language: string
setTheme: (theme: 'light' | 'dark') => void
setLanguage: (lang: string) => void
}
const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'light',
language: 'ko',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'settings-storage', // localStorage 키 이름
storage: createJSONStorage(() => localStorage), // 기본값
},
),
)
partialize 옵션으로 특정 상태만 저장할 수도 있습니다. 예를 들어 partialize: (state) => ({ theme: state.theme })처럼 설정하면 theme만 영속화됩니다.
devtools — Redux DevTools 연동
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const useStore = create<StoreState>()(
devtools(
(set) => ({
count: 0,
increment: () =>
set(
(state) => ({ count: state.count + 1 }),
undefined,
'counter/increment' // DevTools에 표시되는 액션 이름
),
}),
{ name: 'CounterStore' },
),
)
set의 세 번째 인자로 액션 이름을 전달하면 Redux DevTools에서 어떤 액션이 실행되었는지 추적할 수 있습니다.
immer — 뮤터블 구문으로 불변 업데이트
중첩된 상태를 업데이트할 때 스프레드 연산자가 중첩되면서 코드가 복잡해질 수 있습니다. immer 미들웨어를 사용하면 직접 값을 변경하는 것처럼 작성하되, 내부적으로는 불변 업데이트가 이루어집니다.
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
type TodoStore = {
todos: { id: string; text: string; done: boolean }[]
addTodo: (text: string) => void
toggleTodo: (id: string) => void
}
const useTodoStore = create<TodoStore>()(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({ id: crypto.randomUUID(), text, done: false })
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id)
if (todo) todo.done = !todo.done
}),
})),
)
미들웨어 조합
여러 미들웨어를 함께 사용할 수 있습니다. 순서가 중요한데, 바깥쪽에서 안쪽 순서로 devtools, persist, immer를 배치하는 것이 권장됩니다.
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
const useStore = create<StoreState>()(
devtools(
persist(
immer((set) => ({
// 스토어 정의
})),
{ name: 'store-key' },
),
{ name: 'StoreName' },
),
)
실전 패턴: 인증 상태 관리
실무에서 가장 흔하게 만나는 인증 상태 관리를 Zustand로 구현해 보겠습니다. persist 미들웨어로 토큰을 영속화하고, partialize로 민감하지 않은 데이터만 저장합니다.
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
type User = {
id: string
name: string
email: string
}
type AuthStore = {
user: User | null
token: string | null
isAuthenticated: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
}
const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (email, password) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
throw new Error('로그인에 실패했습니다')
}
const { user, token } = await response.json()
set({ user, token, isAuthenticated: true })
},
logout: () => {
set({ user: null, token: null, isAuthenticated: false })
},
}),
{
name: 'auth-storage',
partialize: (state) => ({ token: state.token }), // 토큰만 영속화
},
),
)
// 컴포넌트에서 사용
function LoginButton() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const login = useAuthStore((s) => s.login)
const logout = useAuthStore((s) => s.logout)
if (isAuthenticated) {
return <button onClick={logout}>로그아웃</button>
}
return <button onClick={() => login('user@example.com', 'pw')}>로그인</button>
}
// API 요청 시 토큰 활용 (컴포넌트 외부)
async function fetchWithAuth(url: string) {
const token = useAuthStore.getState().token
return fetch(url, {
headers: { Authorization: `Bearer ${token}` },
})
}
getState()를 활용하면 React 컴포넌트 밖에서도 인증 토큰에 접근할 수 있어, API 유틸리티 함수를 깔끔하게 구성할 수 있습니다.
고급 패턴
슬라이스 패턴 — 대규모 스토어 분할
앱이 커지면 하나의 스토어에 모든 상태를 넣기 어려워집니다. 슬라이스 패턴을 사용하면 도메인별로 상태와 액션을 분리한 뒤 하나의 스토어로 합칠 수 있습니다.
import { create, StateCreator } from 'zustand'
// 각 슬라이스 타입 정의
type AuthSlice = {
isLoggedIn: boolean
login: () => void
logout: () => void
}
type UISlice = {
sidebarOpen: boolean
toggleSidebar: () => void
}
type AppStore = AuthSlice & UISlice
// 슬라이스 구현
const createAuthSlice: StateCreator<AppStore, [], [], AuthSlice> = (set) => ({
isLoggedIn: false,
login: () => set({ isLoggedIn: true }),
logout: () => set({ isLoggedIn: false }),
})
const createUISlice: StateCreator<AppStore, [], [], UISlice> = (set) => ({
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
})
// 스토어 합성
const useAppStore = create<AppStore>()((...args) => ({
...createAuthSlice(...args),
...createUISlice(...args),
}))
슬라이스를 정의할 때 StateCreator의 첫 번째 제네릭에 전체 스토어 타입(AppStore)을 전달해야 합니다. 그래야 한 슬라이스에서 get()으로 다른 슬라이스의 상태에 접근할 수 있습니다.
비동기 액션 처리
Zustand는 별도의 미들웨어 없이 async/await를 직접 사용할 수 있습니다. Redux에서 thunk나 saga가 필요했던 것과 비교하면 훨씬 단순합니다.
import { create } from 'zustand'
type Product = { id: string; name: string; price: number }
type ProductStore = {
products: Product[]
isLoading: boolean
error: string | null
fetchProducts: () => Promise<void>
}
const useProductStore = create<ProductStore>((set) => ({
products: [],
isLoading: false,
error: null,
fetchProducts: async () => {
set({ isLoading: true, error: null })
try {
const res = await fetch('/api/products')
if (!res.ok) throw new Error('Failed to fetch products')
const products: Product[] = await res.json()
set({ products, isLoading: false })
} catch (err) {
set({ error: (err as Error).message, isLoading: false })
}
},
}))
TanStack Query와의 조합
현대 React 앱에서 가장 중요한 구분 중 하나는 서버 상태 와 클라이언트 상태 의 분리입니다. API에서 가져온 데이터의 캐싱, 동기화, 재검증은 TanStack Query가 담당하고, UI 상태나 사용자 설정 같은 클라이언트 상태는 Zustand가 담당하는 구조가 효과적입니다.
- •API 데이터 페치
- •캐싱 & 재검증
- •백그라운드 동기화
- •낙관적 업데이트
- •서버 데이터 표시
- •로딩/에러 처리
- •UI 상태 반영
- •사용자 인터랙션
- •UI 상태 (사이드바 등)
- •사용자 설정 / 테마
- •폼 데이터 / 필터
- •인증 토큰
서버 상태와 클라이언트 상태를 분리하면 각 도구의 강점을 최대로 활용할 수 있습니다
핵심 규칙은 간단합니다. 서버 데이터를 Zustand에 저장하지 마세요. TanStack Query가 캐싱과 재검증을 자동으로 처리하는데, 이를 Zustand에 복제하면 동기화 버그가 발생합니다.
// Zustand: 클라이언트 상태만 관리
const useUIStore = create<UIStore>((set) => ({
sidebarOpen: false,
theme: 'dark' as const,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setTheme: (theme: 'light' | 'dark') => set({ theme }),
}))
// TanStack Query: 서버 상태 관리
function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
})
}
// 컴포넌트에서 자연스럽게 조합
function TodoPage() {
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const { data: todos, isLoading } = useTodos()
return (
<Layout sidebar={sidebarOpen}>
{isLoading ? <Spinner /> : <TodoList todos={todos} />}
</Layout>
)
}
한 회사의 사례에 따르면, Apollo Client(50KB)에서 TanStack Query + Zustand(합산 18KB)로 마이그레이션하여 번들 크기 70% 감소와 초기 로드 속도 3배 향상을 달성했습니다.
다른 상태 관리 라이브러리와 비교
어떤 라이브러리가 "최고"라고 단정하기는 어렵습니다. 프로젝트 규모, 팀 구성, 기존 코드베이스에 따라 적합한 선택이 다릅니다. 각 라이브러리의 특성을 이해하고, 우리 상황에 맞는 도구를 고르는 것이 중요합니다.
| 라이브러리 | 번들 크기 | 보일러플레이트 | 학습 곡선 | TypeScript | 미들웨어 | 적합한 규모 |
|---|---|---|---|---|---|---|
🐻 Zustand | ~1.2KB | 최소 | 낮음 | ✅ | persist, devtools, immer | 소~중~대 |
| Redux Toolkit | ~15KB | 중간 | 높음 | ✅ | Redux 에코시스템 | 중~대규모 |
| Jotai | ~3KB | 최소 | 낮음 | ✅ | atom utilities | 소~중규모 |
| Valtio | ~3KB | 최소 | 낮음 | ⚠️ | 제한적 | 소~중규모 |
| React Context | 0KB (내장) | 중간 | 낮음 | ✅ | 없음 | 소규모 / 정적 값 |
* 번들 크기는 min+gzip 기준 / ⚠️ Proxy 기반 특성으로 타입 추론 일부 제한
Zustand vs Redux Toolkit
Redux Toolkit은 여전히 강력한 선택입니다. 특히 대규모 팀에서 엄격한 패턴과 규칙이 필요할 때, 이미 Redux 생태계에 투자한 프로젝트라면 굳이 바꿀 이유가 없습니다. 하지만 새 프로젝트를 시작하거나 Redux의 보일러플레이트에 피로를 느낀다면, Zustand가 훨씬 가벼운 대안이 됩니다. 번들 크기만 비교해도 약 1.2KB 대 약 15KB로 큰 차이가 납니다.
Zustand vs Jotai
Jotai는 같은 pmndrs 팀이 만들었지만 접근 방식이 다릅니다. Recoil과 유사하게 원자(atom) 단위로 상태를 관리합니다. 독립적인 상태 조각이 많고 복잡한 파생 상태가 필요한 앱에 적합합니다. Zustand는 중앙 집중형 스토어가 필요할 때, Jotai는 분산형 상태가 필요할 때 선택하면 됩니다.
Zustand vs Valtio
Valtio 역시 pmndrs 팀의 작품으로, Proxy 기반의 뮤터블 API를 제공합니다. MobX와 비슷한 사용감을 원하거나 빠른 프로토타이핑이 필요할 때 좋은 선택입니다. 다만 Proxy 기반이라 디버깅이 다소 어려울 수 있습니다.
React Context는 언제 쓸까
React Context는 상태 관리 "라이브러리"가 아닙니다. 의존성 주입 메커니즘에 가깝습니다. 테마, 로케일, 인증 여부 등 자주 변경되지 않는 값을 공유할 때 적합합니다. 자주 바뀌는 상태에 Context를 사용하면 리렌더링 문제를 겪게 됩니다.
주의사항과 안티패턴
Zustand를 효과적으로 사용하려면 몇 가지 함정을 알고 있어야 합니다.
셀렉터를 반드시 사용하세요
// BAD: 스토어 전체를 구독 — 어떤 상태가 바뀌어도 리렌더링
function Component() {
const store = useStore()
return <div>{store.count}</div>
}
// GOOD: 필요한 상태만 구독
function Component() {
const count = useStore((state) => state.count)
return <div>{count}</div>
}
셀렉터 없이 스토어 전체를 구독하면, 관계없는 상태가 바뀌어도 컴포넌트가 리렌더링됩니다. Zustand의 성능 장점을 살리려면 셀렉터 사용이 필수입니다.
useShallow를 적절히 사용하세요
여러 상태를 한 번에 가져올 때 셀렉터가 새 객체를 반환하면, 매 렌더링마다 "다른 값"으로 인식되어 불필요한 리렌더링이 발생합니다. 이때 useShallow를 사용합니다.
import { useShallow } from 'zustand/react/shallow'
// BAD: 매번 새 객체를 생성하여 항상 리렌더링
const { bears, fishes } = useStore((state) => ({
bears: state.bears,
fishes: state.fishes,
}))
// GOOD: useShallow로 얕은 비교 적용
const { bears, fishes } = useStore(
useShallow((state) => ({
bears: state.bears,
fishes: state.fishes,
})),
)
단, 원시값 하나만 선택하는 경우에는 useShallow가 필요 없습니다. Object.is 비교로 충분하기 때문입니다.
모든 상태를 글로벌로 올리지 마세요
Zustand가 편하다고 해서 모든 상태를 글로벌 스토어에 넣으면 안 됩니다. 모달의 열림 여부, 인풋 값, 드롭다운 상태 등 컴포넌트에 국한된 상태는 useState로 충분합니다. Zustand에 넣어야 할 상태는 다음과 같습니다.
- 여러 컴포넌트가 공유하는 상태
- 페이지 이동 후에도 유지되어야 하는 상태
- 컴포넌트 외부(유틸 함수, API 레이어)에서 접근해야 하는 상태
서버 상태를 Zustand에 저장하지 마세요
앞서 TanStack Query와의 조합에서 언급했듯, API에서 가져온 데이터를 Zustand에 복제하면 캐시 무효화, 재검증, 낙관적 업데이트 등을 직접 구현해야 합니다. TanStack Query나 SWR 같은 서버 상태 관리 도구에 맡기는 것이 올바른 접근입니다.
마무리
Zustand는 React 상태 관리의 복잡성을 크게 줄여주는 라이브러리입니다. 1.2KB의 번들 크기, Provider 없는 깔끔한 구조, 셀렉터 기반의 효율적인 리렌더링 제어, 그리고 persist, devtools, immer 같은 실용적인 미들웨어까지 갖추고 있습니다.
다만 Zustand가 만능은 아닙니다. 간단한 로컬 상태는 useState가 여전히 최선이고, 테마나 로케일 같은 정적 값 공유에는 Context API가 적합합니다. 서버 데이터 관리에는 TanStack Query 같은 전용 도구가 필요합니다.
Zustand를 추천하는 상황은 다음과 같습니다.
- Redux의 보일러플레이트에 지쳐 더 가벼운 대안을 찾을 때
- Context API의 리렌더링 문제로 성능 이슈를 겪을 때
- 새 프로젝트에서 상태 관리 라이브러리를 선택해야 할 때
- TypeScript 환경에서 타입 안전한 상태 관리가 필요할 때
2026년 현재, Zustand는 대부분의 React 앱에서 실용적인 기본 선택이 되었습니다. "작고 빠르고, 방해하지 않는" 상태 관리 도구를 찾고 있다면 한번 사용해 보시기 바랍니다.