[React] 내가 Zustand를 사용하는 방법

배준형·2024년 1월 16일
16

서문

인재 채용 플랫폼 잡다의 웹 프론트엔드 개발을 하고 있는 배준형입니다. 잡다 서비스에서는 전역 스토어 라이브러리로 Mobx를 사용하고 있었는데요. 이를 Zustand로 전환하는 프로젝트를 진행했습니다.

Mobx에 비해 Zustand가 가지는 장점을 분석하였는데, 그 이유는 아래와 같습니다.

  1. Zustand는 보일러 플레이트가 필요 없고, store 생성이 더 간결함.
  2. 번들 사이즈가 Mobx 60kB(gZipped 17kB)에 비해 Zustand 3kB(gZipped 1kB)가 작음.
  3. Mobx의 Class 형태로 사용하는 것 대비 함수형으로 사용되므로 더 직관적이고 테스트에 용이.
  4. Zustand 훅을 사용하면 컴포넌트가 필요한 상태만 구독하므로 불필요한 렌더링 감소.

다른 이유가 있을 수 있고, Mobx가 더 나은 환경도 있겠지만, 저희 코드 수준에서는 전역 스토어를 단순 State 저장 정도로만 사용하고 있었기에 Zustand로 전환을 하기로 하였고, 직전 회사에서 Redux → Zustand로 전환해 본 경험이 있었기 때문에 주도적으로 전역 스토어 전환을 진행하였습니다.

그 과정에서 제가 Zustand를 사용한 방법에 대해 공유합니다. 당연히 더 좋은 방법이 있을 수 있으니 참고만 해주시면 감사하겠습니다.


Store 생성

Zustand Gitbub readme에 있는 내용을 가져왔습니다. zustand에서 제공하는 create 함수를 호출하여 store를 생성할 수 있습니다.

import { create } from 'zustand'

interface BearStoreType {
  bears: number;
  increasePopulation: () => void;
  removeAllBears: () => void;
}

export const useBearStore = create<BearStoreType>((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))
const bears = useBearStore((state) => state.bears);
const increasePopulation = useBearStore((state) => state.increasePopulation);

이러면 전역 Store 생성은 끝입니다. 추가적인 코드 없이 바로 import하여 사용하면 됩니다.

작성하는 것은 간단하지만 set 함수를 사용할 때는 기존의 값을 변경시키지 않는지 주의하여야 합니다. 공식 문서에 따르면 set 함수는 병합(Merge) 하는 대신 상태 모델을 대체(Replace) 한다고 하는데요. 저는 기존 State의 불변성을 유지하면서 좀 더 간결하게 Set 함수를 사용하기 위해 immer를 사용하였습니다.


zustand에서 immer middleware를 제공하고 있으므로 이를 그대로 사용하면 됩니다.

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer';

interface BearStoreType {
  bears: number;
  increasePopulation: () => void;
  removeAllBears: () => void;
}

const useBearStore = create<BearStoreType>()(
  immer((set) => ({
    bears: 0,
    increasePopulation: () => {
      set((state) => {
        state.bears += 1;
      });
    },
    removeAllBears: () => {
      set((state) => {
        state.bears = 0;
      });
    },
  })),
);

여기 코드에선 상태가 bears 밖에 없어서 차이를 체감할 수 없지만, 상태가 복잡하고 많을수록 코드도 복잡해집니다. 결국 Immer를 사용하는 것이 더 직관적으로 코드를 작성할 수 있을 것입니다.


Zustand는 작은 Store를 여러 개 만들어 사용하는 것을 권장하고 있고, 이에 따라 Store를 분리하여 사용하려고 합니다. 그런데, 만들 때마다 Immer 코드를 추가하는 것은 불편합니다. 그뿐만 아니라 middleware로 combine, devtools, immer, persist 등을 제공하고 있는데요. 이를 조합하여 사용하면 더 효과적으로 Store를 관리할 수 있지만, 매번 combine, devtools, immer, persist 등을 추가해야 합니다. 그래서 이 부분을 공용으로 빼 중복 코드를 줄여 보겠습니다.

store.ts

import { create, StateCreator } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

export const createStore = <T extends object>(
  initializer: StateCreator<T, [['zustand/devtools', never], ['zustand/immer', never]]>,
) => create<T, [['zustand/devtools', never], ['zustand/immer', never]]>(devtools(immer(initializer)));
  • devtools: store를 react-devtools와 통합하기 위해 사용합니다.
  • immer: 불변성을 유지하면서 더 쉽게 state를 업데이트할 수 있도록 합니다.

저는 devtools와 immer를 추가하였습니다. devtools를 사용하면 개발 환경 React Devtools의 State 탭에서 상태와 액션을 모니터링할 수 있습니다.

팀에서 실제 사용중인 devtools 적용된 Zustand Store 화면입니다.

이제 만들어 놓은 createStore 함수를 zustand create 함수를 사용하듯 작성하면 devtools, immer는 자동으로 적용됩니다. createStore 함수로 만든 Store를 사용하면 개발 환경에서 개발자 도구로 State, Action 등을 확인할 수 있습니다.

