Logo
Published on

리액트 상태 관리 라이브러리 직접 구현해보기

Authors
  • avatar
    Name
    Kim Hyori
    Twitter

해당 내용은 모던 리액트 Deep Dive를 학습하며 작성한 글입니다.

들어가며

나는 리액트를 학습하며 상태 관리에 대해 아래 순서대로 이해하고 있었다.

  • 리액트에는 props와 state가 있다.
    • props는 부모 컴포넌트에서 자식 컴포넌트로 전달되는 데이터고, state는 컴포넌트 내부에서 관리되는 데이터다.
    • 외부에서 상태를 전달해주려면 props로 전달한다.
    • 이 과정에서 depth가 깊어지면 props drilling 문제가 발생해 전달이 번거로워진다.
  • 라이브러리를 설치하지 않고 전역 상태를 관리하기 위해 Context API를 사용할 수 있다.
    • 하지만 Context API는 순수 리액트에서 제공해주는 기능이기 때문에 사용하기 번거롭고, 최적화가 어렵다(?)
  • 더 효율적으로 데이터를 관리하기 위해 리덕스나 Recoil 같은 상태 관리 라이브러리를 사용한다.

    • 사용해서 편리하다.
  • 끝(?)


잘못된 정보 바로잡기

기존에 내가 알고 있던 내용은 몇 개는 맞고, 몇 개는 아예 틀린 정보였다. 리액트 딥다이브 스터디를 진행하면서 틀렸던 정보를 바로 잡고, 상태 관리에 대해 다시 이해할 수 있었다.

먼저 Context API의 경우, 상태 관리를 해주는 역할이 아니라 단순히 상태 주입을 도와주는 역할이다. 즉, 몇 단계 건너 뛰어서 상태를 전달해준다 해도 렌더링을 막아주거나 최적화를 해주지 않는다.
그럼 여기서 상태 관리 라이브러리의 역할도 바로 알 수 있다. 불필요한 렌더링을 막아주고, 최적화를 해주는 것이 상태 관리 라이브러리의 역할이다.

또한 리덕스가 편리한 것도 아니다. 리덕스에서 하나의 상태를 바꾸려면 해야 할 일이 많다. 액션의 타입을 선언하고, 액션을 수행할 creator를 만들어야 하고, dispatcher와 selector도 필요하고, 상태가 어떤 식으로 변경될지도 직접 다 정해줘야 한다.

그렇다면 리덕스를 아직도 많이 사용하는 이유는 뭘까?

리덕스는 단방향 데이터 흐름을 Elm 아키텍처로 구현해 리액트 생태계에 강렬한 인상을 남긴 존재다. 지금과 같이 훅이 나오기 전까지는 리덕스가 상태 관리 라이브러리로서 가장 강력한 후보였다. 따라서 지금까지도 리액트와 리덕스를 한 세트로 생각하는 개발자들이 많다고 한다.


최근 상태 관리 라이브러리의 특징

요즘 쓰이는 Recoil, Jotai, Zustand, Valtio 등의 상태 관리 라이브러리는 기존의 리덕스와는 큰 차이가 있다.

바로 훅을 활용해 작은 크기의 상태를 효율적으로 관리한다는 것이다. 이러한 라이브러리들은 리덕스와 달리 별도의 액션 타입, 액션 생성자, 디스패처, 셀렉터 등을 만들 필요가 없다. 또한 훅을 활용하기 때문에 모두 리액트 16.8 버전 이상을 요구한다.

// Recoil
const counter = atom({ key: 'counter', default: 0 })
const todoList = useRecoilValue(counter)

// Jotai
const countAtom = atom(0)
const [count, setCount] = useAtom(countAtom)

// Zustand
const useStore = create((set) => ({
  count: 0,
  inc: () => set((state) => ({ count: state.count + 1 })),
  dec: () => set((state) => ({ count: state.count - 1 })),
}))
const count = useStore((state) => state.count)

// Valtio
const state = proxy({ count: 0 })
const snap = useSnapshot(state)
state.count++

코드를 보면 전부 use로 시작하는 커스텀 훅을 사용하는 것을 볼 수 있다. 이러한 훅을 사용하면 상태를 효율적으로 관리할 수 있고, 더 쉽게 최적화할 수 있다.



