Zustand는 왜 React의 바깥에서도 동작하는가
React-Router의 loader는 React의 렌더링 사이클에서 벗어나 있다.
그래서 이 React-Router의 loader는 React hooks를 사용할 수 없는데, 흥미롭게도 이 loader에서 Zustand는 사용이 가능하다.
이게 어떻게 가능한걸까, 항상 의문이 들어 이 자리를 빌어 Zustand 그 자체를 해부하겠다.
Zustand의 철학은 createStore에서 드러난다
Zustand를 사용할 때, 제일 먼저 호출되는 함수는 createStore다. 흔히들 프론트엔드 개발자들은, 이 createStore를 통해 React의 컴포넌트 트리와 무관하게 전역으로 상태를 관리한다.
그러나 이 createStore의 진가는 단순히 전역 상태 관리에 그치지 않는다.
Zustand는 createStore라는 구현체를 통해 React의 life-clycle에서 벗어나 상태를 생성하며, 상태 변화가 감지되면 구독자에게 변화가 전파하는 방식을 결정한다.
즉, createStore는 React의 life-clycle에서 탈피하여 상태를 관리하는 핵심 모델이다.
createStore
Zustand의 createStore 의사 코드는 다음과 같다.
export const createStore = (createState) => {
let state;
const listeners = new Set();
const setState = (partial, replace) => {
const nextState =
typeof partial === 'function' ? partial(state) : partial;
if (nextState !== state) {
const previousState = state;
state = replace ? nextState : Object.assign({}, state, nextState);
listeners.forEach((listener) => listener(state, previousState));
}
};
const getState = () => state;
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
state = createState(setState, getState, { setState, getState, subscribe });
return { setState, getState, subscribe };
};
사실 원본 코드도 상당히 흡사하다.
zustand/src/vanilla.ts at main · pmndrs/zustand
🐻 Bear necessities for state management in React. Contribute to pmndrs/zustand development by creating an account on GitHub.
github.com
이 createStore에서 가장 중요한 것은 다음의 3 가지이다.
1. createStore는 closure를 기반으로 한 인스턴스 스토어이다.
2. 상태 비교는 reference equality를 활용한다.
3. pub/sub 모델로 상태를 전파한다.
하나씩 파헤쳐보자.
1. createStore는 closure를 기반으로 한 인스턴스 스토어이다.
createStore를 호출할 때마다, state와 listeners를 포함하는 독립적인 스토어 인스턴스가 생성된다.
즉, createStore라는 함수를 단순하게 호출하는 것이 곧 createStore의 생성이다.
우리는 보통 createStore를 통해 단일 스토어로 생성하기에, 종종 createStore를 단일 인스턴스 스토어라고 착각한다. 하지만 Zustand는 구태여 스토어에 단일을 강제하지 않기에-우리가 단순히 단일 스토어를 만들고 있을 뿐이다-정확히는 각 인스턴스가 독립적인 스코프를 가진 스토어를 만드는 인스턴스 스토어이다.
createStore의 내부 구현은 다음과 같다.
let state;
const listeners = new Set();
여기서 알아야할 핵심은 다음이다.
createStore로 만들어지는 스토어는 전역 객체가 아니라, 호출 시점에서 state와 listeners가 closure로 은닉된 인스턴스로 생성된다.
상태 관리를 closure로 설계한 그 이유는 다음의 상태 비교를 통해 확인해보자.
상태 비교는 스토어의 구조와 분리해서 볼 수 없다.
2. 상태 비교는 reference equality를 활용한다.
const setState = (partial, replace) => {
const nextState =
typeof partial === 'function' ? partial(state) : partial;
if (nextState !== state) {
const previousState = state;
state = replace
? nextState
: Object.assign({}, state, nextState);
listeners.forEach((listener) => listener(state, previousState));
}
};
zustand는 상태를 추적하지 않고, 이전 상태와의 참조 비교만을 기준으로 변화 여부를 판단한다.
React는 리렌더링 여부를 reference equality로 결정한다.
그래서 Zustand도 state의 변경을 가장 싸게 판단해야 한다.
nextState !== state 비교의 본질
if (nextState !== state){
// ...로직
}
===는 객체 비교시 참조(reference) 비교다.
=== 연산자는 인자가 primitive이면 값을 비교한다.
하지만 그 primitive가 아닌 값들은 객체로 취급되기에(자세한 이유는 글 주제와 맞지 않아 생략), 이 경우 === 연산자는 이 객체의 내용과는 상관없이, 같은 참조인지를 확인하여 다르면 무조건 false인 참조 동등성을 비교한다.
결국, === 연산자는 두 피연산자가 같은 identity를 공유하는지를 확인한다.
이것이 reference equality의 본질이다.
인스턴스 스토어의 상태를 closure로 설계했기에, 이 reference equality가 빛을 발한다.
createStore로 생성된 스토어 인스턴스의 closure로 은닉된 state의 참조를 비교함으로써 상태 변화를 감지한다.
3. pub/sub 모델로 상태를 전파한다.
Zustand에서 변화된 상태를 감지하였지만, 이 변화를 누가 알아야하는가.
변화한 상태를 감지하는 방식을 zustand는 listener를 통하여 해결한다.
setState/listener
Zustand는 상태 전파 모델로 pub/sub 구조를 선택하였다.
여기서 pub/sub 모델은 간단하게 다음과 같다.
상태를 변경하는 publisher와, 그 변화를 소비하는 subscriber를 분리한다.
상태를 변경하는 측은 상태를 변경하는 것에 대해만 알고 있는다. 누가 이 상태를 사용하는지는 알 필요가 없다.
상태를 소비하는 측은 상태를 소비하는 것에 대해만 알고 있는다. 누가 이 상태를 변경하는지는 알 필요가 없다.
그리고 이 둘은 상태 변화라는 이벤트로만 연결된다.
이는 React에서 useState를 활용하는 패턴에서도 어렴풋이 나타난다.
우리는 종종 setState를 핸들러 함수를 통해서, 상태 변경 로직을 분리시킨다.
컴포넌트는 상태를 직접 변경시키지 않고, 이 핸들러 함수를 호출한다.
Zustand도 같은 결의 설계 철학을 따른다.
그러나 이 원리를 subscribe라는 함수를 너머, 스토어 구조 전체로 확장한다.
listener 기반 전파 구조
Zustand의 listener는 상태가 변화했다는 사실을 전달받는 구독자이다.
스토어에서는 이 listener가 무엇인지 신경쓰지 않고 단순히 상태 변경이 감지되면 변화를 listener에게 전달한다.
if (nextState !== state) {
const previousState = state;
state = replace ? nextState : Object.assign({}, state, nextState);
listeners.forEach((listener) => listener(state, previousState));
}
Zustand는 setState를 통해 상태 변경을 감지한 이후, 등록된 listener들에게 상태를 전파한다.
이는 React의 render-cycle과 무관하게 스토어에서 독립적으로 실행되기에, zustand는 React 컴포넌트 바깥에서도 상태를 관리할 수 있다.
subscribe
상태 변화를 감지하는 listener의 등록은 subscribe 함수를 통해 일어난다.
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
이 subscribe는 단순히 listener를 등록하고, 함수를 반환한다.
이 반환 함수를 호출하면 listener를 제거한다.
조금 더 해석을 가미하자면, 다음과 같은 동작을 한다.
function subscribe(listener) {
listeners.add(listener);
return function inner() {
listeners.delete(listener);
};
}
const unsubscribe = subscribe(listener);
unsubscribe(); // listener 제거
subscribe를 호출한 시점에서 listener는 subscribe의 Lexical Environment에 바인딩된다.
반환된 unsubscribe 함수는 이 Lexical Environment를 참조하는 closure이고, 이 함수를 호출하면 closure로 바인딩된 listener를 제거한다.
unsubscribe는 closure를 활용하기에 어떤 listener를 제거할지, 전달받을 필요가 없다.
단순히 unsubscribe를 실행함으로써 listener가 제거된다.
cleanup
Zustand에서 등록된 listener를 제거하는 패턴을 cleanup이라 한다.
cleanup은 Javascript에서 특별한 개념이 아니다.
단순히 함수를 호출하여 작업을 하고, 그 작업을 되돌리는 함수를 반환한다. 필요한 시점에서 반환한 함수를 다시 호출하는 패턴일 뿐이다.
이 cleanup을 통해, Zustand에서는 subscribe의 life-cycle을 스토어가 아닌 구독자 스스로가 관리하게 만든다.
React에서는 이 패턴을 useEffect에서 콜백함수에 하나의 규칙으로 규정하였다.
하지만, cleanup 자체는 React만의 전유물이 아니라 순수한 함수 호출 모델에 가깝다.
이러한 구조 덕분에, Zustand는 react의 life-cycle 바깥에서 상태를 안전하게 관리한다.
Zustand의 state 초기화
createStore에서 제일 마지막에 state를 초기화하고, setState, getState 및 subscribe를 외부에 노출한다.
state = createState(setState, getState, { setState, getState, subscribe });
return { setState, getState, subscribe };
따라서 Zustand에서 명시적으로 선언된 setState와 getState로 상태를 관리하고, 또한 subscribe를 통해 구독을 관리할 수 있다.
하지만 프론트엔드 개발자는 실제로 보통 setState와 getState만 사용하는 경우가 대부분이고, subscribe를 호출하는 경우는 드물다.
이는 React의 컴포넌트 life-cycle과 관련이 있다. React 내부에서 useSyncExternalStore를 통해서 subscribe가 컴포넌트의 생명주기에 의존되기 때문이다.
즉, React에서 pub/sub 모델을 대신 사용해주고 관리하기 때문에, 프론트엔드 개발자들은 이 subscribe를 직접 다룰 필요가 없다.
set, get, subscribe
set, get, subscribe만을 활용하는 이 구조는 얼핏보면 단순해보인다.
그러나 이는 의도적으로 복잡함을 제거한 결과이다.
우리가 흔히 코드를 설계할 때, 기능을 만드는건 누구나 할 수 있다. 심지어 AI가 더 쉽게 기능을 만들 수 있다.
하지만 코드에 담긴 의도를 누구나 쉽게 이해하게끔 만드는 것은 어렵다.
이런 점에서 Zustand의 스토어는 마치 피카소의 그림처럼 보여진다. 피카소가 어린아이처럼 그림을 그리기 위해 상당히 노력했듯이, Zustand의 설계에 상당히 많은 노력이 들어간 것이 보인다.
Zustand는 스토어의 핵심을 단순하게 만들고, 개발자에게 set, get만을 다루게 하여 복잡성을 감추었다.
그 사이의 복잡성은 middleware가 resolve한다.
결국, Zustand에서 스토어는 모든 책임이 모이는 핵심이자, 책임의 정수이다. 그리고 이 set, get, subscribe 모델은 그 책임을 응축시킨 결과이다.
middleware
Zustand의 middleware는 createStore를 래핑하는 방식으로 복잡성을 resolve한다.
다음은 middleware의 사용 예시이다.
const useExampleStore = createStore(
devtools(
persist(
(set, get, api) => ({
count: 0,
increase: () => set(s => ({ count: s.count + 1 })),
}),
{ name: 'example-key' }
)
)
);
이 구조에서 middleware는 스토어의 상태나 상태 관리 방식을 변경하지 않는다. 대신 createStore를 래핑하여 기존의 set, get, subscribe를 새로운 set, get, api로 감싸, 스토어를 변형하지 않고 스토어 그 자체를 확장한다.
그 결과, 상태 관리의 핵심은 그대로 유지한 채, 복잡성만이 middleware에서 resolve된다.
React에서의 Zustand
React에서 Zustand를 사용할 때, 스토어를 직접 subscribe하지 않는다.
대신 useSyncExternalStore를 통해서 스토어가 컴포넌트의 life-cycle에 자연스럽게 연결된다.
export function useStore(api, selector) {
const slice = React.useSyncExternalStore(
api.subscribe,
useCallback(() => selector(api.getState()), [api, selector]),
useCallback(() => selector(api.getInitialState()), [api, selector])
);
useDebugValue(slice);
return slice;
}
useSyncExternalStore를 활용함으로써, 프론트엔드 개발자들은 subscribe 또는 cleanup을 사용하지 않고, React 안에서 자연스럽게 상태를 관리할 수 있다.
그렇기에 Zustand는 React의 ContextAPI같이 단순한 전역 상태 관리 모델이 아니라, React가 소비할 수 있는 외부 상태 모델인 것이다.
const counter = useStore((state) => state.counter);
이 호출을 통해 스토어 인스턴스 기반 상태 변경이 내부적으로 React render-cycle로 연결된다.
글을 마무리 지으면서
Zustand를 역설계하면서, 이 글에서는 구현의 세부 디테일보다는 Zustand가 가진 구조 그 자체에 중점을 두었다.
코드를 한줄한줄 따라가는 것보다 최대한 구조를 보면서, Zustand가 가진 아키텍쳐의 우아함이 드러나도록, 코드의 세부적인 설명은 최소화하였다.
Zustand를 해부하면서 특히 감탄한 부분은 closure와 reference equality, 그리고 middleware wrapping을 통한 extend이다.
세상에는 수많은 코드가 존재하고, 수많은 아키텍쳐가 존재한다.
그 중에서도 상위에 존재하는 Zustand의 아키텍쳐를 탐구하면서 느낀 구조적 우아함이, 내가 앞으로 설계하는 아키텍쳐에서도 표현될 수 있기를 희망한다.
'프론트엔드 관련 > 기초' 카테고리의 다른 글
| FSD에서 도메인 중심 아키텍처로 전환하며 마주한 현실적인 선택들 (3) | 2026.01.04 |
|---|---|
| Execution Context를 통한 Closure의 이해 (1) | 2025.12.11 |
| tanstack query 개선기 (0) | 2025.12.07 |
| React Router Path AutoComplete with type-safety (4) | 2025.04.10 |
| [React] http Typescript axios(feat. interceptor 에러 핸들링) (0) | 2025.03.30 |