본문 바로가기

자바스크립트

Zutand 라이브러리 파해치기

최근 리덕스에서 Zustand으로 관심이 옮겨가고있다.

 

우선 리덕스라는 라이브러리는 잘 알 것이다.

리액트를 사용하는 개발자라면 누구나 한 번씩은 리덕스를 경험해 봤을 것이다.

 

리덕스를 사용하는데는 여러 이유가 있겠지만 아마 많은 개발자들은 "상태관리"를 더 원활히 수행하기 위해 사용할 것이다.

그러나 리덕스를 사용해보면 알겠지만 어쩌면 배보다 배꼽이 더 크다고 느낄지도 모른다.

 

고작 전역상태 "하나"를 만드는데 액션,리듀서,스토어를 생성해야하고 추후에 추가될 경우에 대비해 이를 파일로 나누어 정리한다.

등등의 파일을 말이다.

 

그러다보면 자연스레 리덕스의 역할을 하면서 완전히 쉬운 라이브러리가 떠오르기 마련이다. 그것이 바로 zustand이다.

이 zustand는 출시된지 꽤 된 라이브러리이지만, 회사에서 리덕스를 사용함에 따라 리덕스만 사용해왔었다.

 

Zustand는 매우 간단하다.

 

그저 state와 increase등의 액셜을 create로 선언만 해주면 완료된다.

나는 간단한 todo-list를 만들며 zustand연습했고 자연스레 zustand의 매력에 빠지가 되었다.

그리고 zustand의 깃허브를 들여다보았다.

 

Test 코드

오픈소스를 잘 이해하기 위해선 우선 깃허브의 코드를 봐야한다. 인터넷에 존재하는 여러 블로그들이 잘 설명해놓은 사용 예제들만 봐도 해당 라이브러리를 사용하는데 전혀 지장이 없겠지만 나는 깃허브를 한 번 들여다보기로했다.

 

우선 테스트 파일이다.

 

테스트 파일은 여러개가 있지만 그 중 가장 "기본이다"라고 느껴지는 basic.test.tsx를 봐보자.

 

it('creates a store hook and api object', () => {
  let params
  const result = create((...args) => {
    params = args
    return { value: null }
  })
  expect({ params, result }).toMatchInlineSnapshot(`
    {
      "params": [
        [Function],
        [Function],
        {
          "destroy": [Function],
          "getInitialState": [Function],
          "getState": [Function],
          "setState": [Function],
          "subscribe": [Function],
        },
      ],
      "result": [Function],
    }
  `)
})

베이직 파일의 한 테스트 코드이다.

테스트의 이름만 봐도 어떠한 테스트를 진행하는데 유추할 수 있게 잘 정리를 해둔 느낌이다.

 

우선 zustand의 create함수로 스토어의 결과값을 테스트하는 것 같다.

 

"...arg"로 들어온 값은 setState가 있는 것으로 보아 create함수에서 제공하는 함수이고 이 함수가 제대로 제공되었는지, 즉 create로 스토어를 생성할 때 setState등의 create가 제공하는 함수가 잘 전달 되었는지를 확인한다.

it('only re-renders if selected state has changed', async () => {
  const useBoundStore = create<CounterState>((set) => ({
    count: 0,
    inc: () => set((state) => ({ count: state.count + 1 })),
  }))
  let counterRenderCount = 0
  let controlRenderCount = 0

  function Counter() {
    const count = useBoundStore((state) => state.count)
    counterRenderCount++
    return <div>count: {count}</div>
  }

  function Control() {
    const inc = useBoundStore((state) => state.inc)
    controlRenderCount++
    return <button onClick={inc}>button</button>
  }

  const { getByText, findByText } = render(
    <>
      <Counter />
      <Control />
    </>,
  )

  fireEvent.click(getByText('button'))

  await findByText('count: 1')

  expect(counterRenderCount).toBe(2)
  expect(controlRenderCount).toBe(1)
})

다음 테스트코드이다.

 

'only re-renders if selected state has changed'라는 글만 봐도 이 테스트코드이가 어떤 동작에 대한 테스트인지 유추해볼 수있다.

 

우선 리렌더링이 몇 번 일어났는지 확인하는 controlRenderCount와 counterRenderCount변수가 존재한다. 이 변수는 useState값이 아닌 일반 변수로 선언되어있어 각각 컴포넌트에서 리렌더링이 일어날 시 1씩 증가한다.

 

