React 상태 관리 라이브러리 Zustand의 코드를 파헤쳐보자


들어가며

TOAST UI Calendar는 새로운 메이저 버전을 개발하며 다양한 기술 전환을 시도하고 있다. 그 중 하나가 Preact를 도입하는 것이다.

Preact를 도입하며 얻을 수 있는 이점 중 하나는 React와 같은 느낌으로 Hooks API(이하 훅)를 사용할 수 있다는 점이다. 훅을 활용하여 UI 상태 로직을 효과적으로 정의하고, 조합하여 활용할 수 있게 되었다.

아쉽게도 새 버전 개발 초기에 컨벤션이 완벽하게 정리되지 않은 상태로 작업이 진행되었다. 따라서 초기에 구현했던 스토어 구조에 개선하고 싶었던 점들이 눈에 띄기 시작했다.

  1. 하나의 통합된 스토어를 활용하면서 컴포넌트가 상태를 부분적으로 가져다 쓰기 어려웠다. 큰 단위로 상태를 선택할 수 있었지만, 상태 트리의 업데이트가 일어나면 실제 업데이트가 일어나지 않은 부분을 수신하고 있는 컴포넌트도 리랜더링(re-rendering)이 일어났다.
  2. Typescript를 사용하면서도 상태를 업데이트하는 동작의 인자 타입을 제대로 보장하지 못하고 있었다.
  3. Flux 아키텍쳐를 활용할 수 있도록 스토어를 구성하고, 각각의 뷰와 로직을 개발하는 개발자들이 최대한 컨벤션만 따라가면 쉽게 스토어를 활용할 수 있도록 개선하고 싶었다.

그래서 캘린더라는 애플리케이션의 상태를 효과적으로 다루는 상태 관리 도구를 새로 만들어보기로 했다. '될 수 있으면 외부 라이브러리를 설치하여 사용하지 않고 가능한 한 가볍게 해결책을 구현해 보자'는 목표를 정했고, 참고할 만한 오픈 소스 프로젝트를 찾기 위해 아래의 조건을 정했다.

  • 동작 원리가 쉽게 이해될 수 있는 것이면 좋겠다. 모듈을 가볍게 유지하고, 유지보수를 하기 쉽게 만들기 위해서이다.
  • 작은 규모의 상태를 구축하는 것부터 큰 규모의 상태를 구축하는 것까지 충분한 확장성을 가지고 있었으면 좋겠다. 개발이 진행되면서 상태 구조가 얼마나 복잡해질지 담보하기 어렵기 때문이다.
  • Redux의 셀렉터나 MobX의 computed 값 처럼 파생 상태를 효과적으로 관리할 수 있으면 좋겠다.
  • Preact에 적용할 수 있어야 하고, 만약에 Preact가 아닌 다른 뷰 레이어를 사용하는 결정을 하더라도 큰 문제가 없도록 React 의존성이 적거나 없었으면 좋겠다. 특히 React 의존성 때문에 preact/compat 패키지를 사용하느라 번들 사이즈가 늘어나는 것을 억제하고 싶다.

그렇게 탐색의 과정을 거쳐 분석해보기로 한 프로젝트가 Zustand였다.

이 글은 Zustand라는 라이브러리의 소스 코드를 직접 분석 하면서 어떤 패턴과 트릭을 사용하여 구현되어 있는지 파고들며, 이를 통해 애플리케이션에서 사용할 수 있는 상태 관리 라이브러리를 직접 구현해보려는 사람들에게 도움이 되는 내용을 제공하고자 한다. 코어를 분석하는 부분은 React와 관계없는 내용이지만, 전반적으로 React를 어느 정도 이해하고 있는 독자를 대상으로 작성되었다.

Zustand는 무엇인가?

Zustand는 독일어로 '상태'라는 뜻을 가진 라이브러리이며 Jotai를 만든 카토 다이시가 제작에 참여하고 적극적으로 관리하는 라이브러리이다. 아래의 특징을 가지고 있다.

  • 특정 라이브러리에 엮이지 않는다. (그래도 React와 함께 쓸 수 있는 API는 기본적으로 제공한다.)
  • 한 개의 중앙에 집중된 형식의 스토어 구조를 활용하면서, 상태를 정의하고 사용하는 방법이 단순하다.
  • Context API를 사용할 때와 달리 상태 변경 시 불필요한 리랜더링을 일으키지 않도록 제어하기 쉽다.
  • React에 직접적으로 의존하지 않기 때문에 자주 바뀌는 상태를 직접 제어할 수 있는 방법도 제공한다. (Transient Update라고 한다.)
  • 동작을 이해하기 위해 알아야 하는 코드 양이 아주 적다. 핵심 로직의 코드 줄 수가 약 42줄밖에 되지 않는다. (VanillaJS 기준)