직접 구현해보기

그렇다면 우리가 도전해볼 것은 무엇일까? 바로 리액트 훅을 활용해 상태를 관리하는 라이브러리를 직접 구현해보는 것이다.

첫 번째 시도: useState로 상태 관리하기

useCounter라는 커스텀 훅을 만들어서 카운터를 관리하는 컴포넌트를 만들어보자.

function useCounter(initCount: number = 0) {
  const [counter, setCounter] = useState(initCount)

  // 카운터 증가 함수
  function inc() {
    setCounter((prev) => prev + 1)
  }

  return { counter, inc }
}

function Counter1() {
  const { counter, inc } = useCounter()

  return (
    <>
      <h3>Counter1: {counter}</h3>
      <button onClick={inc}>+</button>
    </>
  )
}

function Counter2() {
  const { counter, inc } = useCounter()

  return (
    <>
      <h3>Counter2: {counter}</h3>
      <button onClick={inc}>+</button>
    </>
  )
}

위 코드를 보면 useCounter라는 커스텀 훅을 만들어서 Counter1Counter2 컴포넌트에서 사용하고 있다. 이렇게 하면 Counter1Counter2 컴포넌트는 서로 독립적으로 상태를 관리할 수 있다.

counter1

react 코드를 뜯어보면 useState 훅이 useReducer로 구현되어 있기 때문에, useReducer로도 동일하게 커스텀 훅을 만들 수 있다. 그러나 useStateuseReducer로는 컴포넌트별로 초기화되기 때문에, 지역 상태 관리에만 용이할 뿐 전역 상태를 관리할 수는 없다.

그렇다면 두 Counter 컴포넌트가 같은 상태를 공유하도록 만들기 위해선 어떻게 해야 할까?

정말 간단하게 생각하면 props로 똑같은 상태를 전달해주면 되겠지만, 이러한 방식은 props drilling 문제가 발생한다. props drilling 없이 같은 전역 상태를 참조하도록 구현하기 위한 코드를 작성해보자.


두 번째 시도: 훅 없이 외부 객체만으로 상태 관리하기

useState는 리액트가 만든 클로저 내부에서 관리되므로 해당 컴포넌트에서만 사용할 수 있다. 이 useState를 리액트 클로저가 아닌 다른 클로저로 분리하면 전역 상태를 관리할 수 있을 것이다.

즉, 완전히 다른 곳에서 초기화된 채로 관리하면 그 상태를 참조하는 스코프 내에서는 해당 객체의 값을 공유해서 사용할 수 있을 것이다.

counter.ts라는 파일을 만들고 훅 없이 상태를 관리하는 코드를 작성해보자.

export type State = { counter: number }

let state: State = { counter: 0 }

// getter
export function get(): State {
  return state
}

// useState와 동일하게 게으른 초기화 함수나 값을 받는다.
type Initializer<T> = T extends any ? T | ((prev: T) => T) : never

// setter
export function set<T>(nextState: Initializer<T>) {
  state = typeof nextState === 'function' ? nextState(state) : nextState
}

그리고 이 파일을 Counter1Counter2 컴포넌트에서 사용해보자.

function Counter1() {
  const state = get()

  function handleClick() {
    set((prev: State) => ({ counter: prev.counter + 1 }))
  }

  return (
    <>
      <h3>Counter1: {state.counter}</h3>
      <button onClick={handleClick}>+</button>
    </>
  )
}

function Counter2() {
  const state = get()

  function handleClick() {
    set((prev: State) => ({ counter: prev.counter + 1 }))
  }

  return (
    <>
      <h3>Counter2: {state.counter}</h3>
      <button onClick={handleClick}>+</button>
    </>
  )
}

그럼 이제 Counter1Counter2 컴포넌트는 같은 상태를 참조하게 된다. 하지만 한 가지 큰 문제가 있다.

counter2

바로 브라우저 화면 상에서 + 버튼을 클릭해도 숫자가 변하지 않는다는 것이다. 그럼 set 함수 내에서 console.log를 찍어보자.

counter3

콘솔 창에서는 정상적으로 상태가 변경되는 것을 확인할 수 있다.

