thumbnail
기술아티클
Redux Deep Dive 3. UI 바인딩(feat. react-redux)
Redux가 UI 라이브러리와 함께 사용되는 방식을 살펴봅니다.
October 28, 2024

앞선 아티클에서는 Redux의 동작 흐름을 소개하기 위해 UI 라이브러리를 사용하지 않고, 바닐라 자바스크립트로 UI를 구현했습니다. 그러나 대부분의 경우 React, Vue와 같은 UI 라이브러리와 함께 Redux를 사용하므로, 이와 같은 조합에서 Redux가 어떻게 동작하는지를 살펴볼 필요가 있습니다.

이번 아티클에서는 가장 널리 사용되는 UI 라이브러리인 React와 Redux를 함께 사용할 때, Redux의 상태 변경이 어떻게 UI에 전파되는지, 그리고 이 과정에서 어떤 방식으로 최적화가 이루어지는지를 자세히 분석해보겠습니다.

React의 기본 개념을 이해하고 있다면 이번 내용을 보다 쉽게 따라오실 수 있습니다. React에 익숙하지 않으시다면 React tutorial을 먼저 참고해보시길 권장합니다.

직접 UI 바인딩하기

이전 아티클에서 살펴본 내용중에서 UI 바인딩과 관련된 내용을 간단하게 요약하면 HTML 렌더링을 책임지는 render함수가 store를 구독함으로써 store가 변경될때마다 render함수가 실행되어 최신의 상태가 UI에 반영된다 였습니다. 코드로 정리해보면 아래와 같습니다.

// 1. 스토어 생성
const store = createStore(counterReducer)

// 2. render 함수가 store 구독
store.subscribe(render)

// 3. render 함수
function render() {
  const state = store.getState()
  document.getElementById("value").innerHTML = state.value.toString()
}

// 4. render실행
render()

// 5. html 요소에 이벤트 리스너 등록
document.getElementById("increment").addEventListener("click", function () {
  store.dispatch({ type: "counter/incremented" })
})

바닐라 자바스크립트의 논리를 React에 적용한다면 React의 컴포넌트가 store를 구독하고 store가 변경될때 컴포넌트가 리렌더링 되도록 하는것이므로 어렵지 않게 React와 Redux를 연결할수 있을것으로 보입니다.

다만 바닐라 자바스크립트와 달리 React는 일반적으로 html을 변경하는 렌더링을 개발자가 원하는 타이밍에 직접 수행할수 없으므로(클래스형 컴포넌트의 render메서드나 함수형 컴포넌트 자체를 우리가 실행하지 않는다는것을 생각해보세요) 렌더링을 제어하는 방법을 알아야합니다.

클래스형의 경우 강제로 렌더링을 시켜주는 forceUpdate메서드가 존재하며 함수형의 경우 클래스형의 forceUpdate같은 훅은 없지만 useState훅을 적절하게 사용해 개발자가 원하는 타이밍에 렌더링을 실행할수 있습니다.

간단한 예시로 클래스형 컴포넌트를 사용하여 React에 Redux를 바인딩 해보겠습니다. 렌더링을 책임지는 forceUpdate메서드를 store에 구독시키기만 하면 store가 변경될때마다 forceUpdate메서드가 실행되면서 컴포넌트가 리렌더링 되어 최신의 상태가 UI에 반영됩니다.

class App extends React.Component {
  componentDidMount() {
    this.unsubscribe = store.subscribe(this.handleChange.bind(this))
  }

  componentWillUnmount() {
    this.unsubscribe()
  }

  handleChange() {
    this.forceUpdate()
  }

  increaseCount() {
    store.dispatch({ type: "counter/incremented" })
  }

  render() {
    return (
      <div>
        <div>{store.getState().count}</div>
        <button onClick={this.increaseCount}>+</button>
      </div>
    )
  }
}

하지만 이러한 코드를 모든 컴포넌트에 일일이 적용하면, 중복된 코드가 늘어나고 실수로 인한 누락이 발생할 수 있습니다. 따라서 일반적으로는 UI와 Redux 간의 바인딩을 도와주는 라이브러리를 사용합니다. 그 대표적인 예가 바로 React와 Redux를 연결해주는 React-Redux입니다. 이러한 라이브러리들은 상태 변경에 따른 리렌더링 최적화까지 함께 제공하기 때문에, 실제 프로젝트에서는 사실상 필수적으로 사용됩니다.