왜 Context API를 사용하지 않는가?

Context는 컴포넌트에 의존성을 주입할 수 있는 아주 효과적인 방법 중 하나이다. 하지만 부모 컴포넌트 쪽에 Context.Provider 컴포넌트를 선언하고 Context로 전달되는 값이 변경될 때 해당 Context를 사용하는 모든 자손 컴포넌트는 리랜더링된다.

다음과 같이 임의의 값과 값을 변경하는 방법을 제공하는 Context와 Provider를 만들었다고 가정해보자.

const SomeObjectContext = React.createContext({ input: '', count: 0 });
const SetSomeObjectConteext = React.createContext();

const Provider = ({ children }) => {
  const [someObj, setSomeObj] = React.useState({ input: '', count: 0 });

  return (
    <SetSomeObjectConteext.Provider value={setSomeObj}>
      <SomeObjectContext.Provider value={someObj}>
        {children}
      </SomeObjectContext.Provider>
    </SetSomeObjectConteext.Provider>
  )
};

이 Context를 소비하는 컴포넌트가 여러 단계 아래쪽 자손일 수 있다.

const InputConsumer = () => {
  const { input } = useContext(SomeObjectContext);
  // ...
}

const CountConsumer = () => {
  const { count } = useContext(SomeObjectContext);
  // ...
}

const App = () => (
  <Provider>
    <DeepChildren>
      <SomeOtherChildren>
        <AnotherChildren>
          {/* input 값만 사용하려 함 */}
          <InputConsumer />
        </AnotherChildren>
        {/* count 값만 사용하려 함 */}
        <CountConsumer />
      </SomeOtherChildren>
    </DeepChildren>
    <IDontCareContextChild />
    {/* SomeObject 값을 비꾸는 역할을 함 */}
    <ContextSetter />
  </Provider>
)

이렇게 선언된 컴포넌트 트리의 경우 ContextSetter 컴포넌트에서 Context 값의 일부만 바꾸는 동작을 실행하더라도, InputConsumer, CounterConsumer 모두 리랜더링이 일어나게 된다. 결국 객체 형태로 Context를 관리하면서 Context를 소비하는 컴포넌트가 많아질 경우 불필요한 리랜더링이 많이 일어나 애플리케이션의 성능 문제가 생길 수 있다.

이 문제를 해결하기 위한 방안은 여러 가지가 있다.

  1. 하나의 거대한 값을 가진 Context를 만들지 말고 여럿으로 분리하여 필요한 부분만 사용하기
  2. 컴포넌트를 쪼개고 React.memo 를 활용하기
  3. useMemo 훅을 사용하여 컴포넌트 랜더링 부분을 감싸기

제일 권장되는 방법은 1번이지만, 여러 Context를 만들어 Provider로 주입할 때 Provider Hell이라 불리는 중첩 Provider로 인한 가독성 문제가 생긴다. 또한 애플리케이션 규모나 구조에 따라 다르지만 캘린더는 일원화된 상태 스토어를 사용하는게 더 효과적일 것이라 판단했다.

간략한 Zustand 사용 방법

스토어를 만들 때는 create 함수를 이용하여 상태와 그 상태를 변경하는 액션을 정의한다. 그러면 리액트 컴포넌트에서 사용할 수 있는 useStore 훅을 리턴한다.

import create from 'zustand';

// set 함수를 통해서만 상태를 변경할 수 있다
const useStore = create(set => ({
  bears: 0,
  increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 })
}));

컴포넌트에서 useStore 훅을 사용할 때는 스토어에서 상태를 어떤 형태로 꺼내올지 결정하는 셀렉터 함수를 전달해 주어야 한다. 만약 셀렉터 함수를 전달하지 않는다면 스토어 전체가 리턴된다.

// 상태를 꺼낸다
function BearCounter() {
  const bears = useStore(state => state.bears);
  return <h1>{bears} around here ...</h1>;
}

// 상태를 변경하는 액션을 꺼낸다
function Controls() {
  const increasePopulation = useStore(state => state.increasePopulation);
  return <button onClick={increasePopulation}>one up</button>;
}

