Zustand는 왜 쉽고 편할까?

최씨·2025년 3월 24일
19

Frontend

목록 보기
8/12
post-thumbnail

🍀 이 글을 작성하며….

요즘 zustand가 전역 상태 관리 라이브러리로 많이 채택되고 있습니다.
저도 최근 프로젝트에서 zustand를 사용해봤는데, 전역 상태 관리임에도 사용법이 매우 간단하다는 점이 큰 매력이었습니다.

그러다 문득, ‘내부적으로 어떻게 동작하길래 이렇게 간단하고 쉽게 작동하는 걸까?’
라는 궁금증이 생겨 직접 들여다보게 되었습니다.


🍀 상태 관리

✏️ 다양한 상태 관리들

우선 상태 관리가 무엇인지 가볍게 짚고 넘어갑시다.

상태관리란 말 그대로 데이터(state)를 관리하는 것을 의미한다.

프로젝트를 진행하다 보면 다양한 데이터를 다루게 되는데, 이때 상태를 어떻게 잘 관리하느냐가 중요하다. 특히 프로젝트의 규모가 커질수록 그 중요성은 더 커진다.


상태관리는 크게 두가지로 나뉜다.

  1. 클라이언트 사이드 상태 관리
    • 브라우저 안에서 UI 상태를 관리
    • ex) useState, Context API, Redux, Zustand, Recoil, Jotai 등
  2. 서버 사이드 상태 관리
    • 서버에서 데이터를 가져오고 상태를 동기화
    • ex) React Query, SWR, Apollo Client 등

✏️ 전역 상태 관리

클라이언트 사이드 상태 관리에는 컴포넌트 내부에서 관리하는 지역 상태와 여러 컴포넌트에서 공유하는 전역 상태가 있다. Zustand는 이러한 전역 상태를 관리하기 위한 라이브러리 중 하나다.

최근 5년간 전역 상태 관리 라이브러리의 추세를 보면,

여전히 Redux가 가장 많이 사용되지만, 가장 가파르게 성장한 라이브러리는 Zustand인 것을 확인할 수 있다.

이런 성장세 자체가 Zustand가 얼마나 매력적인지를 보여주는 지표가 아닐까?

💡 Context API는요?
Context API는 일단 라이브러리가 아니라, React에 내장된 기능이다. 그리고 정확히는 전역 상태 관리보다는 props drilling을 방지하기 위한 도구에 가깝다.하지만 우리가 그것을 전역 상태 관리처럼 활용하는 경우가 많은 것이다.


🍀 Zustand

✏️ Zustand란?

재미있게도, 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

  • Flux는 상태 관리를 위한 아키텍처로, 단방향 데이터 흐름을 기반으로 한다.
  • Redux도 Flux 패턴 기반이다.

  1. View에서 사용자가 클릭 등 인터렉션을 함
  2. 그에 따라 Action(객체)을 생성함
  3. Dispatcher에 Action을 전달함
  4. Dispatcher가 해당 Action을 Store로 전달
  5. Store에서 상태가 변경됨
  6. 변경된 상태를 View가 구독하고 있으므로, View가 다시 렌더링 됨.
  • zustand 예시 → 클릭시 숫자가 올라가는 걸 들자. 사용자가 View에서 버튼을 클릭하는 인터렉션을 하면 이에 대응하는 Action 함수가 호출되어 내부적으로 상태 저장소 역할을 하는 Store에 set함수를 통해 상태를 변경한다.

✏️ 주요 함수 및 활용

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)

상태 구독 방식

  1. 구조 분해 할당
  • 전체 상태를 구독. 전체 중 하나라도 변경되면 새로운 객체를 생성해서 리렌더링.
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를 사용하는 이 컴포넌트가 다시 렌더링됨

  1. selector
  • 원하는 상태만 구독. 구독한 상태가 변경될 때만 리렌더링 발생한다.
// 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만 구독 중이므로 리렌더링되지 않음

✏️ persist

최근에 추가된 기능으로, 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 통해서 관리하나요?

  • 둘 다 로컬스토리지를 쓰기 때문에 충분히 들 수 있는 의문이다. 하지만, 직접 관리할 경우, store 외부에서 상태 동기화 로직을 따로 구현해야 하며, 코드가 분산되고 복잡해질 수 있다. 반면 persist를 사용하면, store 내부에서 상태와 저장소가 자동으로 동기화되기 때문에 하나의 흐름 안에서 일관성 있게 상태를 관리할 수 있고, 유지보수 측면에서도 유리하다.

✏️ 내부 로직

도대체 어떻게 이렇게 간편하게 동작하는 걸까?

내부 코드를 들여다보면, 생각보다 코드 양도 많지 않다.

구조를 정리하자면, 밑과 같다

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
}
  • createJSONStoragelocalStorage 또는 sessionStorage와 같은 브라우저 저장소를 감싸, Zustand가 사용할 수 있는 저장소 인터페이스 객체를 생성하는 함수이다. 내부적으로 getItem, setItem, removeItem 메서드를 제공하며, 상태를 JSON 형태로 직렬화하거나 파싱하여 저장소와 상태를 자동으로 동기화할 수 있도록 도와준다.
  • persist 미들웨어에서 이 함수를 사용하면, Zustand의 상태를 브라우저 저장소에 저장하고, 앱이 다시 로드될 때 저장된 상태를 불러와 자동으로 복원할 수 있게 된다.

persist 동작 흐름

persist(
  (set, get) => ({ ... }),
  {
    name: 'user-storage',
    storage: createJSONStorage(() => localStorage),
  }
)
  1. createJSONStorage(() => localStorage)가 실행되어 PersistStorage 객체를 만든다.
  2. Zustand는 상태 변경이 일어날 때마다 해당 상태를 JSON.stringify하여 localStorage.setItem()으로 저장한다.
  3. 앱이 새로 로드되면 localStorage.getItem()으로 데이터를 읽어 상태를 복원한다.
  4. 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는 서버에서 데이터를 가져와야 할 때 데이터 요청, 캐싱, 로딩/에러 상태 관리, 자동 리패치 등을 편리하게 처리해주기 때문에 사용했습니다. 이처럼 역할을 명확히 분리함으로써, 각 도구의 장점을 살리고 코드의 구조와 유지보수성을 높일 수 있었습니다.


🌐 참고 링크

profile
정답은 없지만, 가까워지려고 노력하고 있습니다 :)

4개의 댓글

comment-user-thumbnail
2025년 3월 25일

Next.js 와도 친했다면 계속 썼을텐데 오히려 단점만 늘어나는 아쉬움이 있더라고요

1개의 답글
comment-user-thumbnail
2025년 4월 14일

진짜 많이 배워갑니당~

1개의 답글