카테고리 없음

상태 관리와 zustand (zustand 동작 원리를 이해하기)

mugglim 2024. 10. 12. 14:49

글을 들어가며

본 글에서는 상태 관리에 대한 개념을 소개하고, zustand의 구현 코드를 기반으로 동작 원리를 살펴보려고 한다. 글 내용 중 zustand 코드는 v5 버전을 기준으로 작성되었다.

상태와 상태 관리

프론트엔드 영역에서 상태란 UI를 표현하기 위해 사용되는 데이터이다. 상태는 시간에 따라 변화될 수 있는 값이다. 상태 관리는 상태 데이터를 관리하는 객체라고 정의하여, 상태 관리 객체의 책임을 생각해보자. 책임은 더 다양할 수 있지만, 4가지 정도로 정의할 수 있다.

  1. 최신 상태를 알고 있다.
  2. 최신 상태를 반환할 수 있다.
  3. 상태를 변경할 수 있다.
  4. 상태가 변경될 때, 변경된 상태를 전파할 수 있다.

상태 관리 객체의 책임을 기억하며, zustand 코드를 살펴보자.

zustand

zustand는 flux 패턴 기반의 상태 관리 라이브러리이다. 사용 방법이 간단하여, 많은 인기(star 수)를 얻고 있다. 주의할 점은 zustand는 리액트에 의존적인 라이브러리가 아니다. 코어 로직은 타입스크립트를 기반 작성되어 있다. 

이제 실제 코드를 살펴보려고 한다. github 코드를 살펴보면, src 폴더 하위에 여러 폴더와 파일이 존재한다. 동작 원리를 이해하기 위한 파일은 vanilla.ts 및 react.ts 2가지이다.

└── src
    ├── vanilla.ts    # 상태 관리 core 로직 파일
    ├── react.ts      # core 로직을 활용해 커스텀 훅을 반환하는 파일
    └── ...

코드는 타입스크립트로 작성되어 있지만, 간결하게 전달하기 위해 자바스크립트로 코드로 설명하고자 한다. 추가로, 옵션 인자와 같이 로직을 이해하는데 불필요한 요소들은 제거하였다.

vanilla.ts 파일 살펴보기

zustand는 클로저와 옵저버 패턴을 기반으로 상태 관리 객체를 구현한다.
전체 코드를 한번 살펴보자. 생각보다 코드가 길지는 않다.

const createStore = (createState) => {
  /** 상태 */
  let state;

  /** 상태가 변경될 때, 변경 이벤트를 감지할 대상 */
  const listeners = new Set();

  /** (책임) 최신 상태를 반환한다. */
  const getState = () => state;

  /** 상태가 변경될 때, 이벤트를 전파하는 대상을 추가할 수 있다. */
  const subscribe = (callback) => {
    listeners.add(callback);

    return () => {
      listeners.delete(callback);
    };
  };

  /** (책임) 상태를 변경할 수 있다. */
  const setState = (partial) => {
    const prevState = state;
    const nextState = typeof partial === "function" ? partial(state) : state;

    if (!Object.is(prevState, nextState)) {
      state = nextState;

      /** (책임) 상태가 변경될 때, 이벤트를 전파한다. */
      listeners.forEach((listener) => listener(state));
    }
  };

  const api = { setState, getState, subscribe };

  /** 클로저를 기반으로 객체를 구성한다. */
  state = createState(setState, getState, api);

  return { getState, setState, subscribe };
};

createStore 함수는 상태 관리 객체를 생성하는 함수이다.
createStore 함수는 createState 콜백 함수를 인자로 전달받는다.

createState 함수는 인자 값으로 set, get 함수를 받으며, 인자 값은 모두 createStore 함수 내부에 setState, getState 변수를 사용하고 있다. 즉 여기서 클로저를 활용하고 있다.

위 코드를 기반으로 간단하게 count 상태를 관리하는 상태 관리 객체를 만들어보자. count 상태 관리 객체의 역할은 아래와 같다.

  • 최신 count를 상태를 알고 있다.
  • count를 1씩 증가시키는 액션을 제공한다.

역할을 기반으로 countStore 상태 관리 객체를 구성하면 아래와 같다.