이제부터는 가장 널리 사용되는 UI 바인딩 라이브러리인 React-Redux를 분석하면서, 이 라이브러리가 어떻게 Redux와 UI 간의 연결 로직을 공통화했는지, 그리고 리렌더링 과정을 어떻게 최적화했는지를 살펴보겠습니다.

react-redux 분석

초기 React-Redux와 달리 현재 버전(9.3.0)은 클래스형과 함수형 컴포넌트를 모두 지원하지만 이번 아티클의 목표는 React-Redux의 코어기능을 살펴보는것이므로 함수형 컴포넌트에서 사용하는 방식만 살펴보겠습니다. 여유가 되신다면 클래스형 컴포넌트도 살펴보시는것을 추천드립니다. 라이브러리가 원래 클래스형에서 출발했던 만큼 많은 것을 얻을수 있을것입니다.

예제코드

본격적인 분석에 들어가기에 앞서, React와 React-Redux, Redux를 같이 사용하는 예제 코드를 살펴보겠습니다.

// reducer 함수
function counterReducer(state = { value: 0 }, action) {
  switch (action.type) {
    case "counter/incremented":
      return { ...state, value: state.value + 1 }
    default:
      return state
  }
}

// store 생성
const store = createStore(counterReducer)

// root 컴포넌트
export function App() {
  const count = useSelector(state => state.counter.value)

  const dispatch = useDispatch()

  return (
    <div>
      <div>
        <button
          aria-label="Increment value"
          onClick={() => dispatch(increment())}
        >
          Increment
        </button>
        <span>{count}</span>
        <button
          aria-label="Decrement value"
          onClick={() => dispatch(decrement())}
        >
          Decrement
        </button>
      </div>
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))

// store가 포함된 Provider로 감싼 root 컴포넌트 렌더링
root.render(
  <Provider store={store}>
    <App />
  </Provider>
)

root 컴포넌트를 store를 인자로 받는 Provider컴포넌트로 감싸고 있으며 컴포넌트에서는 useDispatch훅에서 dispatch함수를 가져오고 useSelector훅에서 상태를 가져오는 동작만으로 컴포넌트가 스토어를 구독하여 최신의 상태를 받아올 수 있음을 알 수 있습니다.

어떻게 이러한 일이 일어나는지 알아내기 위해 Provider컴포넌트, useDispatch훅, useSelector훅을 하나씩 분석해보겠습니다.

Provider

React-Redux를 사용하기 위해서는 root 컴포넌트를 React-Redux에서 제공하는 Provider컴포넌트로 감싸주어야 하며 이때 createStore함수로 생성한 store를 넘겨야합니다. 이 컴포넌트는 어떤 역할을 하고 있기에 반드시 이러한 작업을 해야할까요? 의문을 풀기위해 Provider컴포넌트를 살펴보겠습니다.

function Provider({ store }) {
  const subscription = createSubscription(store) // 스토어 구독과 관련된 기능 제공
  const contextValue = { store, subscription }
  const Context = React.createContext(null)

  return <Context.Provider value={contextValue}>{children}</Context.Provider>
}

React-Redux를 사용하기 위해서는 루트 컴포넌트를 React-Redux에서 제공하는 Provider 컴포넌트로 감싸고, createStore 함수로 생성한 store를 해당 컴포넌트에 전달해야 합니다. 그렇다면 왜 반드시 이러한 작업이 필요할까요?

이 의문을 해결하기 위해, 이제 Provider 컴포넌트가 어떤 역할을 수행하는지 자세히 살펴보겠습니다.

function useReduxContext() {
  return React.useContext(context)
}

useContext를 사용하여 컨텍스트내부의 값을 가져오는 역할을 하는 간단한 코드입니다. 여기서 인자내부에 있는 contextProvider컴포넌트에서 생성한 context라고 생각하면되겠습니다. 개발자가 직접 사용할일은 없지만 useDispatch훅, useSelector훅에서 내부적으로 이용하게 됩니다.

결국 Provider컴포넌트로 감싸지 않으면 store를 컴포넌트 내부에서 제공받을수 없으므로, React-Redux를 사용하기 위해서는 반드시 root 컴포넌트를 Provider컴포넌트로 감싸주어야합니다.

useDispatch

dispatch함수를 리턴하는 useDispatch훅을 살펴보겠습니다.

const useDispatch = () => {
  const { store } = useReduxContext()
  return store.dispatch
}

