Ayden's journal

Caro-Kann으로 알아보는 전역 상태 고급 원리

이 포스트의 내용 대부분은 카토 다이시의 [리액트 훅을 활용한 마이크로 상태 관리]에서도 찾아볼 수 있습니다. 전역 상태 관리 원리를 더욱 깊게 이해하고 싶으시다면 해당 서적을 여러번 읽어보시는 것을 권해드립니다.

 

[ Caro-Kann으로 알아보는 전역 상태 기본 원리 ]에서 이어지는 내용입니다. 

 

useSyncExternalStore

우리는 이전 포스트에서 useState를 사용하여 발행-구독 패턴을 처리하였다. 하지만 널리 사용되는 전역 상태 관리 라이브러리들은 useState 대신 useSyncExternalStore 훅을 사용하여 발행-구독 패턴을 처리한다. 이 훅은 리액트 18부터 제공되며, 외부 스토어의 업데이트가 컴포넌트의 리랜더링과 정확하게 동기화되도록 보장해준다. useSyncExternalStore는 아래와 같은 세 가지 인자를 받을 수 있다.

  • subscribe : store를 구독하고 구독을 취소하는 함수를 반환한다.
  • getSnapshot : store에서 데이터의 스냅샷을 읽어들이는 함수로, 이전 포스트의 getStore에 해당한다.
  • getServerSnapshot : 옵셔널한 값으로 store에 있는 데이터의 초기 스냅샷을 반환해야 한다. 이는 서버 렌더링 도중과 클라이언트에서 서버 렌더링 된 콘텐츠의 하이드레이션 중에만 사용되는데, 이 함수가 제공되지 않으면 서버에서 컴포넌트를 렌더링할 때 오류가 발생할 수 있다.
  const state = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot
  );

 

아래의 코드는 useSyncExternalStore를 사용하여 다시 작성된 전역 상태 관리 도구이다. 살펴보면 useState를 쓰던 이전에 비해 useStore가 얼마나 간단해졌는지 확인할 수 있다.

