jay.log

posts.

portfolio.

기록

zustand에 대해 알아보기

떠오르는 전역 상태관리 라이브러리 zustand를 뜯어보았습니다

12분

2024-04-13

0. 들어가며

zustand는 최근 눈에 띄게 부상중인 전역 상태관리 라이브러리입니다.

이번에 우연히 배민 유튜브를 보다가 프론트엔트 상태관리를 server-side에선 react-query를, client-side에선 zustand를 사용하고 있으며, 전역상태를 최소화하고 있다고 들었는데, 저의 개발 가치관과 잘 맞는 것 같아 이번 기회에 zustand에 대해 더욱 관심이 생겨 공부해보기로 했습니다

redux처럼 Flux패턴을 이용해서 상태를 관리하고 있지만, 신기하게도 context api를 사용하지 않고 이벤트 리스너에 변수를 등록해 클로저로 상태 값을 유지하면서 전역적으로 상태를 관리하는 것 같습니다.

이런 특징 덕분에 컴포넌트 혹은 훅에서 독립해서 스토어를 생성해서 관리할 수 있으며, 컴포넌트 외부에서도 상태 값을 관리할 수 있는 장점이 있습니다.

1. Vanilla JS

zustand의 바닐라 코드를 살펴보면, 엄청난 기능에 비해 코드가 엄청 간단하게 구성되어 있었습니다.

// 스토어를 생성하는 팩토리 함수. 이 함수는 createStoreImpl을 호출하여 스토어 인스턴스를 생성한다.
// @param createState - 스토어의 초기 상태를 설정하는 함수.
// 이 함수가 제공되면, 해당 함수를 사용하여 초기화된 스토어를 생성한다.
// 함수가 제공되지 않는 경우, 기본 createStoreImpl 함수만을 반환한다.
// 이 함수는 스토어의 상태를 설정하고 관리하는 API들({ setState, getState, api })을 인자로 받는다.
export const createStore = ((createState) => (createState ? createStoreImpl(createState) : createStoreImpl)) as CreateStore;
  1. createStore로 스토어를 생성합니다.
  2. 생성한 스토어는 createState라는 인자를 받으며, 인자는 set, get, api를 받습니다.
  • set은 스토어에 등록된 값을 업데이트하기 위해 사용합니다.
  • get은 스토어에 등록된 값을 가져오기위해 사용합니다.
  • api는 생성된 스토어에 get, set 이외의 세부적인 조작을 하기위해 사용합니다.
    • eg. 강제 상태 값 업데이트, 스토어에 등록된 리스너 삭제
// createStore를 위해 사용하는 팩토리함수 createStoreImpl
const createStoreImpl: CreateStoreImpl = (createState) => {
  type TState = ReturnType<typeof createState>;
  type Listener = (state: TState, prevState: TState) => void;
 
  // state를 let으로 선언하여 클로저를 통해 상태를 유지하고 관리
  let state: TState;
 
  // 리스너를 중복 없이 관리하기 위해 Set 데이터 구조를 사용
  const listeners: Set<Listener> = new Set();
 
  // setState는 새로운 상태 또는 상태를 업데이트하는 함수를 받고, replace 플래그를 사용하여 상태를 교체할지 결정
  const setState: StoreApi<TState>['setState'] = (partial, replace) => {
    // nextState는 인자로 받은 partial이 함수인 경우 현재 상태를 기반으로 새 상태를 계산하고,
    // 아닌 경우 partial 자체가 새 상태가 된다.
    const nextState = typeof partial === 'function' ? (partial as (state: TState) => TState)(state) : partial;
 
    // nextState와 현재 state가 다른지 Object.is를 통해 비교한다.
 
    // Object.is()는 기본형 데이터면 메모리 주소가 달라도 값을 제대로 비교할 수 있으나
    // 참조형 자료구조에는 메모리 주소가 같은지 검사
    // Nan의 값이 동일한지와 +0 -0이 같은지 비교에서 유용한 메서드
 
    // nextState와 state가 다르다면 if문 내부의 동작이 일어남
    if (!Object.is(nextState, state)) {
      // 기존의 state를 previousState로 복사
      const previousState = state;
 
      // replace가 true이거나 nextState가 객체가 아니거나 nextState가 null인 경우 nextState를 직접 할당한다.
      // 그렇지 않은 경우에는 기존 state와 nextState를 합쳐 새로운 객체를 생성하여 할당한다.
      state = (replace ?? (typeof nextState !== 'object' || nextState === null)) ? (nextState as TState) : Object.assign({}, state, nextState);
 
      // 모든 리스너를 순회하면서 업데이트된 상태와 이전 상태를 전달한다.
      listeners.forEach((listener) => listener(state, previousState));
    }
  };
 
  // 그냥 state를 반환하는 함수
  // 말 그대로 현재 state의 상태를 조회하는 역할을 한다
  const getState: StoreApi<TState>['getState'] = () => state;
 
  // initialState를 인자로 받았다면, 바로 Store를 생성할 수 있도록 하는 함수
  const getInitialState: StoreApi<TState>['getInitialState'] = () => initialState;
 
  // 가장 핵심적인 로직이라고 할 수 있다
  // 위에서 Set으로 생성한 listner를 이벤트 리스너에 등록하였으며,
  // 리턴문에 이벤트 리스너에서 제거함으로 메모리 누수도 막고 있다.
  const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
    listeners.add(listener);
    // Unsubscribe
    return () => listeners.delete(listener);
  };
 
  // 스토어 내부의 모든 리스너를 지우는 함수
  const destroy: StoreApi<TState>['destroy'] = () => {
    listeners.clear();
  };
 
  // 생성한 스토어의 함수들을 묶어서 리턴하기 위해 선언
  const api = { setState, getState, getInitialState, subscribe, destroy };
 
  // createState 함수를 호출하여 스토어의 초기 상태를 설정하고, 이를 state 변수에 할당한다.
  const initialState = (state = createState(setState, getState, api));
  return api as any;
};

