
- #Etc
zustand 코드 까보기
Prolip
2025-05-16
zustand가 어떻게 함수 레벨에서도 동작하는지 너무 궁금해서 코드를 까보고 내용을 정리해봤습니다..
시작하며
이번에 프로젝트를 진행하며 상태 관리를 위해 zustand를 사용했습니다. 일반적인 사용법은 아래와 같아요.
const globalErrorStore = create<State & Action>(set => ({ error: null, updateError: error => set({error}), })); export const useGlobalError = () => globalErrorStore(state => state.error); export const useUpdateGlobalError = () => globalErrorStore(state => state.updateError);
이렇게 만들어진 스토어에 대한 각 상태 값과 상태를 업데이트하는 훅들은 아래와 같이 사용합니다.
// 소비하는 곳 function Component() { if(~~~) return null; const globalError = useGlobalError(); const updateGlobalError = useUpdateGlobalError(); return ( <div>뭐시기</div> ) } function 일반함수use로시작안함() { const updateGlobalError = useUpdateGlobalError(); }
하지만 위와 같이 사용하면 훅의 호출 순서를 보장할 수 없어 훅의 규칙을 위반한다는 에러 메세지를 표시하게 됩니다.
잠깐 딴 길로 새서, 제가 이번에 프로젝트를 진행하면서 에러 처리를 중앙화시켜보겠다고 시도한 작업이 있는데요. API 요청을 전부 하나로 묶어서 에러가 발생하면 스토어로 전부 넣어버렸습니다.
async function requestWithErrorHandling<T>(fn: () => Promise<T>): Promise<T | undefined> { try { return await fn(); } catch (error) { if (error instanceof RequestGetError) { if (error.errorHandlingType === 'errorBoundary') throw error; else reportGlobalError(error); return; } if (error instanceof RequestError) { reportGlobalError(error); return; } throw error; } } export async function requestGet<T>({ errorHandlingType, ...args }: WithErrorHandling<RequestMethodProps>): Promise<T> { return requestWithErrorHandling(() => request<T>({ ...args, method: 'GET', headers, withResponse: true, errorHandlingType, }), ); } export async function get뭐시기1() { return await requestGet(...) } export async function get뭐시기2() { return await requestGet(...) } export async function get뭐시기3() { await requestPost(...) }
이런 방식으로 모든 요청들을 모듈화시켜놓고 서버와 요청할 때는 래핑 함수들만 호출해 에러 처리에 대한 코드는 더 작성하지 않을 수 있게 말이죠..
결과적으로는 실패해서 다른 방법을 사용하기는 했는데.. 다시 돌아와서 중간에 보면 reportGlobalError
라는 함수가 있습니다.
전달된 에러를 스토어에 업데이트하는 함수인데, 호출한 함수가 일반 함수죠? 별 거 없습니다.. 일반 함수에서 zustand 스토어를 업데이트하는 방법은 아래와 같이 setState
를 사용하면 됩니다.
const globalErrorStore = create<State & Action>(set => ({ error: null, updateError: error => set({error}), })); export const reportGlobalError = (error: State['error']) => { globalErrorStore.setState({error}); };
그런데.. 어째서 setState를 사용하면 일반 함수에서도 호출이 가능한지 너무 궁금해서 zustand 코드를 좀 까보게 되었는데요. 그냥 지나가면 까먹을 거 같아서 그 내용들을 기록해보려고 합니다.
vanilla.ts
zustand 저장소를 확인해보면 가장 먼저 react.ts가 보이는데요. 당연히 react.ts를 먼저 확인해봤는데 핵심 로직은 모두 vanilla.ts에 담겨있고, 이 코드를 리액트에서 사용 가능하게 래핑한 코드였습니다.
그래서 vanilla.ts를 먼저 확인해봤습니다. 이 코드는 리액트에 의존하지 않은 순수한 자바스크립트 코드로 상태 관리 로직이 담긴 핵심 모듈이라고 볼 수 있어요. 타입에 대한 부분은 전부 치우고 볼게요.
const createStoreImpl = createState => { let state; const listeners = new Set(); const setState = (partial, replace) => { const nextState = typeof partial === 'function' ? partial(state) : partial; if (!Object.is(nextState, state)) { const previousState = state; state = (replace ?? (typeof nextState !== 'object' || nextState === null)) ? nextState : Object.assign({}, state, nextState); listeners.forEach(listener => listener(state, previousState)); } }; const getState = () => state; const getInitialState = () => initialState; const 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; }; export const createStore = createState => (createState ? createStoreImpl(createState) : createStoreImpl);
일단 외부에 노출되는 함수는 createStore
함수니 아래에서부터 올라가면서 확인하면 좋을 거 같아요.
createStore
export const createStore = createState => (createState ? createStoreImpl(createState) : createStoreImpl);
사용자에게 노출되는 최종 함수인데요. 조건부 로직으로 두 가지 사용 패턴을 지원해요. 카운터 스토어를 만든다면 아래와 같을 거예요.
// 상태 생성 함수를 직접 제공 const countStore = createStore((set) => ({ count: 0, increment: () => set(state => ({ count: state.count + 1 })), })) // 인자 없이 호출한 뒤 반환된 함수에 상태 생성 함수 제공 const countStore = createStore()(set => ({ /* 위와 동일 */ }))
createState
가 제공되었다면 즉시 createStoreImpl
을 호출해 스토어를 생성하고, 그렇지 않으면 createStoreImpl
함수 자체를 반환해 나중에 호출 가능하게 해요.
그럼 호출되는 createStoreImpl
함수를 살펴볼게요.
createStoreImpl
1. 변수 선언부
let state; const listeners = new Set();
가장 상단에 변수 선언이 되어있는데요. state
는 현재 상태를 저장할 변수, listeners
는 리스너 집합으로 Set 자료형을 사용해 중복을 방지하고 있어요.
2. setState
const setState = (partial, replace) => { const nextState = typeof partial === 'function' ? partial(state) : partial; if (!Object.is(nextState, state)) { const previousState = state; state = (replace ?? (typeof nextState !== 'object' || nextState === null)) ? nextState : Object.assign({}, state, nextState); listeners.forEach(listener => listener(state, previousState)); } };
다음으로 setState()
함수로 zustand 스토어 내부 상태를 업데이트하는 유일한 방법이라고 보면 돼요. 입력의 형태는 다양할 수 있고 변경 사항이 있으면 구독자들에게 알리는 역할까지 담당하고 있어요.
먼저 nextState
의 조건부에 대해 이해해볼게요. 만약 partial
이 함수라면 state
를 인자로 넘겨 실행해요. 하지만 partial
이 값 자체라면 그 자체가 다음 상태가 돼요.
조금 쉽게 예시를 들어보자면,
setState({ count: 2 }); => 이 경우 nextState은 { count : 2 }로 값 자체가 상태가 돼요. setState(state => ({ count: state.count + 1 })) => 이 경우 nextState은 함수의 실행 결과가 돼요
다음으로 상태 비교와 업데이트 여부 판단 부분인데요.
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)); }
Object.is
를 통해 실제로 상태가 변경된 경우에만 실행되는 코드 블럭으로 값이 변경된 경우에만 아래 로직이 실행돼요.
state
은 조건부를 통해 상태를 완전히 덮어쓸지, 혹은 병합할지 결정해요. 좀 난잡하게 생겼지만,
state = condition ? 바꾸기 : 합치기; => condition이 true면 state = nextState로 완전히 갈아끼우고, => condition이 false면 Object.assign을 통해 병합한다고 생각하면 돼요.
마지막으로 리스너에게 상태 변경을 알리는데 여기서 listeners는 subscribe()
로 등록된 함수들로 상태가 바뀔 때 구독 중인 컴포넌트 등에 알리는 부분입니다.
3. getState
const getState = () => state;
단순히 현재 상태를 반환해요.
4. getInitialState
const getInitialState = () => initialState;
초기 상태를 반환해요.
5. subscribe
const listeners: Set<Listener> = new Set(); const subscribe = listener => { listeners.add(listener); // Unsubscribe return () => listeners.delete(listener); };
내부적으로 listeners.add
로 상태가 변경될 때 실행될 함수를 등록하고, listeners.delete
을 통해 해당 콜백을 해제해 구독을 종료해요.
그럼 위에서 살펴봤듯, 상태가 변경될 때 setState
내부에서 listeners.forEach
로 모든 리스너들에게 알리게 되는 구조입니다.
6. api. initialState
const api = { setState, getState, getInitialState, subscribe } const initialState = (state = createState(setState, getState, api)) return api
zustand의 createStore()
내부에서 스토어를 초기화하고 상태를 생성하고 만들어진 API 객체를 반환해요. 여기서 반환되는 API 객체가 state와 listeners를 계속해서 참조하고 있는데 이거 클로저죠?
이게 zustand의 핵심 로직이라고 볼 수 있었고, 이제 이 로직들을 리액트에서 어떻게 동작하게 변환해주는지 react.ts
를 확인해봅시다.
react.ts
const identity = arg => arg; export function useStore(api, selector = identity) { const slice = React.useSyncExternalStore( api.subscribe, () => selector(api.getState()), () => selector(api.getInitialState()), ); React.useDebugValue(slice); return slice; } const createImpl = createState => { const api = createStore(createState); const useBoundStore = selector => useStore(api, selector); Object.assign(useBoundStore, api); return useBoundStore; }; export const create = createState => (createState ? createImpl(createState) : createImpl);
마찬가지로 타입에 대한 부분들은 전부 치우고 vanilla.ts
와 비슷한 느낌인데요. 우리에게 노출되는 최종 함수는 create
함수니 따라 올라가면서 확인해볼게요.
create
export const create = (createState) => createState ? createImpl(createState) : createImpl
create
함수는 우리에게 노출되는 최종 함수로 인자로 상태 생성 함수를 직접 제공하거나 혹은 인자 없이 호출한 뒤 반환된 함수에 상태 생성 함수를 제공하는 방식으로 사용이 가능합니다.
그럼 내부적으로 createState
가 제공되었는지 확인한 뒤 제공되었다면 바로 createImpl
을 호출하고 제공되지 않았다면 createImpl
함수 자체를 반환해 나중에 호출할 수 있게 됩니다. 위에서 봤던 createStore
랑 비슷해보입니다. 바로 내부에서 호출하는 createImpl
함수를 살펴볼게요.
createImpl
const createImpl = (createState) => { const api = createStore(createState) const useBoundStore = (selector) => useStore(api, selector) Object.assign(useBoundStore, api) return useBoundStore }
createImpl은 실제로 zustand 스토어를 생성하는 내부 구현 함수로 볼 수 있어요.
작동 순서는 다음과 같아요.
createStore
를 호출해 스토어 API를 생성해요. 위에서 이미 다 보고 왔는데 핵심 API 로직이 담긴 객체였습니다.useBoundStore
함수를 정의해요. 이 함수는 선택자를 인자로 받아useStore
훅을 호출해요.Object.assign(useBoundStore, api)
를 통해useBoundStore
함수에 스토어 API의 모든 메서드를 복사해요.- 아주 흥미로운 부분인데, 함수이면서 객체로 동작하는 스토어를 만드는 핵심이라고 보면 돼요.
- 이 줄 덕분에
globalErrorStore.setState()
와 같은 방식으로 직접 스토어 API에 접근할 수 있는데, 지금은 아하?로 넘어가고 이후에 다시 보면 더 좋을 거 같아요.
이런 흐름을 통해 함수이면서 동시에 객체인 스토어를 반환하게 돼요. 그럼 useStore
를 확인해볼게요.
useStore
const identity = arg => arg export function useStore(api, selector = identity) { const slice = React.useSyncExternalStore( api.subscribe, () => selector(api.getState()), () => selector(api.getInitialState()), ); return slice; }
useStore
는 zustand 스토어에서 리액트 컴포넌트가 어떤 상태를 구독할지 지정하고, 상태가 바뀔 때 해당 컴포넌트만 정확하게 리렌더링될 수 있게 추적하는 함수라고 볼 수 있어요.
인자로 두 가지를 받을 수 있는데요.
- api: zustand 스토어 API
- selector: 스토어의 전체 상태에서 필요한 부분만 선택하는 함수로 기본값은 전체 상태를 반환하는
identity
함수입니다.
useSyncExternalStore
useStore 내부에선 리액트의 useSyncExternalStore
훅을 사용해요. 이 부분 때문에 zustand 사용 시 훅의 규칙이 작동해요.
여기서 useSyncExternalStore
는 외부 저장소를 구독하기 위한 훅으로 리액트 18에서 도입됐어요. 리액트 외부에서 상태가 바뀌는걸 감지하고 리렌더링을 트리거해줍니다.
시그니처는 아래와 같아요.
const value = useSyncExternalStore( subscribe: (onStoreChange) => () => void, getSnapshot: () => any, getServerSnapshot: () => any // SSR 용 );
- subscribe은 말 그대로 저장소를 구독하는 함수로 스토어의 값이 변경되면 제공된 subscribe를 다시 호출해요. 리렌더링 과정에서 훅의 호출 순서에 맞게 다시 스토어에 연결해주는 과정입니다.
- 전달할 subscribe 함수는 스토어를 구독할 수도 있어야하고, 반대로 구독을 취소할 수도 있어야 해요.
- getSnapshot은 저장소의 현재 상태를 가져오는 함수를 전달해야 하는데요. 전달된 스토어 상태를 Object.is로 비교해 변경이 감지되면 리렌더링합니다.
- getServerSnapshot은 SSR에 사용해요.
그럼 zustand는 여기에 어떻게 대응학고 있는지 확인해볼게요.
1. subscribe
const slice = React.useSyncExternalStore( api.subscribe, .. .. ); // vanilla.ts const subribe = (listener) => { listeners.add(listener); return () => listeners.delete(listener); };
위에서 설명했듯, subscribe에 전달할 함수는 스토어를 구독할 수 있어야하고, 반대로 취소할 수 있어야 했는데요. zustand의 vanilla.ts를 보면 이 스토어를 구독하는 구독자들을 listeners라는 Set 자료형에 담아두고 있었습니다.
useSyncExternalStore가 호출되어 subscribe 함수가 실행되면 호출한 컴포넌트들 구독자로 판단하고, 이후에 리렌더링이 유발되면 구독을 취소하고 호출 순서에 맞게 다시 listeners 배열에 들어갈 거예요.
2. getSnapshot
const slice = React.useSyncExternalStore( .. () => selector(api.getState()), .. ); // vanilla.ts const getState = () => state;
두 번째 인자에는 현재 스토어의 상태를 반환하는 함수가 전달되어야 했습니다. api 객체의 getState 함수는 스토어의 상태를 반환하는 함수였으니 적절합니다.
여기 selector는 아래에서 설명해볼게요.
3. getServerSnapshot
const slice = React.useSyncExternalStore( .. .. () => selector(api.getInitialState()) ); const getInitialState = () => initialState; const initialState = (state = createState(setState, getState, api));
이건 서버측 렌더링, 클라이언트측 렌더링 중 하이드레이션이 발생할 때 사용된다고 합니다. 그냥 스토어의 초기 상태를 반환하는 함수를 전달하고 있어요.
다시 useSyncExternalStore를 사용하는 이유를 짚어보자면,
무언가가담긴객체.value = 123; let 변수 = 2; 변수 = 3; 변수 = 4;
리액트는 기본적으로 외부에서 직접 수정한 값에 반응하지 않습니다. 위의 경우처럼 객체의 value 값이나 선언한 변수 값을 아무리 바꾼다한들 리렌더링이 발생하지 않아요.
zustand의 코어 로직은 순수한 자바스크립트로 이루어져있어 완벽하게 리액트 외부에서 동작한다고 볼 수 있었어요. 때문에 리액트에게 상태가 바뀌었다고 알리는 메커니즘이 필요해지는데 그게 useSyncExternalStore
인 거예요.
createImpl
const createImpl = (createState) => { const api = createStore(createState) const useBoundStore = (selector) => useStore(api, selector) Object.assign(useBoundStore, api) return useBoundStore }
다시 createImpl
로 돌아와서 이제 useBoundStore
는 selector를 인자로 받아 위에서 살펴본 useStore
를 호출하는 함수라는 것을 알겠어요. 아래 Object.assign
를 보기 전에 먼저 이해하고 가야하는 부분이 있는데요.
자바스크립트에서 함수는 일급 객체입니다.
function 무언가() {} // 이 함수는 아래와 동일! const 무언가 = new Function(); // Function 인스턴스 (객체라는 의미!)
그럼 모든 함수는 객체니까 속성을 자유롭게 추가할 수 있습니다. 위에서 쓰인 Object.assign
의 동작을 예로 들어보면
function 나는합침당할함수(selector) { console.log("나는 함수!") } const 나는합쳐질객체 = { state: { count: 5 }, getState() { return this.state; }, setState(value) { this.state.count = value; } }; Object.assign(나는합침당할함수, 나는합쳐질객체); console.log(나는합침당할함수.getState()); // { count: 5 } 나는합침당할함수.setState(6); 나는합침당할함수(); // 나는 함수! console.log(나는합침당할함수.getState()); // { count: 6 }
이렇게 첫 번째 인자로 타겟을 두 번째 인자로 합치고 싶은 소스를 전달해 함수이면서 동시에 객체에 존재하던 속성도 가질 수 있게 되는 거예요.
const createImpl = (createState) => { const api = createStore(createState) const useBoundStore = (selector) => useStore(api, selector) Object.assign(useBoundStore, api) return useBoundStore }
이렇게 vanilla.ts
에 존재하던 순수 자바스크립트 로직을 useStore
를 호출하는 useBoundStore
함수에 합쳐서 사용자에게 제공하게 돼요.
identity
외부로 useBoundStore
가 반환된다는 사실까지 알았으니 아까 위에서 잠깐 넘어갔던 selector에 대해 알아볼게요.
const identity = arg => arg export function useStore(api, selector = identity) { ... }
먼저 identity는 받은 인자를 그대로 반환하는 항등 함수인데요. 이 함수를 useStore
훅의 selector 인자의 기본값으로 사용하고 있어요.
왜일까요? 이건 zustand를 사용하면서 자연스럽게 사용했던 동작일텐데, 스토어 객체의 특정 값을 가져올 수도 있고 혹은 객체 전체를 조회할 때도 있죠?
const 어떤스토어 = create(set => { 어떤값: "어떤 값!", 어떤값업데이트: () => set(어떤값 => ({ 어떤값 })), }); const 스토어자체 = 어떤스토어(); // {어떤값: "어떤값!", ...} const 스토어의 어떤 값 = 어떤스토어(state => state.어떤 값); // 어떤 값!
이렇게요! 쉽게 selector는 스토어에서 내가 필요한 어떤 값만 가져오는 역할을 해요.
그래서 이거 안전한가?
이렇게 순수 자바스크립트 로직을 사용할 수 있기 때문에 훅의 규칙을 위반하지 않게 되는데요. 아 그러면 이거 리액트 외부에서 스토어의 상태를 변경할 수 있으니 위험하지 않을까? 싶을 수도 있습니다.
이게 편법이 아니라 싱글톤 패턴을 사용하고 있어서 실제로는 위험하지 않습니다.
싱글톤
zustand 스토어는 생명주기에서 단 한 번 생성되는 싱글톤 객체입니다. 이게 왜 중요하냐면 결국 리액트 컴포넌트, 컴포넌트 내부 훅, 외부 함수 레벨에서 상태를 변경해도 결국 하나의 동일한 스토어 객체를 바라보고 수정하기 때문입니다.
용어가 하나 있더라구요. SSOT(Single Source of Truth)라고 단일 진실 소스라고도 하는데 결국 스토어 자체는 항상 복사되지 않고 하나로 유지되기 대문에 상태가 어긋나는 등의 문제는 발생하지 않아요.
리액트와 동기화하는데 사용한 useSyncExternalStore
훅도 결국 컴포넌트 외부에서 상태가 변경되었을 때 변화를 감지하고 리렌더링을 트리거해주는 역할이기 때문입니다.
마치며
여기까지 zustand의 setState()
가 어떻게 함수 레벨에서도 동작할 수 있는지 확인해봤는데요.
우리가 사용하게 되는 react.ts
의 코드는 결국 vanilla.ts
에 작성된 순수 함수 기반의 상태 변경 메서드를 사용하기 때문에 리액트 훅의 규칙에 위배되지 않고 컴포넌트 외부에서도 안전하게 호출할 수 있었습니다.
여기가지 읽어주셔서 감사합니다…뵹…

효율적인 에러 처리를 위한 몸부림