리액트에서는 새로운 상태를 사용자의 UI에 보여주려면 반드시 리렌더링을 해야 한다.
리렌더링은 다음과 같은 조건일 때 일어난다.

  • 클래스 컴포넌트의 setState가 실행되는 경우
  • 클래스 컴포넌트의 forceUpdate가 실행되는 경우
  • 함수 컴포넌트의 useState()의 두 번째 배열 요소인 setter가 실행되는 경우
  • 함수 컴포넌트의 useReducer()의 두 번째 배열 요소인 dispatch가 실행되는 경우
  • 컴포넌트의 key props가 변경되는 경우
  • props가 변경되는 경우
  • 부모 컴포넌트가 리렌더링될 경우

위에서 훅 없이 작성한 코드는 리렌더링을 일으키는 조건들 중 하나도 만족하지 않기 때문에 화면에 반영되지 않는다.

그렇다면 우리가 우선적으로 구현해야 하는 목표는 다음과 같이 정리할 수 있다.

  • 컴포넌트 외부에 상태를 두고 여러 컴포넌트가 함께 사용할 수 있어야 한다.
  • 상태가 바뀌면 상태를 참조하는 모든 컴포넌트는 상태의 변화를 알아채고 리렌더링을 해야 한다.

세 번째 시도: 외부 객체와 훅을 함께 활용해 상태 관리하기

먼저 타입을 먼저 선언한다.

type Initializer<T> = T extends any ? T | ((prev: T) => T) : never

type Store<State> = {
  get: () => State
  set: (action: Initializer<State>) => State
  subscribe: (callback: () => void) => void
}
  • get은 함수이기 때문에 항상 최신 상태를 반환한다.
  • set 함수는 새로운 값을 만들어서 상태를 변경하고, 변경된 상태를 반환한다.
  • subscribe 함수는 상태가 변경될 때마다 호출되는 콜백 함수를 등록한다.

이제 타입대로 구현해보자.

export const createStore = <State extends unknown>(
  initialState: Initializer<State>
): Store<State> => {
  // 게으른 초기화 함수나 값을 받는다.
  // 클로저 내부의 state를 변경하기 위해 let으로 선언
  let state = typeof initialState === 'function' ? initialState() : initialState

  // 유일한 값만을 가지는 Set을 사용
  const callbacks = new Set<() => void>()

  const get = () => state
  const set = (nextState: State | ((prev: State) => State)) => {
    state =
      typeof nextState === 'function' ? (nextState as (prev: State) => State)(state) : nextState

    // 상태가 변경되면 모든 콜백을 실행한다.
    // 즉, 모든 구독자에게 알린다.
    callbacks.forEach((callback) => callback())

    return state
  }

  // 구독자를 추가하고, 구독을 취소할 수 있는 함수를 반환한다.
  const subscribe = (callback: () => void) => {
    callbacks.add(callback)

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

  return { get, set, subscribe }
}

이렇게 구현한 createStore 함수를 리액트 훅을 통해 리렌더링을 할 수 있도록 만들 수 있다. 앞서 봤던 recoil이나 zustand처럼 커스텀 훅을 만드는 것이다.

export const useStore = <State extends unknown>(store: Store<State>) => {
  const [state, setState] = useState(() => store.get())

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

  return [state, store.set] as const
}

useStore는 store를 인수로 받는다. 그리고 useState를 통해 state를 관리하는데, 초기값은 store.get()으로 설정한다. 그리고 useEffect를 통해 subscribe에 setState를 수행하는 함수를 등록하도록 했다. 즉, 이 setState를 수행하는 함수는 위에서 봤던 callbacks에 add 되는 것이다.
이때 클린업 함수로 unsubscribe를 반환함으로써 안전하게 구독을 취소할 수 있다.


Counter 컴포넌트에서 useStore를 사용해보자.

const counterStore = createStore({ count: 0 })

function Counter1() {
  const [state, setState] = useStore(counterStore)

  function handleClick() {
    setState((prev) => ({ count: prev.count + 1 }))
  }

  return (
    <>
      <h3>Counter1: {state.count}</h3>
      <button onClick={handleClick}>+</button>
    </>
  )
}

function Counter2() {
  const [state, setState] = useStore(counterStore)

  function handleClick() {
    setState((prev) => ({ count: prev.count + 1 }))
  }

  return (
    <>
      <h3>Counter2: {state.count}</h3>
      <button onClick={handleClick}>+</button>
    </>
  )
}

counter4 브라우저에서 확인해보면 우리가 의도한 대로 두 컴포넌트가 같은 상태를 참조하면서, 상태가 바뀔 때마다 동시에 바뀐 값으로 렌더링이 되는 것을 알 수 있다. 여기서 더 고려해야할 부분이 있다. 현재 store를 객체로 전달해주면, 원하는 키에 해당하는 값만 변경해도 이 상태를 구독하는 모든 컴포넌트가 리렌더링된다. 이는 성능상으로 좋지 않다.

원하는 값이 변했을 때만 리렌더링을 하도록 하기 위해 useStore의 인수로 selector를 추가할 수 있다.

export const useStore = <State extends unknown, Value extends unknown>(
  store: Store<State>,
  selector: (state: State) => Value // selector 함수를 추가로 받는다.
) => {
  // 초기값으로 selector 함수의 반환값을 할당한다.
  const [state, setState] = useState(() => selector(store.get()))

  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      const value = selector(store.get())
      setState(value)
    })

    return unsubscribe
  }, [store, selector])

  return state
}