기본 사용 방법 외의 다양한 활용 방법은 README 문서를 참고하라.

Zustand 파헤쳐보기 - 코어

원본 코드는 Typescript로 작성되어 있으나, 최대한 간략하게 설명하기 위하여 자바스크립트로 작성된 코드를 보며 동작 원리를 살펴보자.

Zustand는 발행/구독 모델을 기반으로 이루어져 있다. 스토어의 상태 변경이 일어날 때 실행할 리스너 함수를 모아두었다가(구독) 상태가 변경되었을 때 등록된 리스너들에게 상태가 변경되었다고 알려준다(발행).

(발행/구독 모델이 궁금하다면 이 글을 참고하는 것이 좋다.)

또한 스토어를 생성하는 함수를 호출할 때 클로저를 활용한다. 클로저는 간단히 말해 '함수가 선언될 시 그 주변 환경을 기억하는 것'으로, 아래의 코드를 보면 스토어의 상태는 스토어를 조회하거나 변경하는 함수 바깥 스코프에 항상 유지되도록 만들어진 것을 볼 수 있다. 그러면 상태의 변경, 조회, 구독 등의 인터페이스를 통해서만 스토어를 다루고 실제 상태는 애플리케이션의 생명 주기 처음부터 끝까지 의도치 않게 변경되는 것을 막을 수 있다.

먼저 전체 코드의 형태를 가볍게 살펴보고, setStatesubscribe 부분을 조금 더 자세히 살펴보자.

전체 코드

원본 소스 코드

