[Zustand] 공식 문서만 보고 Zustand 적용해 보기

찐새·2023년 10월 30일
3

공식 문서만 보고

목록 보기
3/9
post-thumbnail

공식 문서를 돌같이 보는 버릇을 고치자!

0. 개요

그동안 내가 한 프로젝트는 상태관리 라이브러리를 사용하지 않았다. React가 제공하는 Context APIuseReducer를 조합하면 충분히 전역 상태관리가 되었기 때문이다. 규모가 매우매우 작기에 가능한 부분이었다. 하지만 언제까지고 모른 채로 지낼 수는 없었다. 게다가 지난 번에 Redux Toolkit을 사용해 보면서 라이브러리의 편리함을 절감했다. 해서 이참에 다양한 상태관리 라이브러리를 체험해 보고자 했다.

RTK에 이은 라이브러리는 Zustand이다. 대표적인 상태관리 라이브러리들 중 weekly downloads 3위이고, 개인적으로 채용 공고에서도 많이 봐왔기 때문이다. 문서도 깔끔하게 정리되어 있고, 러닝 커브도 낮은 게 마음에 들었다.


상태관리 라이브러리 순위
https://npmtrends.com/@reduxjs/toolkit-vs-jotai-vs-mobx-vs-recoil-vs-redux-vs-zustand

1. Zustand

Zustand상태이라는 뜻의 독일어이다. 공식 문서에 따르면, 작고 빠르며 확장 가능한 베어본 상태 관리 솔루션이다. Redux와 크게 차이나진 않지만, provider가 필요 없다는 점과 action이 없어도 상태 변경이 가능하다는 점이 다르다. 나머지는 마찬가지로 store를 생성하고, selector를 이용해 상태를 호출한다. Comparison

대표적으로 권장하는 패턴은 하나의 store를 두고, set/setState를 사용하여 상태를 변경하는 것이다. 혹은 Redux에 익숙하다면 그와 비슷한 패턴으로 만들어 사용할 수도 있다. Flux inspired practice

2. Create Store

TypeScript 환경에서 사용하므로 곧장 TypeScript Guide로 진입했다. state의 타입을 정의하고 create의 제네릭에 주입한다. 주의할 점은 create<T>()가 아니라 create<T>()()라는 점이다. 이렇게 커링(currying)으로 작성하는 이유는 microsoft/TypeScript#10571와 관련이 있다. 요약해 보면, <T, S> 제네릭을 설정한 경우 T만 주입하면 에러가 발생한다. 불필요하더라도 선언한 제네릭은 적어야 한다. create<T>()()는 이러한 문제를 해결하는 방법이다.

const userStore = create<UserState & UserDispatch>()(
  (set) => ({
    username: "",
    dispatch: (action) => set((state) => dispatchReducer(state, action)),
  })
);

이전에 useReducer를 사용했기에 initial state와 reducer를 사용한 패턴으로 store를 작성했다. 여기서 setzustand가 상태를 변경하는 함수이다.

export enum UserActionTypes {
  SET_USER = "SET_USER",
}

const dispatchReducer = (state: UserState, action: UserAction): UserState => {
  switch (action.type) {
    case UserActionTypes.SET_USER: {
      return { ...state, username: action.payload.username };
    }
    default: {
      throw new Error("Can not find action's type");
    }
  }
};

redecer를 작성하면서도 의구심이 들었다. state 단 하나 변경하는 것도 이렇게 장황한데 조금만 더 복잡해지면 어떨까? 이전에 사용했던 방법(context api + useReducer)과 무슨 차이가 있나? 보일러플레이트가 많지 않다는 zustand 취지에 맞는 건가? 그런 생각을 하면서 다른 서비스의 코드도 작성했으나, 예상대로 엄청 장황해지는 것을 느꼈다. 코드가 길어짐에 따라 느낌은 확신이 되어서 방식을 바꾸기로 했다.

3. Slice Pattern

익숙한 방식에서 벗어나 공식 문서가 추천하는 방식을 따랐다. RTK에서 봤던 것과 비슷했는데 하나의 store를 두고 상태마다 Slice를 만들어 병합하는 식이다. 물론 이러한 사실을 안 것은 프로젝트 빌드 이후의 일...(작성 후 수정해야지) Slices Pattern

const createUserSlice: SlicePattern<UserSlice> = (set) => ({
  username: "",
  setUsername: (payload) =>
    set((state) => {
      state.username = payload;
    }),
});

일단 user에 대한 slice를 만들었다. 타입의 경우, 중복되는 코드가 있어 커스텀으로 재정의했다. 공식 문서에서 타입에 대해 참고했다. TypeScript Guide - Slices pattern

import { StateCreator } from "zustand";

declare module "zustand" {
  type SlicePattern<T, S = T> = StateCreator<
    S & T,
    [["zustand/immer", never], ["zustand/devtools", never]],
    [],
    T
  >;
}

모든 slice에서 immerdevtools 미들웨어를 사용한다. StateCreator는 다른 slice 타입과 합쳐진 유니온 타입과 해당 slice 타입을 받는데, 다른 slice 타입이 없는 경우 해당 slice 타입만 사용하도록 설정했다. 다른 slice 타입을 주입하면 유니온 타입이 된다.

4. with Middleware

4-1. devtools

import { devtools } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";

const UserBoundStore = create<UserSlice>()(
  devtools(immer((...a) => ({ ...createUserSlice(...a) })))
);