/** counterStore.js */

const createState = (
  // createStore 함수 내부의 setState가 사용된다.
  set,
  // createStore 함수 내부의 getState가 사용된다.
  get
) => {
  return {
    count: 0,
    addCount: () => {
      const prevState = get();
      const prevCount = prevstate.count;

      set({ count: prevCount + 1 });
    },
  };
};

export const countStore = createStore(createState);

/** Component.jsx */
const Component = () => {
  const count = countStore.getState().count;
  const addCount = countStore.getState().addCount;

  return <button onClick={addCount}>count: {count}</button>;
};

countStore 기반으로 상태를 읽고 변경하는 방법은 아래와 같다.

  • count 상태 읽기: countStore.getState().count
  • count 상태 1씩 증가시키기: countStore.getState().addCount

아쉽게도 버튼이 클릭될 때마다 UI가 변경되지는 않는다. 이유는 리액트에서 UI를 변경하기 위해서는 리렌더링을 발생시켜야 한다. 즉 addCount가 호출될 때 count 값은 변경되지만, 리렌더링 되지는 않는다.

이제 위 상태 관리 객체를 리액트에서 활용할 수 있는 방법을 알아보자.

react.ts 파일 살펴보기

zustand는 useSyncExternalStore 리액트 훅을 활용해, 상태 값이 변경될 때 마다 리렌더링을 발생시킨다.

전체 코드를 살펴보자.

import React from 'react'
import { createStore } from './vanilla.ts'

export const useStore = (
  // createStore를 통해 생성된 상태 관리 객체를 의미한다.
  api,
  // 반환 되는 상태값을 slicing 하고 싶은 경우 사용한다.
  selector
) => {
  const slice = React.useSyncExternalStore(
    // callback 함수는 createStore.setState 함수가 실행될 때, 상태값이 변경된 경우에만 실행된다.
    api.subscribe,
    () => selector(api.getState())
  );
  return slice;
};

export const create = (createState) => {
  // vanilla.ts 의 createStore을 통해 상태 관리 객체를 생성한다.
  const api = createStore(createState);

  // 인자값으로 selector을 받아 useStore 훅에 전달한다.
  const useBoundStore = (selector) => {
    return useStore(api, selector);
  };

  return useBoundStore;
};

전체적인 코드를 살펴보면 vanilla.ts의 createStore 코어 로직을 기반으로 상태 관리 객체를 생성하고,
useSyncExternalStore을 기반으로 상태가 변경될 때마다 구독된 컴포넌트에 리렌더링을 발생시킨다.

앞서 작성했던 counter 예시 코드를 리액트에서 동작하도록 다시 작성해 보자.

/** counterStore.js */

const createState = (set, get) => {
  return {
    count: 0,
    addCount: () => {
      const prevState = get();
      const prevCount = prevstate.count;

      set({ count: prevCount + 1 });
    },
  };
};

/**
 * createStore 함수가 아닌 create 함수를 사용한다.
 * create 함수는 내부적으로 createStore 함수를 사용하고 있다.
 * */
export const useCountStore = create(createState);

/** Component.jsx */
const Component = () => {
  const count = useCountStore((state) => state.count);
  const addCount = useCountStore((state) => state.addCount);

  return <button onClick={addCount}>count: {count}</button>;
};

이제 버튼이 클릭될 때마다 컴포넌트가 리렌더링 되는 것을 확인할 수 있다.

글을 마무리 하며

간단하게 zustand의 동작 원리를 살펴보았다.

글에는 없는 내용이지만, 처음 코드를 살펴봤을 때 "zustand는 왜 useSyncExternalStore을 사용하는가?"에 대한 의문점이 있었다. 관련해서 React Conf 2021 React 18 for External Store Libraries 발표 영상을 참고하면 좋을 것 같다.

추가로 글을 작성하면서 <리액트 훅을 활용한 마이크로 상태 관리> 책의 내용은 참고하였다. 책의 저자는 zustand 라이브러리의 메인테이너인 Daish Kato이다. 책 내용을 이해하면 상태 관리에 대한 이해를 높일 수 있다고 생각한다. 시간이 된다면 책을 읽어보기를 추천한다.