useReduxContext훅을 이용해 store를 가져온뒤 storedispatch를 반환하는 훅으로 이름과 같이 dispatch함수를 반환하는 기능 외에 별다른 특이점은 없습니다.

useSelector

Provider컴포넌트, useDispatch훅에서 컴포넌트 구독 및 리렌더링 관련 로직을 찾지 못했으므로 useSelector훅에 이러한 로직이 있을것임을 짐작해 볼 수 있습니다. useSelector훅은 인자로 상태를 인자로 받아 원하는 값을 리턴하는 셀렉터함수와 동등 비교시 사용하는 비교함수를 넘길수 있습니다.

const useSelector = (
  selector,
  equalityFnOrOptions,
) => {
  const equalityFn = equalityFnOrOptions || ((a, b) => a === b)

  const {
    store,
    subscription,
  } = useReduxContext()

  const wrappedSelector = React.useCallback<typeof selector>(
    (state) => {
      return selector(state)
    },
    [selector],
  )

  const selectedState = useSyncExternalStoreWithSelector(
    // store.subscribe 함수와 비슷한 기능을 합니다.
    subscription.addNestedSub,
    store.getState,
    wrappedSelector,
    equalityFn,
  )

  return selectedState
}

useSelector훅을 살펴보면 useReduxContext훅을 이용해 storesubscription를 가져온뒤, equalityFn, selector를 래핑하거나 기본값을 적용한뒤, useSyncExternalStoreWithSelector함수에 store, subscription, selector, equalityFn를 넘겨 실행한뒤 선택된 상태를 받아오고 있습니다.

따라서 핵심적인 로직이 useSyncExternalStoreWithSelector함수에 있을것으로 추측되므로 해당 함수를 분석해보겠습니다.

export function useSyncExternalStoreWithSelector(
  subscribe,
  getSnapshot,
  selector,
  isEqual
) {
  const getSelection = useMemo(() => {
    let hasMemo = false
    let memoizedSnapshot
    let memoizedSelection: Selection
    const memoizedSelector = (nextSnapshot: Snapshot) => {
      // 처음 Selector 실행시 초기화 하는 로직입니다.
      if (!hasMemo) {
        hasMemo = true
        memoizedSnapshot = nextSnapshot
        memoizedSelection = selector(nextSnapshot)
        return memoizedSelection
      }

      // 이전 상태를 재사용할수 있다면 재사용 하기위해 변수에 할당합니다.
      const prevSnapshot = memoizedSnapshot
      const prevSelection = memoizedSelection

      // 이전 상태와 다음 상태를 얕은 비교하여 같은경우 이전 selection 결과를 그대로 리턴합니다.
      if (Object.is(prevSnapshot, nextSnapshot)) {
        return prevSelection
      }

      // seletor 함수를 실행해 새로운 selection을 만듭니다.
      const nextSelection = selector(nextSnapshot)

      // 비교함수가 있는경우 이를 이용해 Selection을 비교한뒤 같다면 이전 Selection을 내보냅니다.
      if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
        return prevSelection
      }

      //예외 조건에 걸리지 않았다면 스냅샷 결과를 저장합니다.
      memoizedSnapshot = nextSnapshot
      memoizedSelection = nextSelection
      return nextSelection
    }

    // 앞서 살펴본 Selector 함수를 실행하는 함수를 리턴합니다.
    return () => memoizedSelector(getSnapshot())
  }, [getSnapshot, selector, isEqual])

  const value = useSyncExternalStore(subscribe, getSelection)

  return value
}

해당 함수에서 중요한 코드는 가장 마지막줄에서 실행하는 useSyncExternalStore함수입니다. 이 함수는 subscribe함수와 getSelection함수를 넘기면 store가 변경되었을때 getSelection함수를 실행해 최신의 선택된 상태를 반환해줍니다. 이 함수는 아래에서 다시 살펴볼 예정이므로 동작만 이해한뒤 인자로 넘기는 getSelection 함수를 살펴보겠습니다.

getSelection함수는 useMemo훅으로 래핑되어있으며, 해당 함수를 실행하면, memoizedSelector함수에 getSnapshot(store의 상태)를 인자로 넘겨 실행한 결과를 반환합니다. 따라서 memoizedSelector함수에 대해 분석해보겠습니다.

memoizedSelector 함수의 초기화 로직(if (!hasMemo)부분)을 지나 첫번째 조건문을 살펴보면 Object.is를 호출하여 상태의 참조값이 변경되었는지를 확인한뒤 변경되지 않았다면 이전에 선택된 상태값을 반환합니다.

