Zustand scoped store #1. 글로벌 store로는 안 되는 것들

Zustand scoped store #1. 글로벌 store로는 안 되는 것들

March 13, 2026

“전역상태를 남용하지 마라.”

Zustand, Redux, MobX 같은 상태관리 라이브러리를 쓰다 보면 한 번쯤 듣게 되는 이야기입니다. 그런데 정작 남용하면 안 되는지를 구체적으로 설명하는 글은 많지 않았습니다.

“Context를 쓰세요”, “서버 상태는 React Query로 분리하세요” 같은 조언은 넘쳐나지만, Zustand의 create()로 만든 글로벌 store가 정확히 어떤 상황에서 문제를 일으키는지를 코드로 보여주는 글은 드물었습니다.

이 시리즈는 그 질문에서 출발합니다.

1편에서는 먼저 전역상태와 지역상태의 경계를 짚고,
Zustand의 create()가 구체적으로 어떤 상황에서 한계를 드러내는지 세 가지 시나리오를 살펴보겠습니다.

전역상태는 언제 써야 할까

본격적인 한계를 이야기하기 전에, 먼저 기준을 세워보겠습니다.

모든 상태를 글로벌로 올리는 것이 나쁜 건 아닙니다.

핵심은 상태의 생명주기입니다.
이 상태가 앱 전체와 같은 생명주기를 가져야 하는가, 아니면 특정 페이지나 컴포넌트와 함께 생겨나고 사라져야 하는가. 글로벌 store가 적합한 경우는 전자에 해당합니다.

  • 앱 전체에서 하나의 인스턴스만 필요한 상태.
    인증 정보, 테마 설정, 로케일 같은 것들은 앱에 하나만 있으면 됩니다. 여러 인스턴스가 필요할 이유가 없습니다.
  • 컴포넌트 트리 외부에서 접근해야 하는 상태.
    Axios interceptor에서 토큰을 읽거나, 라우터 가드에서 인증 상태를 확인하는 경우입니다. React Context 안에 있으면 접근할 수 없지만, 글로벌 store는 어디서든 getState()로 접근할 수 있습니다.
  • React 생명주기와 무관한 상태.
    WebSocket 연결 상태나 백그라운드 동기화 상태처럼, 컴포넌트의 마운트/언마운트와 무관하게 유지되어야 하는 상태도 있습니다.
import { create } from 'zustand';

const useCounterStore = create<CounterState>(set => ({
  count: 0,
  inc: () => set(s => ({ count: s.count + 1 })),
}));

이 패턴은 모듈 스코프에 싱글턴 store를 만듭니다.
앱 전체에서 하나의 store 인스턴스를 공유하고, 어떤 컴포넌트에서든 useCounterStore를 호출하면 같은 상태에 접근합니다.

위에서 나열한 경우에는 이 방식이 딱 맞습니다.

반대로, 특정 페이지에서만 쓰이고 다른 페이지와 공유할 필요가 없는 상태는 글로벌에 올릴 이유가 없다고 생각합니다.

  • 주문서 작성 페이지의 form 상태
  • 에디터 페이지의 편집 상태
  • 대시보드 패널의 필터 상태

이런 상태를 글로벌 store에 넣으면, 사용자가 해당 페이지를 떠나도 상태가 메모리에 남아 있습니다.
다시 돌아왔을 때 이전 데이터가 그대로 남아 있거나, 다른 페이지의 로직이 의도치 않게 이 상태에 접근하는 상황이 생길 수 있습니다.

TkDodo도 같은 결론에 도달했습니다.

“I’ve realized that more often than not, I’ve needed some state to be available globally to one component subtree rather than the whole application.”

참고: TkDodo - Zustand and React Context

앱 전체가 아니라 특정 컴포넌트 서브트리에만 상태가 필요한 경우가 실제로는 더 많다는 이야기입니다.

Kent C. Dodds도 비슷한 맥락에서 “상태는 필요한 곳에 최대한 가까이 두라(Keep state as close to where it’s needed as possible)“고 말합니다.
각 페이지가 자신에게 필요한 상태만 가지고, 그 페이지를 벗어나면 상태도 함께 정리되는 것이 자연스러운 구조입니다.

참고: Kent C. Dodds - Application State Management with React

이 기준으로 생각해 보면, 문제가 보이기 시작합니다.

이 근본적인 불일치가 구체적으로 세 가지 한계로 드러납니다.

한계 1. 같은 컴포넌트를 여러 개 렌더링하면 상태가 공유된다

가장 먼저 부딪히는 문제는 다중 인스턴스입니다.
글로벌 store를 사용하는 컴포넌트를 화면에 여러 개 렌더링하면, 모든 인스턴스가 같은 상태를 바라봅니다.

function App() {
  return (
    <>
      <Counter /> {/* count: 3 */}
      <Counter /> {/* count: 3, 같은 값 */}
      <Counter /> {/* count: 3, 같은 값 */}
    </>
  );
}

