ContextAPI에서 Zustand로 갈아타기!

9rganizedChaos·2023년 5월 4일
1
post-thumbnail

기존 회사 프로젝트에서 ContextAPI를 통해 전역 상태를 관리해왔었는데, Zustand를 새로 도입하기로 결정하였습니다. ContextAPI를 사용하던 데에는 특별한 이유가 있지는 않았고, 기존에 팀에서 사용해오던 방식이라 별 다른 이의 제기 없이 쭉 사용해오던 차였습니다. 이번에 홈페이지에 계정을 도입하는 큰 작업을 앞두고, 좀 더 사용이 간편하고, 렌더링에 최적화된 상태관리 라이브러리를 도입해보면 좋겠다고 생각했고, Zustand 도입을 결정하였습니다.

ContextAPI와 Zustand의 가장 큰 차이

리팩토링을 진행하며, 제가 느낀 가장 큰 차이라하면, 모쪼록 Context는 컴포넌트 형태로 생성이 된다는 점이었습니다. Context 내부에서 useEffect와 같은 훅들을 자유롭게 활용할 수 있다는 점 말입니다.

가장 먼저 우선 ContextAPI에서 useState로 작성해두었던 부분을 Store로 옮겨보았습니다.

왼쪽이 ContextAPI, 오른쪽이 Zustand입니다.
다 옮긴 후 고민이 들었던 점은, 그렇다면 이제 프로바이더 내부에 작성해둔 숱한 함수와 useEffect는 어디로 옮겨야 하나, 하는 점이었습니다. 처음에는 일일이 store 내부에 store.setState를 해주는 함수를 만들어주어야 하나 고민했었습니다. (+ subscribe를 통해 스토어 내 상태변화에 대응)

그렇게 시도하다보니 생각보다 로직이 복잡해져서, 우선 기존에 프로바이더 내부에 작성해둔 함수와 useEffect를 가능한 관련 컴포넌트 내부로 모두 옮기고, 전역에서 꼭 필요한 함수만 남겼습니다.

꼭 전역에 필요했던 함수는 이 정도!

export const handleCloseModal = () =>
  useB2BInquiryStore.setState(() => ({
    formType: null,
    isFormModalOpen: false,
    isBtnClickedWithNoResponse: false,
    isThankYouNoteOn: false
  }));

export const handleFormButtonClick = (formType: InquiryFormType, course?: B2BCurriculumCourses) => {
  if (course) {
    useB2BInquiryStore.setState(() => ({
      activeCourse: course
    }));
  }
  useB2BInquiryStore.setState(() => ({
    formType
  }));
};

결국 368줄이었던 ContextAPI Context파일이, 130줄의 Store코드로 변경되었습니다.
ContextAPI를 활용하면서, 제가 불필요하게 많은 부분을, 로직을 한 곳에 모으겠다는 명목하에, 전역에서 관리하고 있었다는 사실을 깨달았습니다.

Zustand Devtools

Zustand는 Redux Devtools를 활용한다고 합니다.

위 확장프로그램을 설치한 후에, 코드를 아래와 같이 작성해주면 devtool을 확인할 수 있습니다.
persist와 같은 미들웨어도 같은 방식으로 적용합니다!

import { devtools } from 'zustand/middleware';

export const useB2BInquiryStore = create<B2BInquiryStoreType>()(
  devtools(
    set => ({
      // Store에서 관리하는 상태들
    }),
    { name: 'b2b-inquiry-storage' }
  )
);

리덕스 데브툴은 아래와 같은 모습입니다.

Zustand 이용해서 RTL 테스트코드 짜기!

ContextAPI를 Zustand로 리팩토링하고, 기존에 짜두었던 테스트코드를 작동시키니 에러가 발생하기 시작했습니다. 주요한 원인은 개별 테스트가 돌아갈 때, Zustand Store가 리셋되지 않는다는 점이었습니다.

Zustand Jest라고만 구글링을 해도 공식문서에서 아래와 같은 내용을 확인할 수 있습니다.

저와 같이 코드만 보고, 어디에 해당 코드를 작성해야할지 헤맬 분들을 위해, 조금 덧붙이면, 위 코드를 프로젝트 루트 test/__mocks__/zustand.ts에 작성하시면 됩니다. 하지만 저의 경우에는 그렇게 작성했음에도 불구하고 아래와 같은 에러를 마주할 수 있었는데요.

TypeError: store.getState is not a function

결국 공식문서에서 제공하는 방법을 포기하고, 테스트 파일 상단에서 직접 개별 테스트가 돌아가기 전에 Store를 리셋해주는 방식을 택하였습니다.

const initialStoreState = useB2BInquiryStore.getState();
beforeEach(() => {
  useB2BInquiryStore.setState(initialStoreState, true);
});

그런데 위와 같이 작성해두고도, 공식문서에서 제공하는 솔루션이 왜 제 환경에서는 돌아가지 않는 것인지 미련을 버리지 못 하고 좀 더 해결책을 찾아본 결과, 동일한 에러를 겪는 StackOverflow 글을 찾아볼 수 있었습니다.

https://github.com/pmndrs/zustand/issues/1059

import { act } from '@testing-library/react';
import { create as createType, StateCreator } from 'zustand';

const zustand = jest.requireActual('zustand');
const actualCreate: typeof createType = zustand.create;

// 앱에 선언된 모든 스토어의 초기화 함수가 할당된 변수
const storeResetFns = new Set<() => void>();

// 스토어를 생성할 때, initialState를 취해서, 초기화 함수를 생성한 뒤, 이를 위에 선언된 store에 담아줍니다.
const createImpl = <S>(createState: StateCreator<S>) => {
  const store = actualCreate<S>(createState);
  const initialState = store.getState();
  storeResetFns.add(() => store.setState(initialState, true));
  return store;
};

// 커링 지원
export function create<S>(f: StateCreator<S>) {
  return f === undefined ? createImpl : createImpl(f);
}

// 테스트 실행 전 모든 스토어 초기화
beforeEach(() => {
  act(() => storeResetFns.forEach(resetFn => resetFn()));
});

위 코드 대로 작성해주니, 아래와 같이 테스트가 잘 동작하는 것을 확인할 수 있었습니다.

위 코드는 공식문서에 따르면, 테스트 코드 동작시 따로 store를 모킹해주는 역할을 한다고 합니다. 아직 의문인 점은 과연 위 코드를 어디서 연결해주고 있느냐는 점입니다. 파일의 경로를 test/mocks/zustand.ts와 같이 변경했을 때 테스트가 동작하지 않는 것으로 봐서는, zustand 내부에서 해당 경로에 파일이 있으면, 테스트 시 해당 파일에 있는 create함수로 store를 생성하고 있는 것일 거라 추측해봅니다. (확인해보니 맞네요...)

profile
부정확한 정보나 잘못된 정보는 댓글로 알려주시면 빠르게 수정토록 하겠습니다, 감사합니다!

1개의 댓글

comment-user-thumbnail
2023년 9월 6일

면접준비하다가 정호님 글이 있어서 한땀한땀 읽어보았습니다 ㅋㅋㅋ zustand로 rtl 사용은 한번도 안해봤는데 정호님덕분에 앞으로 겪을 시행착오를 패스하게되었네요!! ㅎㅎ 저에게 큰 도움이 되는 글 써주셔서 감사합니다

답글 달기