두번째 조건문에서는 equal함수가 있는경우 equal함수를 이용해 선택된 전후 상태값을 비교한뒤 같다면 이전에 선택한 상태값을 반환합니다. 만약 다르다면 상태 스냅샷과 선택된 상태를 저장하고 선택된 상태값을 반환합니다.

이제 마지막단계로 정말 핵심적인 로직이 포함되어있을것 같은 useSyncExternalStore함수를 살펴보겠습니다.

export function useSyncExternalStore(
  subscribe
  getSnapshot
) {
  const value = getSnapshot();
  const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});

  useLayoutEffect(() => {
    inst.value = value;
    inst.getSnapshot = getSnapshot;

    if (checkIfSnapshotChanged(inst)) {
      forceUpdate({inst});
    }
  }, [subscribe, value, getSnapshot]);

  useEffect(() => {
    if (checkIfSnapshotChanged(inst)) {
      forceUpdate({inst});
    }
    const handleStoreChange = () => {
      if (checkIfSnapshotChanged(inst)) {
        forceUpdate({inst});
      }
    };
    return subscribe(handleStoreChange);
  }, [subscribe]);

  return value;
}

function checkIfSnapshotChanged(inst) {
  const latestGetSnapshot = inst.getSnapshot;
  const prevValue = inst.value;
  try {
    const nextValue = latestGetSnapshot();
    return !is(prevValue, nextValue);
  } catch (error) {
    return true;
  }
}

useSyncExternalStore훅은 subscribe함수를 통해 특정 store를 구독하고, store가 변경되면 handleStoreChange함수가 실행되어 checkIfSnapshotChanged함수가 true를 반환하는 경우에만 forceUpdate함수가 실행되어 리렌더링됩니다.

checkIfSnapshotChanged함수를 살펴보면 여기서도 Object.is함수가 사용되어 이전 상태와 다음 상태를 비교한뒤 참조값이 변경된 경우에만 true를 반환하므로 만약 상태 객체 내부의 값을 변경함으로써 상태를 불변하게 관리하지 않는다면 store의 값은 변경되었을지 몰라도 forceUpdate함수가 실행되지 않아 리렌더링이 발생하지 않습니다.

따라서 상태의 불변성을 지키지 않을 경우

  1. 하나의 reducer함수나 combineReducers함수에서는 이전 참조값을 반환
  2. getSelection함수에서 이전 상태를 반환
  3. checkIfSnapshotChanged함수가 false를 반환
  4. forceUpdate 함수가 실행되지 않아 리렌더링이 발생하지 않음

와 같이 되기 때문에 store와 UI가 동기화 되지 않을수 있으므로 React를 Redux와 같이 사용할 경우 반드시 불변성을 지켜야합니다.

추가적으로 살펴보면 좋을것은 forceUpdate함수는 setState 함수로, 클래스형 컴포넌트와 달리 함수형 컴포넌트에는 forceUpdate메서드가 존재하지 않기에 useState를 이용해 리렌더링을 유발한다는 것입니다. 항상 리렌더링 시키기 위해 참조값({init})을 바꾸어 주는것을 확인할 수 있습니다.

Redux에서 상태의 불변성

앞선 2장에서 살펴보았던 불변성 관련 내용은 combineReducers함수를 사용할경우 불변성을 지키지 않으면 store의 상태가 변경되지 않는다는 것이었습니다.

react-redux를 사용할 경우, 직접적으로 store의 상태에 개입하지 않지만, store의 값이 불변성을 지키지 않으면 리렌더링이 발생하지 않기 때문에, 상태의 불변성을 지키지 않으면 UI가 의도한 대로 반영되지 않게 됩니다. 이를 통해 불변성이 강제된다고 볼 수 있습니다.

따라서 단독 reducer를 사용하든, combineReducers를 사용하든, react-redux를 함께 사용할 경우, 상태의 불변성을 반드시 유지해야 합니다.

마치며

이번 아티클에서는 react와 redux를 연결하는 방법에 대해서 살펴보았습니다. 특히 Redux에서 불변성을 유지해야하는 근본적인 이유에 대해서 이해할수 있었으면 좋겠습니다.

마지막 아티클에서는 middleware에 대해 살펴보겠습니다.

참고자료

React Redux Quick Start useSyncExternalStore Idiomatic Redux: The History and Implementation of React-Redux How and when to force a React component to re-render