junyeokk
Blog
React·2025. 12. 01

Zustand와 외부 스토어 기반 상태 관리

React에서 상태 관리는 크게 두 갈래로 나뉜다. React 내부에 상태를 두는 방식(useState, useReducer, Context)과 React 바깥에 상태를 두는 방식(Zustand, Redux, Jotai 등). Zustand는 후자의 대표적인 라이브러리다.


React 내부 상태의 한계

useStateuseContext를 조합하면 전역 상태를 만들 수 있다. Provider로 감싸고 Context에서 꺼내 쓰는 방식이다.

tsx
const CountContext = createContext(null);

function App() {
  const [count, setCount] = useState(0);
  return (
    <CountContext.Provider value={{ count, setCount }}>
      <Child />
    </CountContext.Provider>
  );
}

문제는 Context의 값이 바뀌면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링된다는 점이다. count만 바뀌었는데 setCount만 쓰는 컴포넌트도 리렌더링된다. 상태가 많아질수록 Provider를 쪼개거나 메모이제이션을 추가해야 하는데, 이게 금방 복잡해진다.

Context는 원래 "자주 바뀌지 않는 값"(테마, 언어 설정 등)을 전달하기 위해 만들어졌다. 빈번하게 변경되는 상태를 관리하는 도구로는 적합하지 않다.


외부 스토어라는 발상

Zustand의 핵심 아이디어는 단순하다: 상태를 React 바깥의 평범한 JavaScript 객체로 관리하고, React 컴포넌트는 그 객체를 구독하기만 한다.

text
[외부 스토어 (JS 객체)]  ←→  [React 컴포넌트]
     상태 보관                    구독 & 렌더링

스토어는 React와 무관한 순수 JavaScript다. set 함수로 상태를 변경하고, 변경이 발생하면 구독 중인 리스너에게 알린다. 이 구조의 장점:

  1. Provider 불필요 — React 트리 바깥에 존재하므로 Provider로 감쌀 필요가 없다
  2. 선택적 구독 — 컴포넌트가 스토어의 특정 부분만 선택(select)해서 구독할 수 있다
  3. React 외부에서도 접근 가능 — 유틸 함수, 미들웨어 등에서 직접 상태를 읽고 쓸 수 있다

동작 원리: 구독-알림 패턴

Zustand 스토어의 내부 구조를 단순화하면 이렇다:

javascript
function createStore(initializer) {
  let state;
  const listeners = new Set();

  const getState = () => state;

  const setState = (partial) => {
    const nextState = typeof partial === "function" ? partial(state) : partial;
    state = Object.assign({}, state, nextState);
    listeners.forEach((listener) => listener(state));
  };

  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  state = initializer(setState, getState);
  return { getState, setState, subscribe };
}

핵심은 publish-subscribe 패턴이다. setState가 호출되면 상태를 갱신하고, 등록된 모든 리스너를 호출한다. React 컴포넌트는 이 subscribe에 리렌더링 트리거를 등록해 두면 된다.


useSyncExternalStore와의 연결

React 18에서 추가된 useSyncExternalStore는 외부 스토어를 React와 안전하게 연결하는 공식 API다. Zustand v4+는 이 훅을 내부적으로 사용한다.

javascript
import { useSyncExternalStore } from "react";

function useStore(store, selector) {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState())
  );
}

이 훅이 해결하는 문제는 tearing이다. React 18의 Concurrent Mode에서는 렌더링이 중간에 중단될 수 있다. 외부 스토어의 상태가 렌더 도중에 바뀌면 같은 렌더 사이클 내에서 어떤 컴포넌트는 이전 상태를, 어떤 컴포넌트는 새 상태를 보게 된다. useSyncExternalStore는 렌더 중에 상태가 변경되면 동기적으로 다시 렌더링해서 이런 불일치를 방지한다.


선택적 리렌더링

Zustand가 Context 방식보다 효율적인 핵심 이유는 selector 기반 구독이다.

tsx
// 스토어 전체에서 bears만 선택
const bears = useStore((state) => state.bears);

selector가 반환하는 값이 이전과 같으면(Object.is 비교) 리렌더링이 발생하지 않는다. 스토어에 bears, fish, trees 세 가지 상태가 있어도, bears만 구독하는 컴포넌트는 fish가 변해도 리렌더링되지 않는다.

주의할 점은 selector가 매번 새 객체를 반환하면 안 된다는 것이다:

tsx
// ❌ 매번 새 객체를 만들어서 항상 리렌더링됨
const { bears, fish } = useStore((state) => ({
  bears: state.bears,
  fish: state.fish,
}));

// ✅ shallow 비교를 사용하면 해결
import { useShallow } from "zustand/react/shallow";

const { bears, fish } = useStore(
  useShallow((state) => ({ bears: state.bears, fish: state.fish }))
);

Object.is는 원시값에는 잘 작동하지만, 객체는 참조가 다르면 무조건 다른 값으로 판단한다. useShallow는 객체의 각 프로퍼티를 얕은 비교해서 실제로 값이 바뀌었을 때만 리렌더링을 트리거한다.


불변성과 상태 갱신

set 함수는 기존 상태에 새 값을 머지한다. 전체를 교체하는 게 아니라 변경된 부분만 덮어쓴다.

javascript
const useStore = create((set) => ({
  bears: 0,
  fish: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
}));

set({ bears: 1 })을 호출하면 { bears: 0, fish: 0 }{ bears: 1, fish: 0 }이 된다. fish는 건드리지 않는다. 이건 Object.assign과 같은 동작이다.

중첩 객체를 업데이트할 때는 직접 불변성을 지켜야 한다:

javascript
set((state) => ({
  nested: { ...state.nested, deep: { ...state.nested.deep, value: 42 } },
}));

이게 귀찮으면 immer 미들웨어를 사용할 수 있다.


미들웨어 구조

Zustand의 미들웨어는 스토어 생성 함수를 감싸는 고차 함수다.

javascript
const log = (config) => (set, get, api) =>
  config(
    (...args) => {
      console.log("변경 전:", get());
      set(...args);
      console.log("변경 후:", get());
    },
    get,
    api
  );

const useStore = create(log((set) => ({ bears: 0 })));

set을 가로채서 로깅을 추가했다. 같은 방식으로 persist(localStorage 저장), devtools(Redux DevTools 연동), immer(불변 업데이트 간소화) 등이 구현되어 있다. 미들웨어는 체이닝이 가능해서 여러 개를 겹쳐 쓸 수 있다.


정리

특성Context + useStateZustand
상태 위치React 트리 내부React 외부 (JS 객체)
Provider필수불필요
리렌더링 범위Context 구독자 전체selector로 선택한 값이 변한 컴포넌트만
Concurrent Mode자동 지원useSyncExternalStore로 지원
React 외부 접근불가getState/setState로 가능

Zustand가 인기를 얻은 건 복잡한 상태 관리 문제를 해결하면서도 API가 극도로 단순하기 때문이다. 스토어 하나 만들고, 컴포넌트에서 훅처럼 꺼내 쓰면 끝이다. 보일러플레이트도 거의 없다. "상태 관리가 이렇게 간단해도 되나?" 싶을 정도인데, 그게 가능한 건 내부적으로 useSyncExternalStore와 구독 패턴이 무거운 짐을 지고 있기 때문이다.


관련 문서