slice를 병합하는 store를 생성했다. 여기서 실수를 발견했다. boundStore는 여러 slice를 병합하는 곳이다. 나는 UserMain Service의 store를 나눴다. 문제는 없지만, 디버깅이 힘들더라. devtools는 크롬 확장 프로그램인 Redux Devtools를 이용해 상태를 추적할 수 있게 해주는데, store가 다르면 상태가 호출되었을 때 해당 store만 추적된다.

예를 들어, 나처럼 userStore가 있고, mainStore가 있다면, user state를 호출했을 때는 username만 추적되고, main state 호출로 변경되면 main만 추적되는 식이다. 개발하고 있을 때는 이 생각이 왜 안 났나 몰라. 원활한 디버깅을 위해서라면 유일한 진실의 원천 원칙을 잘 지켜야겠다.

4-2. immer

immer를 사용하려면 별도로 설치해 종속성을 주입해야 한다. Immer middleware

npm install immer

immer 미들웨어를 사용하면 state 변경이 더 쉬워진다. immer가 상태의 불변성을 보장하는 만큼 새로운 상태를 갈아끼울 필요 없이 재할당하는 느낌으로 변경 가능하다.

setUsername: (payload) => set((state) => { state.username = payload; })

주의할 점은 immer 원칙을 지켜야 한다는 것이다. 그렇지 않으면 다음과 같은 에러가 발생한다.

[Immer] An immer producer returned a new value *and* modified its draft.
Either return a new value *or* modify the draft.

이것은 임시 객체를 수정하는 행동과 새 객체를 반환하는 행동을 동시에 했음을 의미한다. 위 두 행동을 동시에 하는 것은 유효하지 않으며, 새 객체를 반환 하거나 임시 객체 수정, 둘 중 하나만 허용된다. 여기서는 임시 객체를 수정했다.

5. Selector

state를 사용하려면 store에서 해당 state를 꺼내면 된다. Updating state

const firstName = usePersonStore((state) => state.firstName)

// of

const firstName = usePersonStore().firstName

기본적인 사용 방법이지만, 나는 좀 더 구분하기 위해 상태를 호출하는 훅과 변경 함수를 호출하는 훅을 따로 만들었다.

export const useUserState = () => UserBoundStore((state) => state.username);

export const useUserDispatch = () =>
  UserBoundStore((state) => state.setUsername);

useUserState는 상태만 반환하고, useUserDispatch는 변경 함수만 반환한다. 자동으로 셀렉터를 만들어주는 방법도 있고, 라이브러리도 있다고 한다. Auto Generating Selectors

6. 느낀점

main도 이렇게 수정했지만, 정리하면서 store 구성에 문제가 있었음을 인지했으니 작성을 마치는 즉시 수정해야겠다. 프로그램 동작에 문제는 없지만 찝찝하니까.

Context API + useReducer 대신 Zustand를 써 보면서 좋았던 점은

  1. 장황하게 적어 내려가던 reducer를 개별 함수로 줄임
  2. 불변성을 고려하며 새 객체를 일일이 만들지 않아도 됨
  3. 전역 상태 생성마다 필요한 곳을 감싸던 Context Provider를 지움
  4. 공식 문서가 깔끔하면서도 자세해서 빠르게 익힐 수 있음

반면, 불편했던 점은 비동기 호출이 RTK처럼 직관적이지 않았다는 점이다. 공식 문서에서는 보기 어려워 검색을 좀 해봤는데, 대부분 다른 라이브러리와 함께 사용하더라. 비동기 패칭과 관련해서 좀 더 시도해 봐야겠다.

7. Bound Store & Set Action Name

위에서 언급했던 실수를 수정했다. 나눠진 store를 하나로 합쳤고, devtools 미들웨어를 원활히 사용하기 위해 slice의 액션명을 설정했다.

const BoundStore = create<BoundSlice>()(
  devtools(
    immer((...a) => ({
      ...createUserSlice(...a),
      ...createBookInfoSlice(...a),
      ...createBookcaseSlice(...a),
    })),
    { name: "bip-bound-store" }
  )
);

type BoundSlice = UserSlice & BookcaseSlice & BookInfoSlice;

export default BoundStore;

각각의 slice에서는 devtools에서 액션을 쉽게 확인할 수 있도록 set의 이름을 지정했다. zustand/readme - Redux devtools

enum UserActions {
  SET_USERNAME = "user/SetUsername",
}

const createUserSlice: SlicePattern<UserSlice> = (set) => ({
  username: "",
  setUsername: (payload) =>
    set(
      (state) => {
        state.username = payload;
      },
      false,
      UserActions.SET_USERNAME // action name
    ),
});

이전에는 Redux Devtools에서 액션이 anonymous로 표기되었으나 이제는 각각 변경에 맞는 액션명이 보인다.

만약 production 단계에서는 devtools를 숨기고 싶다면 devtools option 중 enable을 설정한다.

const BoundStore = create<BoundSlice>()(
  devtools(
    immer((...a) => ({
      ...createUserSlice(...a),
      ...createBookInfoSlice(...a),
      ...createBookcaseSlice(...a),
    })),
    { name: "bip-bound-store", enabled: !!import.meta.env.DEV }
  )
);

vite 환경에서 개발했기 때문에 import.meta.env.DEV로 개발 환경을 구분했다. 일반 node 환경이라면 process.env.NODE === "production" 등으로 구분할 수 있을 듯하다.

profile
프론트엔드 개발자가 되고 싶다

0개의 댓글