요즘 zustand가 전역 상태 관리 라이브러리로 많이 채택되고 있습니다.
저도 최근 프로젝트에서 zustand를 사용해봤는데, 전역 상태 관리임에도 사용법이 매우 간단하다는 점이 큰 매력이었습니다.
그러다 문득, ‘내부적으로 어떻게 동작하길래 이렇게 간단하고 쉽게 작동하는 걸까?’
라는 궁금증이 생겨 직접 들여다보게 되었습니다.
우선 상태 관리가 무엇인지 가볍게 짚고 넘어갑시다.
상태관리란 말 그대로 데이터(state)를 관리하는 것을 의미한다.
프로젝트를 진행하다 보면 다양한 데이터를 다루게 되는데, 이때 상태를 어떻게 잘 관리하느냐가 중요하다. 특히 프로젝트의 규모가 커질수록 그 중요성은 더 커진다.
상태관리는 크게 두가지로 나뉜다.
클라이언트 사이드 상태 관리에는 컴포넌트 내부에서 관리하는 지역 상태와 여러 컴포넌트에서 공유하는 전역 상태가 있다. Zustand는 이러한 전역 상태를 관리하기 위한 라이브러리 중 하나다.
최근 5년간 전역 상태 관리 라이브러리의 추세를 보면,
여전히 Redux가 가장 많이 사용되지만, 가장 가파르게 성장한 라이브러리는 Zustand인 것을 확인할 수 있다.
이런 성장세 자체가 Zustand가 얼마나 매력적인지를 보여주는 지표가 아닐까?
💡 Context API는요?
Context API는 일단 라이브러리가 아니라, React에 내장된 기능이다. 그리고 정확히는 전역 상태 관리보다는 props drilling을 방지하기 위한 도구에 가깝다.하지만 우리가 그것을 전역 상태 관리처럼 활용하는 경우가 많은 것이다.
재미있게도, Zustand는 독일어로 ‘상태’를 의미한다.
(Jotai는 일본어로 ‘상태’를 의미하며, 두 라이브러리 모두 같은 개발자가 만들었다.)
Zustand는 리액트를 위한 작고 빠른 상태 관리 라이브러리로, 다른 라이브러리보다 훨씬 간단하고 직관적인 API를 제공한다.
NPM의 리드미에서는 Redux나 Context API와 비교하며 Zustand의 장점을 다음과 같이 소개하고 있다.
Why zustand over redux?
- Simple and un-opinionated
- Makes hooks the primary means of consuming state
- Doesn't wrap your app in context providers
- Can inform components transiently (without causing render)
Why zustand over context?
- Less boilerplate
- Renders components only on changes
- Centralized, action-based state management
Flux 패턴을 가진 Zustand
Zustand에서 store를 생성할 때는, React 외부 환경에서도 사용 가능한 createStore
와 React 전용인 create
두 가지 방식이 있습니다. 상태를 변경하거나 조회하는 set
, get
은 공통적으로 사용 가능하지만, useStore
는 React 환경에서만 사용할 수 있습니다.
createStore
: create과 유사하지만, 리액트와 무관한 일반적인 상태 저장소를 만듭니다. 훅이 아닌 일반 객체를 리턴합니다.import { createStore } from 'zustand/vanilla'
const store = createStore((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
}))
create
: store를 생성하는 함수 (모든 zustand 사용의 시작점!). 리액트 환경에서 사용할 수 있는 커스텀 훅 형태의 store를 만든다.import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
}))
set
, get
: 상태를 변경하거나 조회할 때 store 내부에서 사용하는 함수이다. 클로저 형태로 create
함수 안에서 접근할 수 있다.const useStore = create((set, get) => ({
count: 0,
increase: () => set({ count: get().count + 1 }), // get()으로 현재 상태 조회
}))
useStore(selector)
: 특정 상태만 선택해서 구독하는 방식. 불필요한 리렌더링을 줄일 때 핵심!const count = useStore((state) => state.count)
const increase = useStore((state) => state.increase)
상태 구독 방식
import { create } from 'zustand'
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
text: 'hello',
increase: () => set((state) => ({ count: state.count + 1 })),
changeText: (text) => set(() => ({ text })),
}))
// Counter.tsx
import { useCounterStore } from './useCounterStore'
export default function Counter() {
const { count, increase } = useCounterStore() // 전체 상태 구독!
console.log('🔁 Counter render')
return (
<div>
<p>Count: {count}</p>
<button onClick={increase}>+1</button>
</div>
)
}
useCounterStore() → {
count: 0,
text: "hello",
increase: fn,
changeText: fn,
} → 이 전체 객체가 바뀌었는지 비교함
text
만 바뀌어도 count
를 사용하는 이 컴포넌트가 다시 렌더링됨// Counter.tsx
import { useCounterStore } from './useCounterStore'
export default function Counter() {
const count = useCounterStore((state) => state.count)
const increase = useCounterStore((state) => state.increase)
console.log('✅ Counter render')
return (
<div>
<p>Count: {count}</p>
<button onClick={increase}>+1</button>
</div>
)
}
text
가 바뀌어도 count
만 구독 중이므로 리렌더링되지 않음최근에 추가된 기능으로, Zustand에서 제공하는 미들웨어이다. 상태를 localStorage
또는 sessionStorage
같은 외부 저장소에 영구적으로 저장할 수 있게 해준다.
이 미들웨어를 사용하면, 페이지를 새로고침해도 기존 상태를 유지할 수 있다.
예를 들어, 로그인 여부를 확인하기 위해 userId
를 직접 localStorage
에 저장해 관리하고 있었다고 하자. 그런데 이후 Zustand를 도입하면서 userId
상태도 store에서 함께 관리하도록 바꿨다면, 다음과 같은 문제가 생긴다:
새로고침 시 로그인 상태가 초기화.
그 이유는, Zustand store의 상태는 메모리 상에서만 유지되기 때문에 페이지가 새로고침되면 JavaScript 환경이 초기화되고, store에 있던 userId
정보도 사라지기 때문이다.
이런 상황에서 persist
를 사용하면, store의 상태를 자동으로 localStorage
에 저장하고, 앱이 다시 실행될 때 해당 값을 불러와 상태를 복원해준다.
// useUserStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface UserState {
userId: string | null
login: (id: string) => void
logout: () => void
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
userId: null,
login: (id: string) => set({ userId: id }),
logout: () => set({ userId: null }),
}),
{
name: 'user-storage', // localStorage 키
}
)
)
Q. 기존 그대로 로컬스토리지로 관리하면 되지, 왜 zustand persist 통해서 관리하나요?
도대체 어떻게 이렇게 간편하게 동작하는 걸까?
내부 코드를 들여다보면, 생각보다 코드 양도 많지 않다.
구조를 정리하자면, 밑과 같다
zustand/src/vanilla.ts
→ 순수 상태 관리 엔진
...
const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>
type Listener = (state: TState, prevState: TState) => void
let state: TState
const listeners: Set<Listener> = new Set()
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
// TODO: Remove type assertion once https://github.com/microsoft/TypeScript/issues/37663 is resolved
// https://github.com/microsoft/TypeScript/issues/37663#issuecomment-759728342
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
if (!Object.is(nextState, state)) {
const previousState = state
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? (nextState as TState)
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState: StoreApi<TState>['getState'] = () => state
const getInitialState: StoreApi<TState>['getInitialState'] = () =>
initialState
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener)
// Unsubscribe
return () => listeners.delete(listener)
}
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api))
return api as any
}
export const createStore = ((createState) =>
createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore
createStoreImpl
은 Zustand의 핵심 로직으로, setState
, getState
, subscribe
, getInitialState
등을 통해 store의 기본 기능을 구현한다. 이렇게 구성된 기능들은 api
객체에 담겨 반환되며, 이것이 Zustand의 store가 된다.createStore
는 사용자 입장에서 직접 호출하는 함수로, 인자로 createState
를 넘기면 내부적으로 createStoreImpl(createState)
를 호출해 실제 store를 생성한다.zustand/src/react.ts
→ 리액트에서 쓰기 편하게 감싼 껍데기
...
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState)
const useBoundStore: any = (selector?: any) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
export const create = (<T>(createState: StateCreator<T, [], []> | undefined) =>
createState ? createImpl(createState) : createImpl) as Create
createImpl
은 실질적으로 store를 생성하는 함수로, 내부에서 createStore
를 호출해 상태 관리 로직을 만들고, 이를 React에서 사용할 수 있도록 useStore
훅으로 감싼다. 마지막에는 Object.assign
을 통해 setState
, getState
등의 메서드도 함께 바인딩하여 반환한다.create
는 사용자가 직접 호출하는 React 전용 상태 훅 생성 함수로, 내부적으로 createImpl
을 호출해 동작한다.zustand/src/middleware/persist.ts
...
export function createJSONStorage<S>(
getStorage: () => StateStorage,
options?: JsonStorageOptions,
): PersistStorage<S> | undefined {
let storage: StateStorage | undefined
try {
storage = getStorage()
} catch {
// prevent error if the storage is not defined (e.g. when server side rendering a page)
return
}
const persistStorage: PersistStorage<S> = {
getItem: (name) => {
const parse = (str: string | null) => {
if (str === null) {
return null
}
return JSON.parse(str, options?.reviver) as StorageValue<S>
}
const str = (storage as StateStorage).getItem(name) ?? null
if (str instanceof Promise) {
return str.then(parse)
}
return parse(str)
},
setItem: (name, newValue) =>
(storage as StateStorage).setItem(
name,
JSON.stringify(newValue, options?.replacer),
),
removeItem: (name) => (storage as StateStorage).removeItem(name),
}
return persistStorage
}
createJSONStorage
는 localStorage
또는 sessionStorage
와 같은 브라우저 저장소를 감싸, Zustand가 사용할 수 있는 저장소 인터페이스 객체를 생성하는 함수이다. 내부적으로 getItem
, setItem
, removeItem
메서드를 제공하며, 상태를 JSON 형태로 직렬화하거나 파싱하여 저장소와 상태를 자동으로 동기화할 수 있도록 도와준다.persist
미들웨어에서 이 함수를 사용하면, Zustand의 상태를 브라우저 저장소에 저장하고, 앱이 다시 로드될 때 저장된 상태를 불러와 자동으로 복원할 수 있게 된다.persist 동작 흐름
persist(
(set, get) => ({ ... }),
{
name: 'user-storage',
storage: createJSONStorage(() => localStorage),
}
)
createJSONStorage(() => localStorage)
가 실행되어 PersistStorage
객체를 만든다.JSON.stringify
하여 localStorage.setItem()
으로 저장한다.localStorage.getItem()
으로 데이터를 읽어 상태를 복원한다.logout()
처럼 상태를 초기화할 경우 removeItem()
으로 데이터를 제거한다.zustand/src/react.ts
...
export function useStore<TState, StateSlice>(
api: ReadonlyStoreApi<TState>,
selector: (state: TState) => StateSlice = identity as any,
) {
const slice = React.useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState()),
)
React.useDebugValue(slice)
return slice
}
selector로 구독한 상태 조각이 실제로 변경됐을 때만 컴포넌트를 리렌더링합니다.
useSyncExternalStore는 React 18에서 도입된 외부 상태 저장소용 구독 훅.
Zustand는 이 훅을 통해 외부 store와 React의 리렌더링 흐름을 연결합니다.
처음에는 ‘내부적으로 얼마나 복잡하고 정교하게 설계됐길래, 이렇게 쓰기 편할까?’ 라는 생각을 했었다.
하지만 막상 내부 코드를 들여다보니, 오히려 그 반대였다.
단순한 내부 구조
+ React와의 자연스러운 통합
+ 불필요한 보일러플레이트 없음
이 세 가지가 바로 Zustand가 간단하고 편리한 이유였다.
예전에는 “왜 Zustand 쓰셨어요?”, “어떤 점이 좋아요?” 같은 질문에
그저 “다들 쓰길래…” 같은 아쉬운 최악의 답변을 했었다.
하지만, 이제는 밑과 같이 대답할 것이다.
💡 참고: 아래는 공통적인 답변이므로, 여기에 프로젝트의 특징을 엮어서 설명하자!
Q. 왜 일반적인 지역 상태관리를 안쓰고, 전역 상태관리 라이브러리를 사용하시나요?
-> A. useState, useReducer 같은 지역 상태 관리는 컴포넌트 내부에서만 사용할 수 있기 때문에, 여러 컴포넌트에서 상태를 공유하려면 props를 계속 전달해야 합니다. 이로 인해 props drilling이 발생하고, 상태 흐름을 추적하거나 유지보수하기가 어려워집니다. 전역 상태관리 라이브러리를 사용하면 공통 상태를 한 곳에서 관리할 수 있어 상태의 일관성을 유지하고, 코드 구조를 더 명확하게 만들 수 있습니다.
Q. 여러 전역 상태관리 도구 중 Zustand를 선택한 이유는 뭔가요?
-> A. Zustand를 선택한 가장 큰 이유는 간결함과 유연성 때문입니다. 다른 전역 상태관리 도구들에 비해 스토어 설정이 매우 직관적이고 보일러플레이트가 거의 없어 빠르게 적용할 수 있었고, Hook 기반 API 덕분에 리액트 함수형 컴포넌트와도 잘 어울렸습니다. 또한 Redux나 Context API처럼 Provider로 감싸지 않아도 되기 때문에 구조가 단순해지고, 상태 구조도 자유롭게 설계할 수 있어 유지보수나 확장에도 유리하다고 판단했습니다.
Redux와 비교해서 좀 더 자세히 설명해주세요.
-> A. Redux는 action, reducer, dispatch 등의 보일러플레이트 코드가 많고, 상태 업데이트도 특정한 패턴을 따르도록 강제합니다. 반면 Zustand는 이런 제약 없이, 단순한 함수 호출만으로 상태를 업데이트할 수 있어 개발 속도와 가독성이 뛰어납니다. 또한 Zustand는 선택적 구독이 가능해, 상태가 변경되더라도 필요한 컴포넌트만 리렌더링되기 때문에 성능 측면에서도 효율적입니다.
Q. Context API와 비교해서 좀 더 자세히 설명해주세요.
-> A. Context API는 가벼운 전역 상태 공유에는 적합하지만, 상태가 변경되면 해당 Context를 구독하고 있는 모든 컴포넌트가 리렌더링됩니다. 반면 Zustand는 부분 구독이 가능해서, 변경된 상태만 사용하는 컴포넌트만 리렌더링되어 성능이 더 우수합니다. 또한 실무에서는 Context를 역할별로 나눠 여러 개 생성하게 되는데, 이 경우 Provider 중첩 구조가 생기기 쉬워 트리 구조가 복잡해질 수 있습니다. Zustand는 Provider 없이도 전역 상태 접근이 가능해 구조적으로도 더 단순합니다.
Q. 왜 TanStack Query와 함께 사용했나요? 전역 상태관리로 모두 다루지 않고?
-> A. Zustand는 클라이언트 상태 관리에 적합하고, TanStack Query는 서버 상태 관리에 특화되어 있기 때문에 두 도구를 함께 사용했습니다. Zustand는 사용자 인터랙션이나 UI 상태처럼 서버와 무관한 상태를 간단하게 관리할 수 있어 사용했고, TanStack Query는 서버에서 데이터를 가져와야 할 때 데이터 요청, 캐싱, 로딩/에러 상태 관리, 자동 리패치 등을 편리하게 처리해주기 때문에 사용했습니다. 이처럼 역할을 명확히 분리함으로써, 각 도구의 장점을 살리고 코드의 구조와 유지보수성을 높일 수 있었습니다.
🌐 참고 링크
Next.js 와도 친했다면 계속 썼을텐데 오히려 단점만 늘어나는 아쉬움이 있더라고요