2. React

위 바닐라 코드를 이용해 리액트에서는 훅으로 스토어를 생성 및 구독할 수 있습니다.

// Zustand 스토어 인스턴스, 선택 함수, 비교 함수를 인자로 받는 커스텀 훅
// api: Zustand에서 생성된 스토어 API 인스턴스
// selector: 스토어의 전체 상태에서 필요한 부분만을 선택하여 반환하는 함수
// equalityFn: 선택된 상태의 이전 값과 새 값이 같은지 비교하는 함수
export function useStore<TState, StateSlice>(api: WithReact<StoreApi<TState>>, selector: (state: TState) => StateSlice = identity as any, equalityFn?: (a: StateSlice, b: StateSlice) => boolean) {
  // React 18의 useSyncExternalStoreWithSelector를 사용하여 스토어의 상태를 구독하고,
  // 동시성 렌더링에서 발생할 수 있는 tearing 문제를 해결
  const slice = useSyncExternalStoreWithSelector(api.subscribe, api.getState, api.getServerState || api.getInitialState, selector, equalityFn);
 
  useDebugValue(slice); // 개발자 도구에서 추적을 용이하게 하는 값 표시
  return slice;
}
 
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
  const api = typeof createState === 'function' ? createStore(createState) : createState;
 
  const useBoundStore: any = (selector?: any, equalityFn?: any) => useStore(api, selector, equalityFn);
 
  Object.assign(useBoundStore, api);
 
  return useBoundStore;
};
 
export const create = (<T>(createState: StateCreator<T, [], []> | undefined) => (createState ? createImpl(createState) : createImpl)) as Create;

리액트에서 사용할 수 있는 코드는 바닐라 자바스크립트에서 본 패턴과 비슷하게 작성되어있습니다. 그 중에서도 집중해서 살펴볼 포인트는 useSyncExternalStoreWithSelector를 이용해서 React 18부터 지원되는 동시성 렌더링에서 발생할 수 있는 tearing 문제를 해결하고 있습니다.

tearinguseTransition같은 훅으로 렌더링되는 우선순위를 결정할 수 있는데, 외부 상태관리 라이브러리와 충돌이 일어나 기대하는 렌더링이 제대로 일어나지 않는 문제를 말합니다.

3. 예제

위 내용들을 가지고 실제로 사용하는 예제를 살펴본다면,

import { create } from 'zustand';
 
const useBearStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 })
}));
  1. useBearStorecreate를 사용하여 생성된 zustand 스토어의 커스텀 훅입니다. 이 커스텀 훅을 통해 스토어의 상태를 직접적으로 관리할 수 있습니다.
  2. create 함수의 인자로 ( set ) => ( ... )를 넣어줬습니다.
  • 이는 생성한 스토어의 초기 값을 설정 해준 것이며, set, get, api 인자 중 첫번째 인자인 set에 할당한 것을 확인할 수 있습니다.
  1. 이렇게 생성한 스토어는 생성된 인스턴스의 리턴된 api 객체 속 set(setState)콜백함수의 인자로 가져왔으며, increasePopulationremoveAllBears를 통해 값을 업데이트 할 수 있도록 만들고 있습니다.

4. 마무리

zustand에 대해 간단하게 알아봤습니다. react-queryjotai를 만든 개발자들이 함께 만든 라이브러리라 엄청나게 어려운 코드가 있는 줄 알았지만, 제가 이해할 수 있을 정도의 코드로 구성되어있다는게 놀라울 뿐 입니다. 아이디어가 정말 대단하고 이를 실제로 구현하여 많은 이용자들이 사용하고 있다는게 두 개발자분들이 대단하다고 느껴집니다.

새롭게 레거시코드를 정리하면서 서버사이드 상태는 react-query, 클라이언트 사이드 상태는 zustand로 관리하려고 코드를 작성하고 있었는데, 이번 기회에 동작원리에 대해 알게되어 잘 사용할 수 있게 되지 않을까 싶습니다.

5. Reference

Toast UI 블로그
배민 우아콘 유튜브
zustand github

안녕하세요, 프론트엔드 개발자 이진웅입니다!

확장성이 뛰어나고 유지보수가 용이한 개발 방법론에 큰 관심을 가지고 있습니다.

© jay.log powered by Next.js, Vercel