export const create = <T extends unknown>(initValue: T) => {
  function createStore() {
    let store = initValue;
    const callbacks = new Set<Function>()
    const getStore = () => store
    const setStore = (value: T) => { store = value, callbacks.forEach(cb => cb()) }
 
    const subscribe = (callback: () => void) => {
      callbacks.add(callback);
 
      return () => {
        callbacks.delete(callback);
      };
    };
 
    return { getStore, setStore, subscribe };
  }
 
  const { getStore, setStore, subscribe } = createStore();
 
  const useStore = () => {
  const useStore = (selector?: ) => {
    const state = useSyncExternalStore(
      subscribe,
      () => getStore(),
      () => initValue,
    );
    
    return [state, setStore] as const;
  }
 
  return useStore
}

 

 

불변성 상태 업데이트

useState를 사용하면 나오는 setState에는 값 뿐만 아니라 콜백 함수를 제공할 수 있다. 보통 아래와 같은 형태로 사용되는데, 이를 불변성 상태 업데이트(Immutable State Update) 혹은 함수적 업데이트(Functional Update)라고 부른다. 불변성이란 데이터를 직접 수정하지 않고 복제본을 만들어 변경 사항을 적용하는 것을 말하는데, 이를 통해 상태 관리와 변경 감지를 효율적으로 수행하고 예측 가능한 코드 작성이 가능해진다.

setState((prev) => ({...prev, A: prev.A + 1}))

 

그러나 지금까지 작성한 전역 상태 관리 도구는 불변성 상태 업데이트를 제공하지 않는다. 이를 제공하기 위해서는 setStore가 값 뿐만 아니라 함수를 받을 수 있도록 처리해주어야 하는데, Caro-Kann에서는 아래와 같은 방식으로 불변성 상태 업데이트를 제공하고 있다.

// 변경 전 setStore
const setStore = (value: T) => { store = value, callbacks.forEach(cb => cb()) }

// 변경 후 setStore
const setStore = (value: T | ((prev: T) => T)) => {
  store = typeof value === "function" ? (value as (prev: T) => T)(store) : value;
  callbacks.forEach(cb => cb());
};

 

 

셀렉터 패턴

useContext를 사용해서 상태를 내려보내고는 '전역 상태다!'라고 주장하는 사람들이 있다. 그것이 전역 상태의 정의에 부합하는지는 차치하고서라도, 이런 방법은 상태가 변경될 때마다 모든 컨텍스트 컨슈머가 리랜더링된다는 문제를 안고 있다. 그리고 놀랍지 않게도, 위에서 만든 전역 상태 관리 도구 역시 똑같은 문제를 안고 있다. 원시 자료형의 경우라면 어쩔 수 없겠지만, 객체의 경우 프로퍼티 하나만 바꿔도 모든 컨텍스트 컨슈머가 리랜더링 된다. 아래의 경우 A 컴포넌트에서 전역 상태를 업데이트한 바람에 B 컴포넌트까지 리랜더링 되는 것이다.

const useStore = create({
  A: 0,
  B: 0,
})

// A comp
export default function A() {
  const [store, setStore] = useStore()
  
  const handleClick = () => {
    setStore((prev) => ({...prev, A: A + 1}))
  }

  return (
    <>
      {store.A}
      <button type="button" onClick={handleClick}>A + 1</button>
    </>
  )
}

// B comp
export default function B() {
  const [store, setStore] = useStore()

  return <>{store.B}</>
}

 

이를 해결하기 위해 Caro-Kann은 셀렉터 패턴을 사용한다. Caro-Kann에 영향을 준 Zustand도 마찬가지로 셀렉터 패턴을 사용하고 있다. Jotai의 경우 아토믹 기반이라 구조적으로 이 문제를 해결한 셈이다. 셀렉터 패턴은 상태의 특정 부분만을 구독하도록 함으로써 불필요한 리렌더링을 방지하는 방법이다. 이는 전역 상태가 커질수록 리렌더링의 성능 문제를 해결하는 데 유용하다. 셀렉터 패턴의 핵심은 구독자가 직접 상태를 변경하지 않고, 특정 상태를 선택(Select)해서 읽기만 한다는 점이다.

type UseStore<T> = {
  (): readonly [T, (action: T | ((prev: T) => T)) => void];
  <S>(selector: (state: T) => S): readonly [S, (action: T | ((prev: T) => T)) => void];
};

const useStore: UseStore<T> = <S,>(selector?: (state: T) => S)  => {
  const snapshot = () => selector ? selector(getStore()) : getStore()
  const serverSnapshot = () => selector ? selector(initValue) : initValue
  
  const state = useSyncExternalStore(subscribe, snapshot, serverSnapshot)

  return [state, setStore] as const;
}

 

셀렉터 함수를 적용시키면 코드는 아래와 같은 형태가 된다. A 컴포넌트에서 store를 변경해도 불변성 상태 업데이트 덕분에 B 컴포넌트는 리랜더링 되지 않는다.

const useStore = create({
  A: 0,
  B: 0,
})

// A comp
export default function A() {
  const [a, setStore] = useStore(store => store.A)
  
  const handleClick = () => {
    setStore((prev) => ({...prev, A: A + 1}))
  }

  return (
    <>
      {a}
      <button type="button" onClick={handleClick}>A + 1</button>
    </>
  )
}

// B comp
export default function B() {
  const [b, setStore] = useStore(store => store.B)

  return <>{b}</>
}

 

 

셀렉터 패턴 with setStore

위에서 셀렉터 패턴은 snapshot에만 적용되었다. 따라서 setStore는 여전히 전체 상태를 대상으로 동작한다. Caro-Kann은 아직 셀렉터를 사용하여 setStore를 처리하고 있지 않지만, 다음 버전에서 실험적 기능으로 ─ 몇 가지 제약이 달리지만 ─ 이를 지원하고자 한다. 방법은 아래와 같다.

type SetStore<T> = (action: T | ((prev: T) => T)) => void;
type UseStore<T> = {
  (): readonly [T, SetStore<T>];
  <S>(selector: (state: T) => S): readonly [S, SetStore<S>];
};

const useStore: UseStore<T> = <S>(selector?: (state: T) => S) => {
  const snapshot = () => selector ? selector(getStore()) : getStore();
  const serverSnapshot = () => selector ? selector(initValue) : initValue;
  const board = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
  
  if (selector) {
    type TargetProps = keyof ReturnType<typeof getStore>;
  
    const target =
      selector.toString().split(".").at(1) as TargetProps
      ?? selector?.toString().split(/[\[\]\"]+/).at(1) as TargetProps;

    const setTargetBoard = (value: S | ((prev: S) => S)) => {
      if (typeof value === "function") {
        setStore((prev) => ({
          ...prev,
          [target]: (value as (prev: S) => S)(prev[target] as S),
        }));
      } else {
        setStore((prev) => ({
          ...prev,
          [target]: value,
        }));
      }
    };
  
    return [board, setTargetBoard] as const;
  } else {
    return [board, setStore] as const;
  }
}

 

아쉽게도 아직은 셀렉터 함수가 특정한 형태(store => store.A 혹은 store => store["A"])를 하고 있어야 하고, 중첩된 객체에 대해서는 전혀 처리할 수 없는 상태이다. 뭐, 실험적 기능인 만큼 위의 코드는 앞으로 얼마든 변경될 수 있으므로 여기서는 그저 참고 사항 정도로 남기고 가려 한다.

 

아무튼 이제 각각의 컴포넌트에서 아래와 같이 setA와 setB를 사용할 수 있다.

const useStore = create({
  A: 0,
  B: 0,
})

// A comp
export default function A() {
  const [a, setA] = useStore(store => store.A)
  
  const handleClick = () => {
    setA((prev) => prev + 1))
  }

  return (
    <>
      {a}
      <button type="button" onClick={handleClick}>A + 1</button>
    </>
  )
}

// B comp
export default function B() {
  const [b, setB] = useStore(store => store.B)

  return <>{b}</>
}

 

지금까지의 코드를 살펴보면 아래와 같다. setStore에 selector를 적용하는 과정에서 코드가 거의 두 배로 늘어났지만, 그걸 제외하면 지난 포스트에서 작성한 코드와 아주 크게 다르지는 않다.

import { useSyncExternalStore } from "react";

type SetStore<T> = (action: T | ((prev: T) => T)) => void;
type UseStore<T> = {
  (): readonly [T, SetStore<T>];
  <S>(selector: (state: T) => S): readonly [S, SetStore<S>];
};

export const create = <T>(initValue: T) => {
  function createStore() {
    let store = initValue;
    const callbacks = new Set<Function>()
    const getStore = () => store
    const setStore = (value: T | ((prev: T) => T)) => {
      store = typeof value === "function" ? (value as (prev: T) => T)(store) : value;
      callbacks.forEach(cb => cb());
    };

    const subscribe = (callback: () => void) => {
      callbacks.add(callback);

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

    return { getStore, setStore, subscribe };
  }

  const { getStore, setStore, subscribe } = createStore();

  const useStore: UseStore<T> = <S>(selector?: (state: T) => S) => {
    const snapshot = () => selector ? selector(getStore()) : getStore();
    const serverSnapshot = () => selector ? selector(initValue) : initValue;
  
    const board = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
  
    if (selector) {
      type TargetProps = keyof ReturnType<typeof getStore>;
  
      const target =
        selector.toString().split(".").at(1) as TargetProps
        ?? selector?.toString().split(/[\[\]\"]+/).at(1) as TargetProps;
  
      const setTargetBoard = (value: S | ((prev: S) => S)) => {
        if (typeof value === "function") {
          setStore((prev) => ({
            ...prev,
            [target]: (value as (prev: S) => S)(prev[target] as S),
          }));
        } else {
          setStore((prev) => ({
            ...prev,
            [target]: value,
          }));
        }
      };
  
      return [board, setTargetBoard] as const;
    } else {
      return [board, setStore] as const;
    }
  }

  return useStore
}

 

 

파생 상태

셀렉터 패턴은 상태의 특정 부분만을 구독하도록 함으로써 불필요한 리렌더링을 방지하는 방법이다. 파생 상태(derived state)는 셀렉터 패턴과 꽤 비슷하지만, 기존 상태를 가공하거나 조건을 적용해 새로운 값을 도출하는 방식이다. Jotai의 readOnlyAtom과도 비슷한 부분이 있지만, 완전히 같지는 않다.

이러한 파생 상태는 기반하는 상태가 변경될 때마다 파생 상태 계산 함수를 다시 호출하여 값을 다시 평가한다.

const useStore = create(0)

export default function Comp() {
  const [isQualified] = useStore(store => store > 10 ? true : false)

  return (
    <>{isQualified ? "레벨이 충분하지 않습니다" : "레벨이 충분합니다"}</>
  )
}

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기