테스트는 간단하다. 렌더링 후 버튼을 누르면 스토어에 저장된 count값이 렌더링되는 Counter컴포넌트가 리렌더링된다. 때문에 counterRnederCount는 2가 된다.

마찬가지로 controlRenderCounter는 단 한 번만 렌더링이 되기 때문에 1이 된다.

 

테스트코드를 쭉쭉쭉 넘어가 다음 테스트코드를 보자

it('can update the equality checker', async () => {
  type State = { value: number }
  type Props = { equalityFn: (a: State, b: State) => boolean }
  const useBoundStore = createWithEqualityFn<State>(
    () => ({ value: 0 }),
    Object.is,
  )
  const { setState } = useBoundStore
  const selector = (s: State) => s

  let renderCount = 0
  function Component({ equalityFn }: Props) {
    const { value } = useBoundStore(selector, equalityFn)
    return (
      <div>
        renderCount: {++renderCount}, value: {value}
      </div>
    )
  }

  // Set an equality checker that always returns false to always re-render.
  const { findByText, rerender } = render(
    <>
      <Component equalityFn={() => false} />
    </>,
  )

  // This will cause a re-render due to the equality checker.
  act(() => setState({ value: 0 }))
  await findByText('renderCount: 2, value: 0')

  // Set an equality checker that always returns true to never re-render.
  rerender(
    <>
      <Component equalityFn={() => true} />
    </>,
  )

  // This will NOT cause a re-render due to the equality checker.
  act(() => setState({ value: 1 }))
  await findByText('renderCount: 3, value: 0')
})

우선 Component라는 컴포넌트를 확인해보자. 이 컴포넌트에서는 useBoundStore에 있는 값을 꺼내오는데, 해당 훅의 인자로 selector와 equilyFunc을 건내고 renderCount를 렌더링한다. selector와 equalityFunc이 무엇인지 모르니 생각하지 않고 코드를 쭉 보겠다.

 

해당 컴포넌트 다음에는 "Set an equality checker that always returns false to always re-render."이란 주석으로 대충 유추해볼 수가있다. 아, 이퀄리티 체커란 항상 false를 반환하는구나. 그리고 이것으로 리렌더링을 하는구나.

 

다음 줄에는 "This will cause a re-render due to the equality checker."이 있다. 

act(()=>....)의 구문은 이 이퀄리티 체커 때문에 리렌더링이 된다.(euqality checker에 대한 설명은 마지막 테스트코드에 설명을 적었다.)

따라서 리렌더링이 된 만큼의 숫자를 갖고있는 renderCount의 값을 확인한다.

그 다음 테스트 또한 마찬가지므로 건너뛰겠다.

it('can destroy the store', () => {
  const { destroy, getState, setState, subscribe } = create(() => ({
    value: 1,
  }))

  subscribe(() => {
    throw new Error('did not clear listener on destroy')
  })
  destroy()

  setState({ value: 2 })
  expect(getState().value).toEqual(2)
})

'can destroy the store'

스토어를 제거하는 테스트인것 같다. redux에서는 스토어를 제거해보지는 않았는데 zustand에서는 제거도 지원하나보다.

 

  const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
    listeners.add(listener)
    // Unsubscribe
    return () => listeners.delete(listener)
  }

subscribe함수는 다음과 같다. 아마 함수를 받아 함수를 등록하는 기능인 것같다.

다음으로는 destory로 스토어를 파괴한 후 값을 확인한다.

destory가 궁금해서 콘솔로그에 찍어봤다.

 

 const destroy = () => {
    if ((import.meta.env ? import.meta.env.MODE : void 0) !== "production") {
      console.warn(
        "[DEPRECATED] The `destroy` method will be unsupported in a future version. Instead use unsubscribe function returned by subscribe. Everything will be garbage-collected if store is garbage-collected."
      );
    }
    listeners.clear();
  };

프로덕션 빌드가 아니면 경고를 표시하고 다음과 같은 문장이 뜬다. "destory method will be. nsupported in.a future version"

금방 사장될 훅이라고 경고하고있다.

