공식 문서를 돌같이 보는 버릇을 고치자!
그동안 내가 한 프로젝트는 상태관리 라이브러리를 사용하지 않았다. React가 제공하는 Context API
와 useReducer
를 조합하면 충분히 전역 상태관리가 되었기 때문이다. 규모가 매우매우 작기에 가능한 부분이었다. 하지만 언제까지고 모른 채로 지낼 수는 없었다. 게다가 지난 번에 Redux Toolkit
을 사용해 보면서 라이브러리의 편리함을 절감했다. 해서 이참에 다양한 상태관리 라이브러리를 체험해 보고자 했다.
RTK
에 이은 라이브러리는 Zustand
이다. 대표적인 상태관리 라이브러리들 중 weekly downloads 3위이고, 개인적으로 채용 공고에서도 많이 봐왔기 때문이다. 문서도 깔끔하게 정리되어 있고, 러닝 커브도 낮은 게 마음에 들었다.
상태관리 라이브러리 순위
https://npmtrends.com/@reduxjs/toolkit-vs-jotai-vs-mobx-vs-recoil-vs-redux-vs-zustand
Zustand
는 상태
이라는 뜻의 독일어이다. 공식 문서에 따르면, 작고 빠르며 확장 가능한 베어본 상태 관리 솔루션이다. Redux
와 크게 차이나진 않지만, provider
가 필요 없다는 점과 action
이 없어도 상태 변경이 가능하다는 점이 다르다. 나머지는 마찬가지로 store
를 생성하고, selector
를 이용해 상태를 호출한다. Comparison
대표적으로 권장하는 패턴은 하나의 store
를 두고, set/setState
를 사용하여 상태를 변경하는 것이다. 혹은 Redux
에 익숙하다면 그와 비슷한 패턴으로 만들어 사용할 수도 있다. Flux inspired practice
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를 작성했다. 여기서 set
은 zustand
가 상태를 변경하는 함수이다.
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
취지에 맞는 건가? 그런 생각을 하면서 다른 서비스의 코드도 작성했으나, 예상대로 엄청 장황해지는 것을 느꼈다. 코드가 길어짐에 따라 느낌은 확신이 되어서 방식을 바꾸기로 했다.
익숙한 방식에서 벗어나 공식 문서가 추천하는 방식을 따랐다. 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에서 immer
와 devtools
미들웨어를 사용한다. StateCreator
는 다른 slice 타입과 합쳐진 유니온 타입과 해당 slice 타입을 받는데, 다른 slice 타입이 없는 경우 해당 slice 타입만 사용하도록 설정했다. 다른 slice 타입을 주입하면 유니온 타입이 된다.
import { devtools } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
const UserBoundStore = create<UserSlice>()(
devtools(immer((...a) => ({ ...createUserSlice(...a) })))
);
slice를 병합하는 store를 생성했다. 여기서 실수를 발견했다. boundStore
는 여러 slice를 병합하는 곳이다. 나는 User
와 Main Service
의 store를 나눴다. 문제는 없지만, 디버깅이 힘들더라. devtools
는 크롬 확장 프로그램인 Redux Devtools
를 이용해 상태를 추적할 수 있게 해주는데, store가 다르면 상태가 호출되었을 때 해당 store만 추적된다.
예를 들어, 나처럼 userStore
가 있고, mainStore
가 있다면, user state를 호출했을 때는 username
만 추적되고, main state 호출로 변경되면 main
만 추적되는 식이다. 개발하고 있을 때는 이 생각이 왜 안 났나 몰라. 원활한 디버깅을 위해서라면 유일한 진실의 원천
원칙을 잘 지켜야겠다.
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.
이것은 임시 객체를 수정하는 행동과 새 객체를 반환하는 행동을 동시에 했음을 의미한다. 위 두 행동을 동시에 하는 것은 유효하지 않으며, 새 객체를 반환 하거나 임시 객체 수정, 둘 중 하나만 허용된다. 여기서는 임시 객체를 수정했다.
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
main도 이렇게 수정했지만, 정리하면서 store 구성에 문제가 있었음을 인지했으니 작성을 마치는 즉시 수정해야겠다. 프로그램 동작에 문제는 없지만 찝찝하니까.
Context API + useReducer
대신 Zustand
를 써 보면서 좋았던 점은
reducer
를 개별 함수로 줄임Context Provider
를 지움반면, 불편했던 점은 비동기 호출이 RTK
처럼 직관적이지 않았다는 점이다. 공식 문서에서는 보기 어려워 검색을 좀 해봤는데, 대부분 다른 라이브러리와 함께 사용하더라. 비동기 패칭과 관련해서 좀 더 시도해 봐야겠다.
위에서 언급했던 실수를 수정했다. 나눠진 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"
등으로 구분할 수 있을 듯하다.