// createState는 예제에서 보았던 생성자 함수이다.
// 함수 최하단부에 언급되지만 set, get 함수와
// `create` 함수를 통해 만들어지는 내부 API를 인자로 전달받을 수 있다.
export function create(createState) {
  // 스토어의 상태는 클로저로 관리된다.
  let state;

  // 상태 변경을 구독할 리스너를 Set으로 관리한다.
  // 배열로 관리할 경우 중복된 리스너를 솎아내기 어렵기 때문이다.
  const listeners = new Set();

  const setState = (partial, replace) => {
    const nextState = typeof partial === 'function' ? partial(state) : partial;
    if (nextState !== state) {
      const previousState = state;

      state = replace ? nextState : Object.assign({}, state, nextState);

      listeners.forEach(listener => listener(state, previousState));
    }
  };

  const getState = () => state;

  const subscribeWithSelector = (
    listener,
    selector = getState,
    equalityFn = Object.is
  ) => {
    let currentSlice = selector(state);

    function listenerToAdd() {
      const nextSlice = selector(state);
      if (!equalityFn(currentSlice, nextSlice)) {
        const previousSlice = currentSlice;
        listener((currentSlice = nextSlice), previousSlice);
      }
    }

    listeners.add(listenerToAdd);
    return () => listeners.delete(listenerToAdd);
  };

  const subscribe = (listener, selector, equalityFn) => {
    if (selector || equalityFn) {
      return subscribeWithSelector(listener, selector, equalityFn);
    }
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  // 모든 리스너를 제거한다. 하지만 이미 정의된 상태를 초기화하진 않는다.
  const destroy = () => listeners.clear();

  const api = { setState, getState, subscribe, destroy };

  // 인자로 전달받은 createState 함수를 이용하여 최초 상태를 설정한다.
  state = createState(setState, getState, api);

  return api;
}

상태의 변경

먼저 상태를 변경하는 setState 함수를 살펴보자. 이 함수는 현재 상태를 기반으로 새로운 상태를 리턴하는 함수 혹은, 아예 변경하려는 상태 값을 전달받는다.

store.setState(state => ({ counter: state.counter + 1 }));
// 혹은
store.setState({ counter: 10 });

이런 동작을 구현하려면 기존 상태를 함수의 인자로 전달하는 방법, 그리고 함수를 갱신하는 방법이 필요하다. 먼저 함수를 전달받을 경우 현재 상태를 인자로 넘겨주는 식으로 '다음 상태' 를 정의한다.

const setState = (partial) => {
  const nextState = typeof partial === 'function' ? partial(state) : partial;
  // ...
};

그리고 nextState 가 기존 state 와 다른 경우 state 를 갱신한다. 상태를 갱신할 때 간단하고 효과적인 Object.assign 을 사용한다.

if (nextState !== state) {
  const previousState = state;

  state = Object.assign({}, state, nextState);

  listeners.forEach(listener => listener(state, previousState));
}

Object.assign 은 얕은 복사를 수행하기 때문에 깊이 중첩된 형태의 스토어를 만들었을 때는 setState 를 호출할 때 유의해야 한다. setState 함수는 직접 호출할 일이 거의 없고 생성자 함수로 스토어를 정의할 때 첫 번째 인자로 전달받기 때문에 생성자 함수에서 setState 를 사용하여 상태를 변경하는 액션을 정의하면 된다.

// 여기 있는 `set` 이 위에서 정의한 `setState` 함수이다.
const create = create(set => ({
  // ...
  // 이렇게 상태를 변경하는 액션을 정의한다.
  someAction: () => set(state => ({ /* ... */ }))
});

상태의 구독

상태를 구독하는 함수를 등록할 때는 subscribe 함수를 사용한다. 이 함수를 사용하여 모든 상태의 변화를 구독할 수도 있고, 상태의 일부만 구독할 수도 있다.

const subscribe = (listener, selector, equalityFn) => {
  if (selector || equalityFn) {
    return subscribeWithSelector(listener, selector, equalityFn);
  }
  listeners.add(listener);
  // 구독을 해제하는 함수도 리턴해준다.
  return () => listeners.delete(listener);
};

만약 listener 만 전달하지 않고 두 번째 인자로 함수의 셀렉터까지 전달한 경우라면 selector 로 꺼낸 상태의 일부(슬라이스)를 어딘가에 보관하고, 상태가 바뀔 때마다 이전 슬라이스와 비교하는 과정이 필요하다.

const subscribeWithSelector = (listener, selector, equalityFn = Object.is) => {
  // 구독 시 이 변수의 클로저가 생성된다.
  let currentSlice = selector(state);

  // 상태가 변경될 때마다 실행해 줄 함수
  function listenerToAdd() {
    const nextSlice = selector(state);
    if (!equalityFn(currentSlice, nextSlice)) {
      const previousSlice = currentSlice;
      listener((currentSlice = nextSlice), previousSlice);
    }
  }

  listeners.add(listenerToAdd);
  return () => listeners.delete(listenerToAdd);
};

nextSlice 변수를 할당하는 시점에 state 변수는 어딘가에서 호출된 setState 함수 덕분에 새로운 상태로 갱신되었을 것이다. setState 가 호출 될 때 리스너 목록에 등록되어있던 listenerToAdd 함수가 실행되었기 때문이다. 그래서 이전 슬라이스와 새 슬라이스가 다른 값일 경우 지정된 리스너를 호출해주면서 currentSlice 변수를 갱신한다.

결과물

이 함수로 아래와 같이 스토어를 생성하고 사용할 수 있다. set 이 위에 설명한 setState 라는 것을 잊지 말고 다시 살펴보자.

const store = create(set => ({
  text: '',
  count: 0,
  // 객체를 직접 전달하여 상태를 갱신하는 경우
  setCount: newCount => set({ count: newCount }),
  // 함수를 전달하여 상태를 갱신하는 경우
  increment: () => set(state => ({ count: state.count + 1 })),
  setText: text => set({ text })
}));

store.subscribe(state => console.log('Something's changed: ', state)); // 어떤 상태가 변경되더라도 로그가 출력됨
store.subscribe(
  state => console.log('Count is changed: ', count),
  state => state.count
); // count 값이 바뀔 때만 로그가 출력됨
store.subscribe(
  state => console.log('Text has been changed: ', text),
  state => state.text
); // text 값이 바뀔 때만 로그가 출력됨

store.setText('Changed'); // text 값만 변경
// 결과
// Something's changed: [Object]
// Text has been changed: Changed

React 컴포넌트가 사용할 수 있는 훅(Hook)으로 만들기

Zustand는 기본적으로 위의 스토어를 응용하여 React 컴포넌트에서 사용할 수 있는 방법을 제공하고 있다. 이번 단락의 내용을 이해하려면 React의 Hooks API에 대하여 숙지하고 있어야 한다. 먼저 전체 소스 코드를 보고 상태를 담아두는 부분과 내부 상태를 갱신하는 부분, 마지막으로 갱신된 상태를 알리는 부분을 더 자세하게 살펴본다.

전체 코드

원본 소스 코드

import { useEffect, useLayoutEffect, useReducer, useRef } from 'react';
import { create as createImpl } from './vanilla';

// Server Side Rendring(SSR) 지원.
// 맨 마지막 분기 덕분에 Deno도 지원할 수 있다.
const isSSR =
  typeof window === 'undefined' ||
  !window.navigator ||
  /ServerSideRendering|^Deno\//.test(window.navigator.userAgent);

// SSR 환경일 때는 useLayoutEffect 사용 시 경고가 출력되기 때문에 useEffect를 사용한다.
// useLayoutEffect는 브라우저에서 페인트가 일어나기 전에 동기적으로 실행되기 때문에
// 상태 갱신 이전에 브라우저에 화면이 그려져서 생기는 일시적인 깜빡임을 차단한다.
const useIsomorphicLayoutEffect = isSSR ? useEffect : useLayoutEffect;

// createState 인자는 코어 부분에서 보았던 생성자 함수와 같다.
export function createStoreHook(createState) {
  // 스토어를 생성하여 클로저에 담고, 이 스토어를 다루는 방법을 훅으로 제공할 것이다.
  const api =
    typeof createState === 'function' ? createImpl(createState) : createState;

  const useStore = (selector = api.getState, equalityFn = Object.is) => {
    // 훅을 원하는 시점에 재실행하는 트릭이다. 본문에서 추가로 설명한다.
    const [, forceUpdate] = useReducer(c => c + 1, 0);

    const state = api.getState();

    const stateRef = useRef(state);
    const selectorRef = useRef(selector);
    const equalityFnRef = useRef(equalityFn);
    const erroredRef = useRef(false);

    const currentSliceRef = useRef();
    if (currentSliceRef.current === undefined) {
      currentSliceRef.current = selector(state);
    }

    let newStateSlice;
    let hasNewStateSlice = false;

    if (
      stateRef.current !== state ||
      selectorRef.current !== selector ||
      equalityFnRef.current !== equalityFn ||
      erroredRef.current
    ) {
      newStateSlice = selector(state);
      hasNewStateSlice = !equalityFn(currentSliceRef.current, newStateSlice);
    }

    useIsomorphicLayoutEffect(() => {
      if (hasNewStateSlice) {
        currentSliceRef.current = newStateSlice;
      }
      stateRef.current = state;
      selectorRef.current = selector;
      equalityFnRef.current = equalityFn;
      erroredRef.current = false;
    });

    // 간혹 구독이 일어나기 전에 상태가 변하는 엣지 케이스가 있기 때문에,
    // 매끄럽게 처리할 수 있도록 임시로 Ref에 담아둔다.
    const stateBeforeSubscriptionRef = useRef(state);
    useIsomorphicLayoutEffect(() => {
      const listener = () => {
        try {
          const nextState = api.getState();
          const nextStateSlice = selectorRef.current(nextState);

          if (!equalityFnRef.current(currentSliceRef.current, nextStateSlice)) {
            stateRef.current = nextState;
            currentSliceRef.current = nextStateSlice;
            forceUpdate();
          }
        } catch (error) {
          erroredRef.current = true;
          forceUpdate();
        }
      };
      const unsubscribe = api.subscribe(listener);

      if (api.getState() !== stateBeforeSubscriptionRef.current) {
        listener();
      }

      // 훅이 언마운트될 때 구독을 해제한다.
      return unsubscribe;
    }, []);

    return hasNewStateSlice ? newStateSlice : currentSliceRef.current;
  };

  // 훅을 사용하는 컴포넌트에서 때때로 스토어에 직접적으로 접근할 필요가 있을 때 활용하기 위한 트릭
  // ex) useStore.getState() / useStore.subscribe(someCustomListener) 등
  Object.assign(useStore, api);

  return useStore;
}

상태의 부분을 담아두기

api 라는 변수는 코어 부분에서 살펴봤던 create 함수로 만들어진 스토어다. 그 안에 클로저로 가두어진 상태가 있고, 리액트 컴포넌트는 이 변수의 존재를 모른 채 원하는 상태를 꺼내 쓸 수 있게 만드는 것이 목표다.

const useStore = (selector = api.getState, equalityFn = Object.is) => {
  // ...

  // 훅이 실행되는 시점의 상태를 가져온다.
  const state = api.getState();

  // 훅이 실행될 때마다 갱신되는 Ref들이다.
  const stateRef = useRef(state);
  const selectorRef = useRef(selector);
  const equalityFnRef = useRef(equalityFn);
  const erroredRef = useRef(false);

  // 훅을 최초에 실행할 때 selector를 사용하여 상태 슬라이스를 뜬다.
  // 이 값이 훅을 사용하는 컴포넌트에 전달될 것이다.
  const currentSliceRef = useRef();
  if (currentSliceRef.current === undefined) {
    currentSliceRef.current = selector(state);
  }

  // ...
};

커스텀 훅은 다음의 경우 다시 실행된다. 컴포넌트가 리랜더링되는 조건과 유사하다.

  • useState, useReducer 로 내부에 선언한 상태를 갱신할 때
  • 커스텀 훅을 호출하고 있는 컴포넌트가 리랜더링 될 때
  • 커스텀 훅에 전달되는 인자(prop)가 바뀌는 경우

하지만 useRef 로 담아둔 값은 훅이 다시 실행되어도 별도로 재할당하지 않는 한 변경되지 않는다. 추가로 useRef 의 값을 재할당해도 훅은 다시 실행되지 않는다. Zustand는 훅이 다시 호출되는 타이밍, 그리고 useRef 의 생명 주기를 효과적으로 활용하여 useStore 라는 커스텀 훅을 제공한다.

selector 라는 인자가 전달될 때 useStore 를 호출하는 쪽에서 필요한 것은 전체 상태가 아니라 selector 를 통해 얻고자 하는 상태의 일부이다. 그래서 currentSliceRef 의 값이 useStore 훅이 리턴해야 하는 값이 된다.

상태가 갱신될 경우 알리기

코어를 분석하는 단락에서 상태의 변경을 구독하는 리스너를 subscribe 를 통해 등록할 수 있다고 했다. subscribe 를 호출하여 리스너를 등록하는 부분은 코드 조금 아래쪽에 있다.

useIsomorphicLayoutEffect(() => {
  const listener = () => {
    try {
      const nextState = api.getState();
      const nextStateSlice = selectorRef.current(nextState);
      
      if (!equalityFnRef.current(currentSliceRef.current, nextStateSlice)) {
        stateRef.current = nextState;
        currentSliceRef.current = nextStateSlice;
        forceUpdate();
      }
    } catch (error) {
      erroredRef.current = true;
      forceUpdate();
    }
  };
  const unsubscribe = api.subscribe(listener);

  return unsubscribe;
}, []);

리스너가 호출될 때 getState 를 사용하여 새로이 갱신된 전체 상태를 가져오고 가장 최신 상태의 셀렉터를 사용하여 슬라이스를 추출한다. 그리고 현재 슬라이스와 비교를 하여 갱신이 필요하다고 생각하면 forceUpdate 를 호출한다.

forceUpdate 함수는 훅을 재실행하는 일종의 트릭이다. 앞서 훅 내부에 useState 혹은 useReducer 로 정의한 상태가 갱신될 경우 훅이 재실행된다고 언급했는데, 몇몇 라이브러리에서 클래스 컴포넌트의 forceUpdate 메서드를 흉내내고자 다음과 같이 구현하는 것이다.

// 꼭 숫자일 필요는 없지만 새로운 값을 넘겨야 하므로 명시적으로 갱신하기 가장 쉽다.
const [, forceUpdate] = useReducer(n => n + 1, 0);

// 혹은 이렇게 구현할 수 있다.
// 참고로 useState는 내부적으로 useReducer를 사용하고 있기 때문에 큰 차이는 없다.
const [, setCount] = useState(0);
const forceUpdate = useCallback(() => setCount(prev => prev + 1), []);

// 사용 예
const useForceUpdate = () => {
  const [, forceUpdate] = useReducer(n => n + 1, 0);

  return forceUpdate;
};

function Component() {
  const forceUpdate = useForceUpdate();

  // 버튼을 클릭할 때마다 콘솔이 출력되는 것을 볼 수 있다.
  console.log('rendered');

  return (
    <button onClick={forceUpdate}>Click me to see re-render</button>
  );
}

변경된 상태를 갱신하기

Zustand는 상태를 useRef 로 생성한 Ref 객체에 담아두고 레퍼런스로만 관리하고 있기 때문에 리액트 컴포넌트가 상태의 갱신을 인지할 수 있도록, 또한 useStore 훅 내부에서 새로운 슬라이스를 반영하기 위해 forceUpdate 함수를 호출하여 수동으로 훅을 재실행한다. forceUpdate 호출 후 일어나는 일은 currentSliceRef 를 처음 할당하는 부분 바로 아래쪽에 작성되어 있다.

// 지역 변수를 사용하여 훅이 실행되는 단계(랜더링 단계)에서 Ref값이 변하는 것을 피한다.
let newStateSlice;
let hasNewStateSlice = false;

// 셀렉터나 비교 함수(equalityFn)가 변경되었다면 재실행되면서 상태의 갱신이 필요한지 비교해야한다.
// 또한 구독한 쪽에서 에러가 발생할 경우 적절한 오류를 표시하고 훅을 재실행한다.
if (
  stateRef.current !== state ||
  selectorRef.current !== selector ||
  equalityFnRef.current !== equalityFn ||
  erroredRef.current
) {
  // 위에 선언한 지역 변수에 새로운 슬라이스를 임시로 할당한다.
  // 만약 이전 슬라이스와 같은 슬라이스가 아닐 경우 `currentSliceRef` 에 새로이 할당할 것이다.
  newStateSlice = selector(state);
  hasNewStateSlice = !equalityFn(currentSliceRef.current, newStateSlice);
}

// 훅이 재실행 될 때마다 변경된 요소를 갱신한다.
// 이펙트는 랜더링(훅이 값을 리턴함) 이후 실행된다.
useIsomorphicLayoutEffect(() => {
  if (hasNewStateSlice) {
    currentSliceRef.current = newStateSlice;
  }
  stateRef.current = state;
  selectorRef.current = selector;
  equalityFnRef.current = equalityFn;
  erroredRef.current = false;
});

위의 갱신 과정을 거치며 useStore 훅이 리턴하는 값은 상태가 갱신되었을때, 갱신되지 않았을 때에 따라 약간 달라진다. 새로운 슬라이스가 있다면 새 슬라이스를 바로 리턴하고, 그렇지 않다면 현재 슬라이스의 레퍼런스를 유지한다. newStateSlice 라는 변수의 값은 다음에 훅이 재실행 될 때까지 유지될 것이다.

return hasNewStateSlice ? newStateSlice : currentSliceRef.current;

useIsomorphicLayoutEffect 는 훅이 값을 리턴한 이후 실행되므로 바로 currentSliceRef 를 리턴하면 다른 값이 리턴될 것이다. 컴포넌트와 훅의 생명 주기는 이 다이어그램을 참고하라.

결과물 살펴보기

만들어진 createStoreHook 은 다음과 같이 활용할 수 있다.

// useStore를 별도에 파일에 선언하여 export하고
// 필요한 컴포넌트가 import하는 식으로 사용하는 것을 권장한다.
const useStore = createStoreHook(set => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 }))
}));