selector의 매개변수로 store 전체를 전달해준다. 그리고 useState의 초기값으로 인수로 받은 selector 함수의 반환값을 할당한다. 따라서 아무리 store의 값이 변경된다 하더라도 selector(store.get())이 변경되지 않는 이상 리렌더링이 일어나지 않는다.

여기서 store.set이 없어진 걸 확인할 수 있다. 이 함수는 컴포넌트 내부에서 사용하면 된다.

// text 추가
const store = createStore({ count: 0, text: 'hi' })

export function Counter() {
  // 두 번째 인수로 selector 함수를 전달한다.
  // useCallback을 통해 값이 변하지 않는 함수를 만들어 전달한다.
  const counter = useStore(
    store,
    useCallback((state) => state.count, [])
  )

  function handleClick() {
    // 직접 store에 접근해 set 함수를 사용한다.
    store.set((prev) => ({ ...prev, count: prev.count + 1 }))
  }

  return (
    <>
      <h3>Counter: {counter}</h3>
      <button onClick={handleClick}>+</button>
    </>
  )
}

export function Input() {
  const name = useStore(
    store,
    useCallback((state) => state.text, [])
  )

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    store.set({ ...store.get(), text: e.target.value })
  }

  return (
    <>
      <input
        type="text"
        value={name}
        onChange={handleChange}
        className="border-2 border-gray-500 p-2"
      />
    </>
  )
}

counter5 이제 selector를 통해 원하는 값만 리렌더링할 수 있게 되었다.

하지만 번거로운 점도 추가되었다. 컴포넌트마다 useStore를 사용할 때 useCallback을 통해 selector 함수를 만들어 전달해주어야 한다. 그리고 useCallback으로 감싸주지 않는다면 다른 변수로 인해 리렌더링이 일어날 때마다 함수가 새로 생성되어 store의 subscribe를 반복적으로 실행하게 된다.

결과적으로 전역적으로 상태를 관리하면서 원하는 값만 리렌더링하는 상태 관리 라이브러리를 직접 구현할 수 있었다.


사실 리액트 훅 중에 useSubscription라는 훅이 있다. 이 훅을 사용하면 위에서 작성한 내용을 동일하게 구현할 수 있다.
현재는 useSubscription이 18 버전부터 tearing 현상을 해결하기 위해 useSyncExternalStore로 대체되었다.

useStoreuseSubscription은 큰 차이가 있다.

useSubscription은 selector와 subscribe에 대한 비교도 추가되어 있다. useStore에서는 useEffect의 의존성 배열에 있는 selector와 subscribe가 변경되어도 아무런 처리를 하지 않는다. useSubscription에서는 예외 처리를 해줬기 때문에 훨씬 안전하게 사용할 수 있다.


