728x90

React Redux에서 ContextAPI performance issue를 해결한 방법

React

React의 ContextAPI는 퍼포먼스 이슈가 있어 복잡한 데이터를 다루기에 적합하지 않습니다.

퍼포먼스 이슈?

이 퍼포먼스 이슈를 간단히 요약하면 객체 {number: 0, text: "text"}를 contextAPI에서 관리할 때 A Comp에서는 context의 number 값만을, B Comp에서는 context의 text 값만을 사용한다고 가정하겠습니다.

context의 number 값이 변경되면 A Comp만 리렌더링되는 것이 가장 이상적이겠지만, number 값을 사용하지 않는 B Comp 역시 리렌더링됩니다.

해결 방법..?

해결 방법으로는 위 링크에서 언급한 방법과 같이 context 분할, React.memo, React.useMemo로 리렌더링 방지 그리고 contextAPI를 생성할 때 calculateChangedBits를 두 번째 인자로 주어서 해결하는 방법이 있습니다.

context 분할의 경우 복잡한 데이터를 다룰 때 context가 수없이 많아질 수 있으며, React.memo, React.useMemo를 이용해 해결할 경우 컴포넌트에서 다루는 데이터가 변경될 때마다 dependency 배열의 값을 수정해줘야 합니다.

근본적인 해결 방법이라고 할 수 있는 contextAPI의 calculateChangedBits를 지정하는 방법은 contextAPI가 렌더링되는 조건을 변경하는 것인데, 위 링크에 작성된 코드의 경우 contextAPI를 이용한 상태 관리 Provider를 별도로 생성하는 코드입니다. 다만, 코드 자체의 가독성이 좋지 않고 다루는 값이 많아진다면 수행해야 할 조건이 많아져서 효율적인 방법이라고 볼 수 없습니다.

그렇다면 react-redux에서는 contextAPI를 사용하고 있는데, 어떠한 방법으로 위 contextAPI 문제를 해결하고 있는지 살펴보도록 하겠습니다.

react-redux 에서의 contextAPI

react-redux에서는 내부 로직이 contextAPI로 동작하고 있습니다.

react-reduxstoreprops로 받는 Provider를 사용해 값을 관리합니다.

store 구조

store는 listener, state, isDispatching 값을 보관하며, state 값에 접근하기 위한 getState 메소드 제공, state를 업데이트하기 위한 dispatch 제공, 리스너를 등록하기 위한 subscribe을 제공하고 있습니다.

dispatch를 진행하는 동안 isDispatching 값은 true로 변경되며, currentReducer(currentState, action) 함수를 실행하여 반환값을 currentState에 저장합니다. isDispatching 값이 true일 때 getState, subscribe, dispatch 등이 실행된다면 에러를 반환합니다.

또한, listener가 있다면, currentListener에 nextListener를 복사한 뒤, listener 목록을 순차적으로 실행합니다.

Provider Component

Provider component를 호출할 때, store를 props로 넘겨줘야 합니다. 넘겨받은 storecontextValue를 생성할 때 사용되며, contextValueuseMemo hook을 통해 store 값이 변경될 때 다시 생성됩니다. contextValue가 갖고 있는 값은 store, subscription 값이며, subscription 값을 생성하기 위해 Subscription 클래스를 이용합니다.

현재 값과 비교할 수 있는 previousState 값 또한 store가 변경될 때마다 생성되는데, storegetState 메소드의 반환값을 갖고 있습니다.

처음 Provider가 생성될 때와 contextValue, previousState 값이 변경될 때 contextValue가 갖고 있는 subscription.trySubscribe 메소드로 구독을 시도하며, previousState 값과 store.getState 값이 다르다면 subscription.notifyNestedSubs 메소드를 실행합니다.

Provider를 생성할 때 context를 props로 넘겨주면 custom context를 사용해 contextValue를 관리할 수 있습니다. 넘겨주지 않다면 React.createContext(null)로 생성된 React Context를 사용해 값을 관리합니다.

Subscription class

Provider에서 contextValue 값을 생성할 때 Subscription class에 store를 넘겨준다고 했습니다. store를 넘겨받은 Subscription은 내부 store 필드에 넘겨받은 store를 저장합니다.

contextValue를 생성할 때 값이 변경되는 것을 확인하기 위해 subscription.onStateChangesubscription.notifyNestedSubs를 저장합니다. 이 값은 Subscription.trySubscribe 내에서 listener로 넘겨주는 handleChangeWrapper에서 값이 변경되었음을 체크할 때 사용합니다.

