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;
createStore
로 스토어를 생성합니다.- 생성한 스토어는
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
문제를 해결하고 있습니다.
tearing
은 useTransition
같은 훅으로 렌더링되는 우선순위를 결정할 수 있는데, 외부 상태관리 라이브러리와 충돌이 일어나 기대하는 렌더링이 제대로 일어나지 않는 문제를 말합니다.
3. 예제
위 내용들을 가지고 실제로 사용하는 예제를 살펴본다면,
import { create } from 'zustand';
const useBearStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 })
}));
useBearStore
는create
를 사용하여 생성된zustand
스토어의 커스텀 훅입니다. 이 커스텀 훅을 통해 스토어의 상태를 직접적으로 관리할 수 있습니다.create
함수의 인자로( set ) => ( ... )
를 넣어줬습니다.
- 이는 생성한 스토어의 초기 값을 설정 해준 것이며,
set
,get
,api
인자 중 첫번째 인자인set
에 할당한 것을 확인할 수 있습니다.
- 이렇게 생성한 스토어는 생성된 인스턴스의 리턴된
api
객체 속set(setState)
를 콜백함수의 인자로 가져왔으며,increasePopulation
과removeAllBears
를 통해 값을 업데이트 할 수 있도록 만들고 있습니다.
4. 마무리
zustand
에 대해 간단하게 알아봤습니다. react-query
와 jotai
를 만든 개발자들이 함께 만든 라이브러리라 엄청나게 어려운 코드가 있는 줄 알았지만, 제가 이해할 수 있을 정도의 코드로 구성되어있다는게 놀라울 뿐 입니다. 아이디어가 정말 대단하고 이를 실제로 구현하여 많은 이용자들이 사용하고 있다는게 두 개발자분들이 대단하다고 느껴집니다.
새롭게 레거시코드를 정리하면서 서버사이드 상태는 react-query
, 클라이언트 사이드 상태는 zustand
로 관리하려고 코드를 작성하고 있었는데, 이번 기회에 동작원리에 대해 알게되어 잘 사용할 수 있게 되지 않을까 싶습니다.