useStore 훅은 한 가지 문제가 더 있다. 훅이 스토어와 1:1 관계를 가지기 때문에, 여러 개의 스토어를 사용할 때는 여러 번의 useStore를 사용해야 한다. 이 훅이 어느 스토어에 속하는지도 고려해야 하기 때문에 훅의 이름과 스토어의 이름에 의존하게 된다는 문제가 발생하게 된다.

이 문제를 해결하기 위해 맨 처음 다뤘던 Context API를 활용할 수 있다.


네 번째 시도: 만든 스토어를 Context API로 관리하기

리액트의 Context를 활용해 스토어를 하위 컴포넌트에 주입시키는 방법이 있다. 컴포넌트는 주입된 스토어에 대해서만 접근할 수 있게 된다.

export const CounterStoreContext = createContext<Store<CounterStore>>(
  createStore<CounterStore>({ count: 0, text: 'hi' })
)

export const CounterStoreProvider = ({
  initialState,
  children,
}: PropsWithChildren<{ initialState: Initializer<CounterStore> }>) => {
  const storeRef = useRef<Store<CounterStore>>()

  if (!storeRef.current) {
    storeRef.current = createStore(initialState)
  }

  return (
    <CounterStoreContext.Provider value={storeRef.current}>{children}</CounterStoreContext.Provider>
  )
}

CounterStoreProvider에서 useRef를 통해 value props 값이 변경되지 않도록 했다. 오직 첫 렌더링 시에만 createStore를 호출해 스토어를 만들어 값을 내려준다. 이제 이 Context에 접근할 수 있는 useCounterContextSelector 훅을 만들 수 있다.

export const useCounterContextSelector = <State extends unknown>(
  selector: (store: CounterStore) => State
) => {
  const store = useContext(CounterStoreContext)
  const subscription = useStore(store, selector)

  return [subscription, store.set] as const
}

책에서는 useSubscription으로 subscription을 만들었지만, 지금은 쓰이지 않는 훅이기 때문에 useStore를 대신 사용했다.

function Counter() {
  const [counter, setStore] = useCounterContextSelector(useCallback((state) => state.count, []))

  function handleClick() {
    setStore((prev) => ({ ...prev, count: prev.count + 1 }))
  }

  return (
    <>
      <h3>Counter: {counter}</h3>
      <button onClick={handleClick}>+</button>
    </>
  )
}

이제 Counter 컴포넌트를 다시 수정했다. Context API를 사용하기 전에는 store도 직접 생성하고, 그 store에 직접적으로 접근해 set 함수를 사용했지만, useCounterContextSelector 훅에서 내려준 값들만을 활용해서 간단하게 상태를 관리할 수 있게 되었다. counter6 css 스타일까지 입혀주었다.


정리해보면

  • 컴포넌트 외부에서 전역 상태를 관리할 수 있다.
  • 원하는 값만 리렌더링 할 수 있다.
  • 스토어를 직접 건드리지 않고, 훅을 통해 간단하게 상태를 관리할 수 있다.
  • 훅과 스토어의 이름에 의존하지 않고 편리하게 사용할 수 있다.

이제 처음에 언급한 라이브러리들이 어떤 식으로 구현되어 있는지 조금이나마 이해할 수 있게 되었다.

RecoilJotai는 우리가 구현했던 Context와 Provider, 훅을 기반으로 작은 상태를 관리하는 데 초점을 맞추고 있다.
Zustand는 리덕스와 비슷하게 하나의 큰 스토어를 만들어 상태를 관리한다. Context가 아닌 클로저를 활용해 상태를 관리한다.



마치며

리액트 상태 관리 라이브러리를 직접 구현해보면서 상태 관리에 대해 더 깊게 이해할 수 있었다. 그리고 상태 관리 라이브러리가 어떤 식으로 동작하는지, 어떤 문제점을 해결하기 위해 만들어졌는지 알 수 있었다.

이제 상태 관리 라이브러리를 사용할 때 단순히 공식 문서를 따라하면서 코드를 작성하기 보다는, 어떤 식으로 렌더링과 최적화를 하는지도 이해하며 작성해야겠다는 생각이 든다. 안 그래도 새 프로젝트에서 zustand를 새롭게 사용하게 됐는데, 코드를 뜯어보면서 이해해보는 시간을 가져야겠다.