이 외에도 아래 2개의 middleware 도 있으니 상황에 따라 적절히 사용하면 될 것 같습니다.

  • combine: 여러 개의 슬라이스(slice) reducer를 합쳐서 store를 만드는 데 사용
  • persist: store 상태를 로컬스토리지에 지속적으로 저장

Store 사용

Store를 작성해 놓았다면 import 하여 사용하면 됩니다.

import { useBearStore } from '@store/useBearStore';

const bears = useBearStore((state) => state.bears);

다만, 주의할 점이 있습니다. bears를 selector 형태 ((state) => state.bears)로 가져올 수도 있지만 아래처럼 매개변수 없이 호출할 수도 있는데요. 이 경우 전체 스토어를 가져오기 때문에 동일하게 값에 접근할 수 있습니다. 이 경우엔 필요한 것만 구조분해할당하여 사용하면 되겠죠.

const { bears } = useBearStore();

언뜻 보면 이렇게 사용하는 것이 더 편하고 쉬워 보입니다. 그런데, Selector 없이 호출하여 사용하는 경우 bears뿐만 아니라 store 전체를 구독하게 됩니다. 결국 bears 값 외의 state가 변경되어도 상태가 업데이트되므로 의도하지 않은 리렌더링이 발생합니다.

이를 해결하기 위해 selector를 직접 작성하여 Store에 접근하는 것이 권장됩니다.


// Good 👍
const bears = useBearStore((state) => state.bears);
const increasePopulation = useBearStore((state) => state.increasePopulation);
const removeAllBears = useBearStore((state) => state.removeAllBears);

// Bad 👎
const { bears, increasePopulation, removeAllBears } = useBearStore();

// Bad 👎
const { bears, increasePopulation, removeAllBears } = useBearStore((state) => ({
  bears: state.bears,
  increasePopulation: state.increasePopulation,
  removeAllBears: state.removeAllBears,
}));

공식 문서에선 atomic selector를 선호한다고 되어 있습니다. 그렇다고 해서 한 번에 여러 개의 State를 불러오는 것이 불가능한 것이 아닌데요. zustand에서 제공하는 shallow, useShallow 등을 사용하여 얕은 비교를 사용하도록 조정할 수는 있습니다.

// Bad 👎
const { bears, increasePopulation, removeAllBears } = useBearStore();

// Good 👍
import { shallow } from 'zustand/shallow';

const { bears, increasePopulation, removeAllBears } = useBearStore(
  (state) => ({
    bears: state.bears,
    increasePopulation: state.increasePopulation,
    removeAllBears: state.removeAllBears,
  }),
  shallow,
);

// Good 👍
import { useShallow } from 'zustand/react/shallow';

const { bears, increasePopulation, removeAllBears } = useBearStore(useShallow((state) => state));

결국, 사용하는 곳에서 selector를 사용하거나 shallow, useShallow 등을 import 하여 적절히 활용하면 됩니다. 가장 안 좋은 상황은 이러한 내용을 모른 채 useBearStore(); 형태로 사용하여 전체 상태를 구독하는 형태일 것입니다. 이 부분을 방지하기 위해 타입을 지정하여 selector를 항상 활용할 수 있도록 Hook을 만들어 보겠습니다.


항상 Selector를 사용하는 zustand store hook 만들기

types.ts

/**
 * @template S zustand store 상태
 * @template K selector가 없는 경우 default로 사용할 key
 */
export type SelectorHook<S, K extends keyof S> = {
  <U>(selector: (state: S) => U): U;
  (): S[K];
};
  • SelectorHook은 함수 타입을 지정합니다.
  • 매개변수가 있다면 Selector 처럼 활용하고, 매개변수가 없다면 제네릭 S 타입의 key-value 중 하나의 value 값을 반환하도록 설정합니다.

이렇게 타입을 지정하는 이유는 매개변수 없이 Zustand Store를 호출하면 전체 상태를 구독하는 것이 아닌 미리 지정한 State를 반환하도록 하여 실수를 줄이고, 매개변수를 넣는다면 Selector 타입을 지정해 주어서 Typescript의 자동완성 기능도 활용할 수 있게 하기 위함입니다.


useBearStore.ts

interface BearStoreType {
  bears: number;
  increasePopulation: () => void;
  removeAllBears: () => void;
}

// 이 Store는 export 하지 않습니다.
const useBearStore = createStore<BearStoreType>((set) => ({
  bears: 0,
  increasePopulation: () => {
    set((state) => {
      state.bears += 1;
    });
  },
  removeAllBears: () => {
    set((state) => {
      state.bears = 0;
    });
  },
}));

// 여기서 새로운 Hook을 만들고, SelectorHook 타입으로 지정해줍니다.
export const useBear: SelectorHook<BearStoreType, 'bears'> = (selector = (state: BearStoreType) => state.bears) =>
  useBearStore(selector);