Counter 하나를 클릭하면 세 개가 동시에 바뀝니다.
당연한 결과입니다. useCounterStore모듈 스코프에 단 하나만 존재하니까요.

컴포넌트를 아무리 여러 번 마운트해도, 그 컴포넌트들이 바라보는 store는 하나뿐입니다.

Counter 정도면 “그냥 로컬 state 쓰면 되지”라고 넘어갈 수 있습니다.
하지만 실제 프로젝트에서는 상황이 다릅니다.

  • 워크플로우 빌더에서 각 노드마다 독립적인 편집 상태가 필요할 때
  • 대시보드에서 같은 차트 위젯을 여러 개 렌더링하되, 각각 다른 필터를 적용해야 할 때
  • 멀티탭 에디터에서 탭마다 독립적인 편집/저장 상태를 유지해야 할 때

이런 경우에는 상태 로직 자체가 충분히 복잡해서 단순한 useState로는 관리하기 어렵습니다.
Zustand의 미들웨어(devtools, persist 등)도 활용하고 싶고, selector 기반 구독으로 리렌더를 최적화하고 싶습니다.

그런데 글로벌 store로는 인스턴스별 독립 상태를 만들 수 없습니다.

“그러면 store를 여러 개 만들면 되지 않을까?” 싶을 수도 있습니다. createStore1, createStore2를 따로 만드는 식으로요. 하지만 인스턴스 수가 동적으로 결정되는 상황(노드 개수, 탭 개수)에서는 이 접근도 통하지 않습니다.

이 문제를 고민하다 보면 자연스럽게 두 번째 한계에 도달합니다.

한계 2. 부모에서 받은 props로 store를 초기화할 수 없다

TkDodo가 이 문제를 정확히 짚었습니다.

“Global stores are created outside of the React Component lifecycle, so we can’t initialize the store with a value we get as a prop.”

참고: TkDodo - Zustand and React Context

create()모듈이 평가되는 시점에 호출됩니다.
이 시점에는 React 컴포넌트가 아직 렌더링되지 않았으므로, props에 접근할 방법이 없습니다.

서버에서 받아온 데이터로 에디터의 초기 상태를 세팅하거나, userId에 따라 대시보드를 초기화해야 하는 상황에서 난감해집니다.

이를 우회하는 가장 흔한 패턴은 useEffect로 props를 store에 밀어넣는 것입니다.

function Editor({ initialContent }: { initialContent: string }) {
  const setContent = useEditorStore(s => s.setContent);

  useEffect(() => {
    setContent(initialContent);
  }, [initialContent, setContent]);

  return <ContentArea />;
}

동작은 합니다. 하지만 두 가지 문제가 따라옵니다.

  • 첫 렌더에서 깜빡임이 발생합니다.
    컴포넌트가 처음 마운트될 때 store에는 기본값(빈 문자열)이 들어 있고, useEffect가 실행된 뒤에야 initialContent로 바뀝니다.
    사용자 눈에는 빈 화면이 한 프레임 보였다가 내용이 채워지는 것으로 보입니다.

  • “초기화”가 아니라 “지속적 동기화”가 되어버립니다.
    useEffect의 의존성 배열에 initialContent가 들어 있기 때문에, 이 prop이 바뀔 때마다 store가 덮어씌워집니다.
    사용자가 에디터에서 내용을 수정하는 도중에 부모 컴포넌트가 리렌더되면서 initialContent가 다시 전달되면, 수정 중이던 내용이 날아갈 수 있습니다.

Warning

초기화와 동기화는 다릅니다. 초기화는 “store가 생성될 때 한 번만 값을 넣는
것”이고, 동기화는 “외부 값이 바뀔 때마다 store를 갱신하는 것”입니다.
useEffect는 후자를 강제합니다.

의존성 배열에서 initialContent를 빼면 동기화 문제는 해결되지만, ESLint의 exhaustive-deps 규칙이 경고를 내고, 의도가 코드에서 드러나지 않습니다.

근본적으로 store 생성 시점에 초기값을 주입하는 방법이 필요하다는 생각이 들었습니다.

그리고 이 두 가지 한계가 세 번째 문제와 만나면, 글로벌 store의 구조적 한계가 더욱 분명해집니다.

한계 3. 테스트 간 store 상태가 누수된다

글로벌 store는 모듈 스코프에 존재하므로, 테스트 간에도 상태가 공유됩니다.

// 테스트 A
it('adds item to cart', () => {
  useCartStore.getState().addItem({ id: 1, name: 'Widget' });
  expect(useCartStore.getState().items).toHaveLength(1);
});

// 테스트 B: 이전 테스트의 상태가 남아 있다
it('starts with empty cart', () => {
  // 실패! items에 이미 Widget이 들어 있음
  expect(useCartStore.getState().items).toHaveLength(0);
});

테스트 A에서 추가한 아이템이 테스트 B에서도 그대로 남아 있습니다.
테스트 실행 순서에 따라 결과가 달라지는, 가장 디버깅하기 어려운 종류의 버그입니다.

