Skip to content

마이크로 상태관리의 의미와 핵심 원리

등록 날짜:2024년 3월 17일 at 오후 11:45

시작하며

이번 글은 “리액트 훅을 이용한 마이크로 상태관리”라는 책을 읽고 얻은 내용을 정리하는 글이다. 이 책의 가치는 어떻게 reactivity 를 가진 UI 라이브러리와 외부의 상태를 연결시키는지를 알려주는 부분이 가장 가치가 있다고 생각하여 글로 정리해보려고 한다. 이 글에서는 UI 라이브러리의 예시로 리액트를 사용할 것이다.

마이크로 상태관리란

마이크로 상태관리란 개발자가 요구사항 각각의 특수한 목적에 맞는 상태관리 방법을 활용하여 개발을 하는 것을 의미한다.

리액트에서 훅이 등장하기 이전에는, 개발자들은 범용적인 상태관리 라이브러리에 의존을 해왔다. 이러한 범용 라이브러리들은 모든 유즈케이스에 대응할 수 있도록 충분히 일반적이면서 필요한 기능들을 모두 가져야했다.

하지만 훅이 등장하면서, 개발자들이 특수한 목적을 가진 상태관리 방법을 스스로 만들어낼 수 있게 됐다.

따라서, 상태관리 라이브러리들은 지나치게 범용적으로 또는 일반적으로 작성되어 모든 요구사항에 대응할 수 있도록 무거워질 필요가 없어졌다. 이에 따라, 특수한 문제를 해결하는 라이브러리를 만들 수 있게됐다. (Tanstack Query, React Hook Form, …)

즉, 리액트 훅은 상태관리의 경량화와 특수화를 가져왔다.

리액트에서의 전역상태

리액트에서는 전역상태를 관리하기 위해 보통 라이브러리에 의존하지만 리액트만으로 전역상태를 관리하려고 하면 컨텍스트를 사용한다.

컨텍스트의 사용 목적

  1. 여러 하위트리에 서로 다른 값을 제공하기 위해서
  2. prop-drilling으로 인한 중간 컴포넌트의 인터페이스 오염을 막기 위해서 (이 경우 컨텍스트를 해결책으로 보기 보다는 컴포넌트의 구조를 다시 생각하는 것이 좋다.)

컨텍스트는 전역 상태를 관리하기 위해 특별히 설계된 것은 아니지만, 컨텍스트가 제공하는 기능을 활용하여 충분히 전역 상태를 관리하는 방법으로 사용할 수 있다.

전역상태로서의 컨텍스트

기본적인 형태

const App = () => {
  return (
    <Provider>
      <SomeProvider>
        <AnotherProvider>
          {...}
        </AnotherProvder>
      </SomeProvider>
    </Provider>
  )
}

컨텍스트를 전역상태를 다루는데 활용하려면 불필요한 리렌더링 문제를 피하기 위해서는 컨텍스트 자체를 조각내는 방법밖에는 없다. 이것은 프로바이더 중첩 문제를 만들어낸다.

리듀서를 활용한 형태

const Provider = ({children}: { children: ReactNode }) => {
  const [state, dispatch] = useReducer(...);

  return (
    <DispatchContext.Provider value={dispatch}>
      <SomeContext.Provider value={state.some}>
        <AnotherContext.Provider value={state.another}>
          {children}
        </AnotherContext.Provider>
      </SomeContext.Provider>
    </DispatchContext.Provider>
  )
}

리듀서를 활용하면 state로 여러 컨텍스트의 값들을 모두 함께 다룰 수 있기 때문에 하나의 액션으로 여러 컨텍스트를 갱신할 수 있다는 장점이 있다.

컨텍스트의 한계

리액트 컨텍스트는 상태가 갱신되면 모든 컨텍스트 소비자가 리렌더링되어 불필요한 리렌더링이 발생한다. 컨텍스트를 갱신하는 과정에서 컨텍스트의 참조가 변경되기 때문에 당연하다.

개인적인 생각으로 useContextSelector라는 API를 아직도 제공하지 않는 것으로 보아 리액트는 전역상태로서의 컨텍스트를 위한 리렌더링 최적화 기능을 제공할 생각이 없는 거같다. 어쩌면 상태가 변경되면 그냥 다시 리렌더링을 일으켜서 최신의 UI 상태를 반드시 반영하는 것이 리액트의 철학인거 같기도 하다.

리렌더링이 중요한가

리렌더링은 기술적으로 피해야 하는 불필요한 연산이지만 성능에 문제가 없다면 해결할 가치가 없을 수 있다.

따라서 앱의 크기가 작고 성능에 문제가 없다면 전역상태 관리로 컨텍스트를 활용할 때 컨텍스트를 여러 조각으로 굳이 무조건 나눠야 하는것은 아니다.

구독을 이용한 모듈상태

모듈 상태

// 파일 단위의 모듈 상태
let state = { count: 0 }
export const getState = () => state
export const setState = (nextState) => {
  state = nextState
}
// 클로저를 활용한 모듈 상태
const createStore = () => {
  let state = { count: 0 }
  const getState = () => state
  const setState = (nextState) => {
    state = nextState
  }
  return state
}

책에서는 모듈상태를 전역적이거나 파일의 스코프 내에서 정의된 변수를 의미한다. 이 글에서는 리액트 렌더링 시스템에서 벗어난 상태들을 모두 모듈 상태로 봐도 된다.

전역상태로서의 모듈 상태

모듈 상태는 리액트 렌더링 시스템 밖에 벗어나 있으므로 모듈 상태의 변경은 리액트에 반영되지 않는다. 모듈 상태는 구독을 이용하여 이 문제를 해결한다.

