Logo

Prolip's Blog

zustand 코드 까보기
  • #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이 falseObject.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 스토어를 생성하는 내부 구현 함수로 볼 수 있어요.

작동 순서는 다음과 같아요.

  1. createStore를 호출해 스토어 API를 생성해요. 위에서 이미 다 보고 왔는데 핵심 API 로직이 담긴 객체였습니다.
  2. useBoundStore 함수를 정의해요. 이 함수는 선택자를 인자로 받아 useStore 훅을 호출해요.
  3. 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로 돌아와서 이제 useBoundStoreselector를 인자로 받아 위에서 살펴본 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에 작성된 순수 함수 기반의 상태 변경 메서드를 사용하기 때문에 리액트 훅의 규칙에 위배되지 않고 컴포넌트 외부에서도 안전하게 호출할 수 있었습니다.

여기가지 읽어주셔서 감사합니다…뵹…

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

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

tiptap으로 에디터 구현하기

tiptap으로 에디터 구현하기