일반적인 우회 방법은 beforeEach에서 store를 수동으로 리셋하는 것입니다.

beforeEach(() => {
  useCartStore.setState({ items: [], total: 0 });
  useUserStore.setState({ user: null, isAuthenticated: false });
  useSettingsStore.setState({ theme: 'light', locale: 'ko' });
  // store가 늘어날 때마다 여기에 추가해야 한다...
});

store가 두세 개일 때는 관리할 만합니다.

하지만 store가 10개, 20개로 늘어나면 어떻게 될까요.
새 store를 만들 때마다 모든 테스트 파일의 beforeEach를 업데이트해야 하고, 하나라도 빠뜨리면 테스트가 간헐적으로 실패합니다.

근본적으로 store 인스턴스 자체가 테스트 단위로 격리되어야 한다고 느꼈습니다.
각 테스트가 자신만의 store를 가지면 리셋을 신경 쓸 필요가 없으니까요.

여기까지 정리하면, 세 가지 한계 모두 같은 근본 원인을 가리킵니다.

create()가 만드는 store는 모듈 스코프의 싱글턴입니다.

  • 싱글턴이기 때문에 → 다중 인스턴스를 만들 수 없고
  • 모듈 평가 시점에 생성되기 때문에 → props로 초기화할 수 없고
  • 테스트 간에 공유되기 때문에 → 상태가 누수된다

그렇다면 이 문제에 대한 zustand의 공식 답은 무엇이었을까요.

zustand/context의 역사, 있었는데 사라졌다

사실 zustand는 이 문제를 인식하고 있었고, 한때 공식 솔루션을 제공하기도 했습니다.

버전zustand/context 상태
v3도입: React Context 기반 scoped store 패턴 제공
v4deprecated: “직접 만들어 쓰라”는 방향으로 전환
v5완전 제거: 패키지 exports에서 context 모듈 자체가 사라짐

v5의 exports를 확인해 보면 vanilla, react, middleware, shallow, traditional만 존재하고 context는 없습니다.

zustand 메인테이너인 dai-shi는 GitHub Discussion에서 이 결정의 배경을 밝혔습니다.

참고: Zustand GitHub Discussion #1276 - createContext deprecation

zustand 팀의 입장은 명확했습니다.
라이브러리를 최소한으로 유지하고, Context 기반 패턴은 사용자가 직접 구현하도록 맡기겠다는 것이었습니다.

Info

결과적으로 공식 대체 API 없이, 직접 만들어야 하는 상황이 되었습니다.
zustand 공식 문서에는 createStore() + React Context를 조합하는
보일러플레이트 패턴이 안내되어 있지만, store마다 매번 Context, Provider,
custom hook을 만들어야 합니다.

커뮤니티 반응은 갈렸습니다. 일부는 core에 남겨야 한다고 주장했고, 일부는 별도 유틸리티로 충분하다고 동의했습니다.

참고: Zustand 공식 문서 - Setup with Next.js (Next.js 가이드에서 per-request store 패턴으로 이 보일러플레이트를 보여줍니다)

정리하며

글로벌 store가 적합한 경우와 그렇지 않은 경우를 고민하면서, 제가 찾은 판단 기준은 결국 생명주기였습니다.

  • 인증이나 테마처럼 앱 전체와 생명주기를 함께하는 상태 → 글로벌 store가 맞습니다.
  • 특정 페이지나 컴포넌트에서만 쓰이고, 그 페이지를 떠나면 사라져야 하는 상태 → 글로벌에 올릴 이유가 없습니다.

후자를 글로벌에 넣으면 상태가 필요 이상으로 오래 살아남으면서 예기치 못한 문제를 만듭니다.
그리고 create()는 이 생명주기 불일치를 해결할 수 없습니다.

한계근본 원인필요한 것
다중 인스턴스 불가모듈 스코프 싱글턴컴포넌트 트리 단위로 store 인스턴스 생성
props 초기화 불가모듈 평가 시점에 store 생성렌더 시점에 props를 캡처하여 store 생성
테스트 격리 불가테스트 간 store 공유테스트 단위로 독립적인 store 인스턴스

세 가지 모두 **“store 인스턴스를 React 컴포넌트 생명주기에 맞춰 생성하고 관리하는 것”**으로 해결할 수 있어 보입니다.
그리고 그 메커니즘으로 가장 자연스러운 것이 React Context라고 생각했습니다.

다음 글에서는 createStore()와 React Context를 사용해 이 문제들을 실제로 해결하는 과정을 다룹니다.
create()createStore()의 차이부터 시작해서, Context에 store 인스턴스를 담는 패턴을 단계별로 구현해 보겠습니다.

하지만 미리 말해두자면, 이 패턴에는 반복되는 보일러플레이트가 따라옵니다.
그 문제는 시리즈 3편에서 다루겠습니다.


레퍼런스