const store = createStore({ count: 0 })
const useStore = (store) => {
  const [state, setState] = useState(store.getState())

  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(store.getState())
    })
    setState(store.getState())
    return unsubscribe
  }, [store])

  return [state, store.setState]
}

위의 코드를 보았을 때 모듈 상태는 구독을 이용하여 상태 갱신에 대한 알림을 리액트에 보내고 리액트는 그 알림을 수신하여 리액트의 상태를 외부상태와 동기화하는 것을 확인할 수 있다.

모듈 상태는 이러한 방식으로 리액트 렌더링 시스템과 연결된다.

선택자를 이용한 리렌더링 최적화

리렌더링 최적화야말로 모듈 상태를 이용하는 것이 리액트 컨텍스트를 이용하는 것과 차별화되는 부분이다.

const useStoreSelector = <T, S>(store: Store<T>, selector: (state: T) => S) => {
  // 구독할 상태의 범위를 좁혀 새로운 리액트의 상태를 만들어낸다.
  const [state, setState] = useState(() => selector(store.getState()))

  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      // 선택자로 범위를 좁힌 상태의 참조가 변경되지 않았다면 리액트에서 setState를 하더라도 리렌더링이 되지 않는다. 즉, 리렌더링 최적화가 선택자를 이용하여 구현된 부분
      setState(selector(store.getState()))
    }, [store, selector])

    setState(selector(store.getState()))
    return unsubscribe
  }, [store, selector])

  return state
}

모듈 상태가 리렌더링 최적화를 할 수 있는 이유는 선택자를 이용하여 구독할 상태의 범위를 특정하여 잡을 수 있다는 것이다.

위의 코드는 두 가지를 알려준다.

  1. 선택자를 이용해서 구독할 특정 상태만큼의 리액트 상태를 새로 만든다.
  2. 모듈로부터 상태 갱신에 대한 알림을 받고 리액트 상태와 동기화를 시킬 때, 구독한 특정 상태에 변경이 없으면 리렌더링이 일어나지 않는다.

리액트 컨텍스트가 컨텍스트 소비자를 모두 리렌더링 시키는 이유는 리액트가 컨텍스트 소비자들이 필요로 하는 상태만 바라볼 수 있는 기능을 제공해주지 않기 때문인데, 구독을 사용한 모듈 상태는 정확히 이 부분을 해결한다.

useSyncExternalStore 활용하기

리액트에서는 컨텍스트에 리렌더링 최적화 기능을 제공하지 않는 대신에 외부 모듈 상태와의 연결을 쉽게할 수 있도록 useSyncExternalStore 훅을 제공한다.

위의 useStoreSelector 훅을 useSyncExternalStore 훅을 활용하여 다시 작성하면 훨씬 더 간단히 구현을 할 수 있게된다.

const useStoreSelector = <T extends unknown, S extends unknown>(
  store: Store<T>,
  selector: (state: T) => S,
) => {
  return useSyncExternalStore(store.subscribe, () =>
    selector(store.getSnapshot()),
  )
}

컨텍스트와 모듈상태의 장점만 활용하기

리액트 컨텍스트는 하위 트리마다 다른 값을 제공하기 편하지만 불필요한 리렌더링을 피하기 힘들며, 모듈 상태는 불필요한 리렌더링을 없애지만 하위 트리마다 다른 값을 제공하기 불편하다.

리액트 컨텍스트와 모듈 상태 두 가지를 함께 활용하여 장점만 남기고 단점은 없앨 수 있다.

컨텍스트에서 모듈 상태 사용하기

type State = { count: number; text?: string }

const StoreContext = createContext<Store<State> | null>(null)

const StoreProvider = <T extends unknown>({
  initialState,
  children,
}: {
  initialState: State
  children: ReactNode
}) => {
  // 리액트의 상태를 활용하지 않고 렌더링 시스템을 우회하는 ref로 모듈 상태를 사용한다.
  const storeRef = useRef<Store<State> | null>(null)
  if (!storeRef.current) {
    storeRef.current = createStore(initialState)
  }
  return (
    <StoreContext.Provider value={storeRef.current}>
      {children}
    </StoreContext.Provider>
  )
}

위의 코드에서 중요한 점은 useRef를 이용해서 리액트의 렌더링 시스템을 우회하여 모듈 상태를 활용하고 있다는 것이다.

위와 같은 구현은 프로바이더마다 리액트 자체의 렌더링 시스템의 영향을 받지않는 각각의 모듈상태를 활용하게 되며 하위 트리에 다른 값을 제공하는 컨텍스트의 기능을 그대로 활용할 수 있게한다.

const useSelector = <S extends unknown>(selector: (state: State) => S) => {
  const store = useContext(StoreContext)
  if (store === null) throw new Error('...')

  return useStoreSelector(store, selector)
}
const Component = () => {
  const state = useSelector((state) => state.[구독할 범위의 상태])
  return (...)
}

컨텍스트 소비자는 useSelector를 이용하여 필요한 범위만큼만 모듈 상태를 구독하여 리액트 상태를 만들어 사용하게 된다.

최종 사용 형태

const App = () => {
  return (
    <>
      <h1>Using default store</h1>
      <Component />
      <StoreProvider initialState={{ count: 10 }}>
        <h1>Using store provider</h1>
        <Component />
        <StoreProvider initialState={{ count: 20 }}>
          <h1>Using inner store provider</h1>
          <Component />
        </StoreProvider>
      </StoreProvider>
    </>
  )
}

컨텍스트와 구독을 이용한 모듈 상태의 장점만 남긴 모습을 확인할 수 있다.