Provider 컴포넌트를 생성할 때와 store 값이 변경될 때 호출되는 Effect에서 contextValue.subscriptiontrySubscribe를 시도합니다. 그리고 이전 값이 다를 때 notifyNestedSubs 메소드를 호출합니다. 각각 다음과 같이 실행됩니다.

trySubscribe

구독 중인 상태라면 unsubscribe 상태에 this.store.subscribe(this.handleChangeWrapper)의 반환값을 저장합니다. 그 후, listeners 필드에 createListenerCollection 함수의 반환값을 저장합니다.

createListenerCollection 함수가 반환하는 값은 다음과 같습니다.

  • first 필드와 last 필드를 모두 제거하는 clear 메소드
  • first 값에 저장된 리스너부터 순차적으로 실행하는 notify 메소드
  • listeners 값을 모두 보여주는 get 메소드
  • callback을 인자로 전달받아 listeners에 linked list 형태로 저장한 후, firstlast 값을 반환하는 subscribe 메소드

notifyNestedSubs

listeners 필드에 저장되어 있는 notify 메소드를 실행합니다.

useSelector

React에서 Hook이 업데이트된 이후로 React Redux에서도 관련 업데이트를 통해 connect가 아닌, useSelectoruseDispatch hook을 사용해 redux가 관리하고 있는 값을 가져오고 업데이트할 수 있게 되었습니다.

또한, Provider가 생성되는 과정을 살펴보면서 Provider에서는 객체 shallow Equal 비교 관련 로직이 존재하지 않는 것을 확인할 수 있습니다. 실제로 useSelector를 사용하지 않는다면 어떠한 값을 갖고 있든 DOM에서 노출이 안 되는 것이므로 리렌더링은 일어나지 않으니 값을 가져오는 곳에서 비교 로직이 존재하는 것이 맞습니다.

useReduxContext

Provider를 생성할 때 custom context를 설정하지 않았다면 ReactReduxContext라는 이름의 context가 생성됩니다. useReduxContextReactReduxContext의 값을 반환하는 hook 입니다.

createSelectorHook

useSelector hook은 createSelectorHook 함수의 반환 값입니다. context 값이 ReactReduxContext라면 useReduxContext, 다른 값이라면 useSelector(context)를 통해 가져온 값을 반환하는 hook을 미리 정의합니다.

useSelector는 두 개의 인자를 전달받는데, 하나는 값을 가져오는 데 사용될 selector, 다른 하나는 slector가 변경되었는지 확인하는 equalityFn 입니다. equalityFn 함수의 기본 값은 (a, b) => a === b 입니다.

간단히 useSelector가 하는 일을 살펴보면 useSelectorWithStoreAndSubscription hook에 selector, equalityFn, store, contextSub 인자를 전달하고, 그 반환값을 반환하는 일을 수행합니다.

useSelectorWithStoreAndSubscription

useSelectorWithStoreAndSubscription hook은 강제로 렌더링을 시키기 위한 forceRender 함수가 존재합니다. 그리고 store 또는 subscription 값이 변경되었다면 실행되는 Effect hook이 존재합니다. 이 Effect hook에서는 인자로 전달받은 equalityFn을 사용해 이전에 갖고 있던 값과 현재 action을 통해 업데이트된 값을 비교합니다.

만약, 값이 같다면 아무런 일도 하지 않고 effect를 종료합니다. 그렇지 않고 값이 다르다면 latestSelectedState 값에 newSelectedState를 저장하여 값을 업데이트한 뒤, forceRender 함수를 실행합니다.

latestSelectedState 값은 ref로 이뤄져 있기 때문에 forceRender를 실행시키지 않는다면 화면의 업데이트는 일어나지 않습니다. 이러한 로직으로 ref와 forceUpdate를 활용하여 react redux에서는 불필요한 리렌더링을 막고 있습니다.

React Redux에서 제공하는 shallowEqual 함수의 로직은 다음과 같습니다. useSelector를 사용할 때 두 번째 인자로 selectorEqual를 넘겨주게 된다면 객체의 reference 주소가 달라졌다는 이유로 리렌더링되지 않을 것입니다.

function is(x, y) {
  if (x === y) {
    return x !== 0 || y !== 0 || 1 / x === 1 / y
  } else {
    return x !== x && y !== y
  }
}

export default function shallowEqual(objA, objB) {
  if (is(objA, objB)) return true

  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false
  }

  const keysA = Object.keys(objA)
  const keysB = Object.keys(objB)

  if (keysA.length !== keysB.length) return false

  for (let i = 0; i < keysA.length; i++) {
    if (
      !Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false
    }
  }

  return true
}
728x90
728x90