SelectorHook 타입을 만들어서 useBear hook의 타입으로 지정해 줍니다. 이렇게 작성하면 useBear(), useBear((state) => state.increasePopulation) 형태를 모두 사용할 수 있게 됩니다. useBear() 형태로 사용하더라도 전체 상태를 구독하여 가져오는 것이 아니라 미리 지정한 값을 반환하고, 그 외에는 selector에 따라 값을 가져오도록 했습니다.

const bears = useBear();
const increasePopulation = useBear((state) => state.increasePopulation);

이렇게 사용한다면 useBearStore()를 전체 구독하는 일이 발생하지 않고, 타입을 적절히 부여했으므로 자동완성 기능도 정상적으로 이용할 수 있습니다.


Class 컴포넌트에서 zustand 사용

zustand는 사용 방법이 간단한 만큼 store를 생성한 다음 별도의 옵션 없이 그대로 사용하면 되는데, 간혹 React Class Component 내부에서 전역 스토어를 사용할 일이 생깁니다. 공식 문서에 따라 useBearStore.getState() 방식이라든지, zustand/vanilla에서 export 하고 있는 create를 사용하여 React Fucntion Component 외부에서 사용할 수 있는데요. useBearStore.getState()는 보통 테스트 환경에서 쓰이고, zustand/vanillacreate 함수는 기존 create 함수와 사용 방식이 달라서 불편합니다.

저는 이런 상황에서 HoC(Higher order Component, 고차 컴포넌트) 방식을 활용했습니다.

export const withBearStore =
  <P,>(WrappedComponent: React.ComponentType<P>) =>
  (props: P) => {
    const bears = useBear();
    return <WrappedComponent {...props} bears={bears} />;
  };
import { withBearStore } from '@store/useBearStore';

class SomeComponent extends React.Component {
  render() {
    return <h1>{this.props.bears}</h1>;
  }
}

export default withBearStore(SomeComponent); // 여기서 감싸줍니다.

HoC는 컴포넌트를 반환하는 컴포넌트이고, 이 내부에서 중복 코드, 연산 등을 처리할 수 있습니다. 여기선 bears 값을 props로 내려주는 역할을 수행하겠네요.

HoC를 이용해 중복 코드를 줄이는 것에 대한 글을 작성했었는데, 참고하면 좋을 것 같습니다.

Higher-order-Component로 중복 코드 줄이기


Action은 묶어서 한 번에 구독하기

직전에 useBearStore()처럼 사용하면 모든 State가 구독되어서 불필요한 리렌더링이 발생한다고 했습니다. 그런데 State 값 외에 Action은 Store의 상태를 업데이트하는 함수로 한 번 정의해 놓으면 변경될 일이 없습니다. 따라서 state 외의 변경되지 않는 상태인 Action들을 하나로 묶은 후 구독하여 사용해도 성능 상 문제가 없을 겁니다.

interface BearStoreType {
  bears: number;

  actions: {
    increasePopulation: () => void;
    removeAllBears: () => void;
  };
}

const useBearStore = createStore<BearStoreType>((set) => ({
  bears: 0,

  actions: {
    increasePopulation: () => {
      set((state) => {
        state.bears += 1;
      });
    },
    removeAllBears: () => {
      set((state) => {
        state.bears = 0;
      });
    },
  },
}));

export const useBearActions = () => useBearStore((state) => state.actions);
const { increasePopulation } = useBearActions();

Action을 따로 묶어서 export하는 경우 자동완성 기능을 활용하여 더 쉽게 작업할 수 있고, 코드 수도 줄어들기 때문에 Action들만 따로 묶어서 전체 구독하여 사용하는 것은 좋은 방법이라고 생각합니다.


정리

저는 Zustand를 위와 같이 사용합니다. 더 좋은 방법, 더 효율적이거나 더 많은 기능을 활용할 수 있는 방법이 있을 수 있지만 아직 내공이 모자라 여기까지만 활용 중입니다. 앞으로 Zustand는 계속 사용하게 될 라이브러리라 사용함에 따라 더 좋은 방법을 생각해 낸다면 정리하여 블로그에 작성, 업데이트 해보려 합니다.

2023년 우아콘에서 프론트엔드 상태 관리 실전 편에서 React Query & Zustand에 대한 세션이 있었는데, 점점 사용하는 프로젝트도 많아지고 유명해지는 것 같습니다.

2023년 JavaScript Rising Stars(23년 한 해 동안 Github Star를 받은 Repository 랭킹)에서 State Management 쪽은 Zustand가 꽤 차이 나게 1등 한 모습을 확인할 수 있습니다.

저도 개인적으로 Zustand가 간결하고 사용하기 편하다고 생각하는데, 거기에 번들 사이즈도 다른 라이브러리보다 작다는 점이 매력적으로 느껴지는 것 같습니다. 다만, 프론트엔드 분야는 기술 트렌드가 빠르게 변화하는 측에 속하기에 Zustand만 볼 것이 아니라 다양한 라이브러리들을 참고할 필요는 있을 것 같습니다.


참조

profile
프론트엔드 개발자 배준형입니다.

1개의 댓글

comment-user-thumbnail
2024년 8월 20일

깔끔한 정리 감사합니다 :)

답글 달기