it("can call useBoundStore with progressively more arguments", async () => {
  type State = { value: number };
  type Props = {
    selector?: (state: State) => number;
    equalityFn?: (a: number, b: number) => boolean;
  };

  const useBoundStore = createWithEqualityFn<State>(
    () => ({ value: 0 }),
    Object.is
  );
  console.log(Object.is);
  const { setState } = useBoundStore;

  let renderCount = 0;
  function Component({ selector, equalityFn }: Props) {
    const value = useBoundStore(selector as any, equalityFn);
    // console.log(value);
    return (
      <div>
        renderCount: {++renderCount}, value: {JSON.stringify(value)}
      </div>
    );
  }

  // Render with no args.
  const { findByText, rerender } = render(
    <>
      <Component />
    </>
  );
  await findByText('renderCount: 1, value: {"value":0}');

  // Render with selector.
  rerender(
    <>
      <Component selector={(s) => s.value} />
    </>
  );
  await findByText("renderCount: 2, value: 0");

  // Render with selector and equality checker.
  rerender(
    <>
      <Component
        selector={(s) => s.value}
        equalityFn={(oldV, newV) => oldV > newV}
      />
    </>
  );

  // Should not cause a re-render because new value is less than previous.
  act(() => setState({ value: -1 }));
  await findByText("renderCount: 3, value: 0");

  act(() => setState({ value: 1 }));
  await findByText("renderCount: 4, value: 1");
});

마지막으로 볼 테스트코드이다.

 

이름에서 알 수 있듯이, 여러 인수를 받았을 때도 잘 동작하는지 확인하는 테스트이다.

눈여겨 봐야할 곳은 

// Should not cause a re-render because new value is less than....이다. (맨 밑에서 두 번째 act(()....); )

<Component/>라는 컴포넌트에 props를 두개넘긴다. 하나는 스토에 저장된 값이고 또 하나는 두 값을 받아 비교하는 함수이다.

이 두개의 인자를 다시 useBoundStore에 넘겨준다. 즉, old와 new를 비교해 그 값이 true라면 스토어를 업데이트해주지 않는다.

 

왜 euqualityCheckFn을 그리고, euqualityCheckFn은 무엇일까

 

바로 zustand의 shallow기능을 사용하기 위해서이다.

해당 기능을 간단히 소개하자면, 변경되지 않는 값은 리렌더링하지 않는 기능이다.

해당 기능을 이용하기 위해선

"create"가 아닌 "createWithEqualityFn"을 이용해야했다.

 

즉, <Compoent/>의 props로 넘겨진 equalityFn이 shallow기능을 위한 함수이고 해당 prop에 선언된 로직에 따라 렌더링/리렌더링을 결정하게 된다.

 

따라서, 위에 테스트코드에 작성된 "can update equality checker"는 shallow기능을 테스트하기 위한 코드이고, 바로 위 코드는 props와 함께 여러 인자들이 넘어왔을 때와 shallow 기능을 함께 테스트하는 코드이다.

 

이제 대충 테스트코드의 기본을 살펴보았으니 사용법을 간단히 보자

 

 

사용법

이미 많은 블로그에 게시된 zustand지만 사용법은 굉장히 간단하다.

export const 훅이름 = create((set) => ({
  스테이트_1: 1,
  스테이트_변경_함수: () =>
    set((s) => ({ ...s, 스테이트_1: s.스테이트_1 + 1 })),
}));

이것이 스토어이다. 

스토어에 저장될 state와 이를 변경하는 함수가 많아짐에 따라 파일을 또 분리할 수도 있겠지만 어쨌든 action파일을 생성하고 그래야하는 redux보다 훨씬 간단하다.

 

이 스테이트 값을 불러오는 것 또한 간단하다.

  const { 스테이트_1 } = 훅이름();

 

정말 간단하다...

 

 


이번에는 간단하게 zustand의 라이브러리를 소개하며 사용법을 알아봤다.

또한 간단하게지만 라이브러리를 어떻게 분석해야할지를 소개해봤다.

 

많은 개발자들이 라이브러리를 사용할 때 공식문서의 사용법 뿐만 아니라 해당 라이브러리의 테스트코드를 분석하는 것을 추천하고있다.

라이브러리의 테스트코드를 간단하게나마 눈으로 봐야하는 이유는 에러의 발생 요소/조건 등을 체크할 수 있고 그를 토대로 본인 또한 테스트코드를 작성하며 어떤 예외상황을 두어야할지 일종의 레거시를 한 눈에 확인할 수 있어서이다.

 

테스트코드를 작성하기 시작한지 얼마되지는 않았지만 css로 스타일을 주거나 js로 코드를 작성하는 것보다 테스트코드를 작성하는게 더 재밌게 느껴진다.