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

앞선 아티클에서 Redux의 흐름을 소개할때는 UI 라이브러리를 사용하지 않고 바닐라 자바스크립트를 이용해 UI를 구현하였습니다. 하지만 일반적으로 UI를 구현할때는 바닐라 자바스크립트가 아닌 라이브러리(React, Vue 등)를 사용하기 때문에 UI 라이브러리와 Redux를 함께 사용하는 케이스를 분석할 필요가 있습니다.

이번 아티클에서는 가장 보편적인 UI 라이브러리인 React와 Redux를 함께 사용할때 어떻게 Redux의 변경사항이 전파되는지, 최적화는 어떤방식으로 이루어지는지 살펴보겠습니다.

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를 분석해보면서 UI 바인딩 라이브러리가 어떻게 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>
}

중요한 코드만 남기면 Provider컴포넌트는 단순히 React Context Api의 Provider 역할을 하고있습니다. 그런데 이렇게 생성한 Provider의 context는 어떻게 사용하게 될까요? 이는 useReduxContext 함수를 살펴보면 쉽게 이해할수 있습니다.

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를 가져온뒤, 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 (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 함수의 초기화 로직(if (!hasMemo)부분)을 지나 첫번째 조건문을 살펴보면 Object.is를 호출하여 상태의 참조값이 변경되었는지를 확인한뒤 변경되지 않았다면 이전에 선택된 상태값을 반환합니다. 이전에 살펴보았던 combineReducers함수에서의 동작과 매우 유사합니다. 하지만 이러한 동작이 불변성을 지키지 않았을때 문제를 발생시킬지는 지금 여기서 판단할수 없습니다. 이러한 로직이 있음을 기억해두고 일단 넘어가겠습니다.

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

이제 마지막단계로 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훅은 설명한것처럼 subscription함수를 통해 특정 store를 구독하고, store가 변경되면 handleStoreChange함수가 실행되어 checkIfSnapshotChanged함수가 true를 반환하는 경우에만 forceUpdate함수가 실행되어 리렌더링됩니다.

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

여기서 앞서 우리가 상태를 불변하게 관리하지 않으면 의도하지 않은대로 동작하지 않은 이유를 알 수 있습니다. Redux의 reducer함수에서 불변성을 지키지 않으면 하나의 reducer함수를 사용할때는 상태의 참조값이 변경되지 않고 combineReducers함수에서는 이전 객체를 반환하여 결국 getSelection함수에서 이전 상태를 반환하게되며 이는 checkIfSnapshotChanged함수가 false를 반환하도록 만들어 실제로 상태가 변경되었음에도 리렌더링이 발생하지 않기 때문입니다.

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

정리

많은 코드를 살펴보았기 때문에 간단하게 다시 정리해보겠습니다. React-Redux는 React의 Context Api를 사용하여 store의 메서드들을 관리하며, useSelector 함수를 사용한경우 해당 Hooks 안에서 useSyncExternalStore hooks을 사용하므로, 상태가 변경되면 곧바로 강제 업데이트 되어 상태가 변경됩니다.

특히 상태를 불변하게 관리하지 않을경우 상태는 변경되겠지만 리렌더링 자체가 발생하지 않기 때문에 React-Redux를 사용할경우 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