function Counter() {
  const [count, increment] = useStore(state => [state.count, state.increment]);

  return (
    <div>
      <span>Count: {count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

React를 위한 상태 관리 라이브러리는 스토어를 주입할 때 Context API를 사용하는 경우가 많지만, Zustand는 Context API 사용을 배제하고 클로저를 활용하여 스토어 내부 상태를 관리한다. 따라서 createStoreHook 을 호출하여 리턴된 useStore 를 어느 컴포넌트에서나 import하여 원하는대로 사용하더라도 같은 스토어를 바라보게 된다. 다만 특수한 경우를 위해 Context API 기반으로 의존성을 주입하는 방법을 제공한다.

increment 액션을 실행할 때 코어 부분의 setState 함수가 호출되고, 상태가 갱신되었기 때문에 useStore 훅 안에 있는 listener 함수가 실행되는 구조이다. 상태 자체는 useRef 를 사용하여 레퍼런스로 다루고 있으므로, 정말로 상태 변경을 알려주어야 하는 경우가 아니라면 useStore 를 사용하는 컴포넌트에 불필요한 리랜더링을 일으키지 않는다. 다시 한 번 전체 흐름을 살펴보면 이런 형태일 것이다.

useStore hook flow.png

라이브러리 제작자의 블로그 포스트를 통해 내부 구현 원리를 더 깊이 파악할 수 있다. 다른 라이브러리에 대한 글이지만 기본 원리는 유사하다.

파생 상태 만들기

파생 상태(Derived State)를 만들어 컴포넌트에게 전달하기 위해 useMemo 와 결합하여 커스텀 훅을 만들 수도 있다. 가령 검색 기능과 필터링이 있는 임의의 TodoList를 만들어본다고 할 때, 이런 스토어를 만들어볼 수 있을 것이다.

const todoNames = ['one', 'two', 'three'];

const useStore = createStoreHook(set => ({
  todos: todoNames.map(name => ({
    title: name,
    id: `id-${name}`,
    isDone: false,
  })),
  filter: 'all',
  searchKeyword: '',
  addTodo: title =>
    set(({ todos }) => ({
      todos: todos.concat({ title, id: `id-${title}`, isDone: false })
    })),
  changeTodoFilter: filter => set({ filter }),
  changeTodoSearchKeyword: searchKeyword => set({ searchKeyword })
}));

임의의 컴포넌트에서 changeTodoFilter 액션을 호출하여 필터를 변경하고, 그 필터에 따라 필터링된 Todo 목록만 출력하고자 할 때 다음과 같이 커스텀 훅을 정의한다.

function useFilteredTodos() {
  const todos = useStore(state => state.todos);
  const currentFilter = useStore(state => state.filter);

  return useMemo(() => {
    if (currentFilter === 'all') {
      return todos;
    }

    return todos.filter(todo =>
      currentFilter === 'active' ? !todo.isDone : todo.isDone
    );
  }, [todos, currentFilter]);
}

useMemo 를 활용하는 것을 선호한다면 이렇게 구현할 수 있지만, Zustand는 셀렉터를 직접 받을 수 있기 때문에 원하는 상태를 추출하는 함수를 직접 넘겨주면 더 적은 양의 코드로 같은 결과물을 낼 수 있다.

function useFilteredTodos() {
  return useStore(state => {
    if (state.currentFilter === 'all') {
      return state.todos;
    }
    return state.todos.filter(todo =>
      state.currentFilter === 'active' ? !todo.isDone : todo.isDone
    );
  });
}

다만 셀렉터의 레퍼런스가 바뀔 경우에도 연산을 다시 수행하기 때문에 만약 컴포넌트의 내부 상태나 prop과 무관한 셀렉터라면 컴포넌트나 커스텀 훅 외부에 셀렉터를 선언하여 쓸 것을 권장한다. 인라인 함수로 셀렉터를 선언한 경우 매번 리랜더링이 일어날 때마다 함수가 새로 선언되고, useStore 훅 내부에서 슬라이스를 꺼내기 위한 연산도 다시 일어나기 때문이다. 그렇지 않다면 useCallback 을 활용하여 셀렉터를 최적화하는 것이 좋다.

// 컴포넌트나 훅 안에 선언된 상태 혹은 prop과 무관한 셀렉터의 경우
function filteredTodoSelector(state) {
  if (state.currentFilter === 'all') {
    return state.todos;
  }
  return state.todos.filter(todo =>
    state.currentFilter === 'active' ? !todo.isDone : todo.isDone
  );
}

function useFilteredTodos() {
  return useStore(filteredTodoSelector);
}
// 상태나 prop의 영향을 받는 셀렉터의 경우
function ItemDetail(id) {
  const item = useStore(useCallback(state => state.items[id], [id]));
  // ...
}

마치며

위의 createStoreHook 구현체를 참고하여 Preact에서도 동일한 API를 사용하여 같은 동작을 하는 스토어를 만들어낼 수 있었다. (코드 링크)

Zustand는 다음 메이저 버전에서 useMutableSource 를 통해 훨씬 간결한 구현체를 만들어낼 수 있지만 Preact에서는 활용할 수 없다는 아쉬움이 남는다. (Mutable Source는 이번 글에서 다루지 않는다.)

만약 해당 라이브러리를 활용하는데 관심이 생겼다면, 이 글로 가볍게 내부를 살펴보고 본인의 개발환경에 맞게 적절히 활용할 수 있으리라 기대한다.

요구사항이나 조건이 고려했던 사항과 맞지 않았을 뿐, 충분히 일반적인 애플리케이션에서 활용하기 좋은 라이브러리들도 있었으니 상태 관리 방법을 고민하고 있다면 다른 라이브러리도 살펴보고, 더 나아가 그 코드를 분석해보는 것도 도움이 될 것이다.

  • Jotai - 일본어로 '상태' 라는 뜻을 가졌으며, Recoil과 유사한 API를 제공한다.
  • Akita - RxJS 기반으로 이루어진 상태 관리 라이브러리이다.
  • react-sweet-state - Redux와 Context API의 좋은 점을 가져와 활용한다.

레퍼런스