728x90

[React] ContextAPI 렌더링 이슈

ContextAPI는 규모가 작은 앱을 개발할 때 매우 편리합니다. 하지만, Context 값이 변경될 때, useContext가 렌더링을 유도하기 때문에 앱의 규모가 커진다면 일부 성능 문제가 발생할 수 있습니다.

'아니 어떻게 React에서 제공하는 기본 기능에서 문제가 발생할 수 있냐??'라고 생각할 수도 있습니다.
지금부터 어떤 문제가 발생하는지와 함께 해당 문제를 해결하는 방법에 대해 알아봅시다.

ContextAPI 앱 구성 및 문제 확인

문제를 확인할 수 있는 CodeSnadbox

간단한 객체 값을 갖고 있는 Context 구성입니다. useReducer를 이용해 Provider에 값을 전달하고 있으며, useContextr를 이용해 해당 값과 함께, dispatch 함수도 불러올 수 있습니다.

다만, 이 간단하고도 간단한 앱에는 치명적인 문제가 있습니다. FirstName 컴포넌트에서는 NameContext.firstName 값만을 사용하고 FamilyName 컴포넌트에서는 NameContext.familyNmae 값만을 사용하지만, FirstName 컴포넌트에서 값을 수정시 FamilyName 컴포넌트에서 리렌더링이 발생합니다.

간단한 규모의 앱이라면 큰 문제가 되지 않겠지만, 퍼포먼스와 관련된 부분이라면 속도 저하 문제가 눈에 보일 수도 있습니다.

해결 방법

Provide more ways to bail out inside Hooks 이슈에서 해당 이슈에 대해 토론이 진행되었지만, React가 Context API 코어에 직접적인 솔루션을 제공해주지는 않았습니다. 하지만, 세 가지 방법으로 위 문제를 해결할 수 있습니다. ContextAPI 문제에 대해 알아본 뒤, 이 이슈를 피하는 방법에 대해 알아봅시다.

방법 1. context 분할

위 문제를 해결하는 가장 쉬운 방법은 context를 분할하는 것입니다.

const initialState1 = {
  firstName: 'Harry',
};

const initialState2 = {
  familyName: 'Potter',
};

객체와 배열 등의 데이터가 value로 관리될 때 렌더링 이슈가 발생합니다. 따라서 위와 같이 값을 분할한 뒤, Context를 각각 생성해 두 개로 관리하면 렌더링 문제는 해결할 수 있습니다.
하지만, 위와 같이 작업할 경우 Context가 많아져 결국 유지보수에 큰 어려움을 겪게 될 수 있습니다.

방법 2. React.memo

React.memo를 사용하는 것으로 위 문제를 해결할 수 있습니다! 하지만, 컴포넌트의 수가 많아집니다.

const FirstName = () => {
  const [state, dispatch] = React.useContext(NameContext);
  console.log("render FirstName");

  return (
    <div>
      First Name:
      <input
        value={state.firstName}
        onChange={event => {
          dispatch({ type: "setFirstName", firstName: event.target.value });
        }}
      />
    </div>
  );
};

export default React.memo(FirstName);

위와 같이 export default 시점에 React.memo를 추가해 해결된다면 좋겠지만, 해당 컴포넌트에서는 NameContext의 값을 반환받고 있어서 NameContext의 구성 요소가 변경되는 것으로 인식해 렌더링이 진행됩니다. 따라서 아래와 같이 props drilling을 섞어서 해결할 수 있습니다.

const InnerFirstName = React.memo(({firstName, dispatch}) => (
  <div>
    First Name:
    <input
      value={firstName}
      onChange={event => {
        dispatch({ type: "setFirstName", firstName: event.target.value });
      }}
    />
  </div>
));

const FirstName = () => {
  const [state, dispatch] = React.useContext(NameContext);
  console.log("render FirstName");

  return (
    <InnerFirstName firstName={state.firstName} dispatch={dispatch} />
  );
};

export default FirstName;

위 방법을 사용하면 FirstName 컴포넌트는 여전히 리렌더링됩니다. 하지만, NameContext의 firstName 값이 변경되지 않는다면 InnerFirstName 컴포넌트는 리렌더링되지 않습니다.

단순히 Wrapper Component를 만드는 것이므로, 위 방법으로 최적화를 진행하더라도 성능상의 문제가 되지는 않습니다.

방법 3. useMemo

React.memo가 다른 부가적인 값에 의해 의도한 대로 동작하지 않을 수도 있습니다. 이럴 경우 useMemo를 사용해 해결할 수 있습니다. 권장하지 않는 방법이며, 위 두 방법으로 해결할 수 없을 때 해당 방법을 사용해야 합니다.

const FirstName = () => {
  const [state, dispatch] = React.useContext(NameContext);
  console.log("render FirstName");

  return useMemo(() => (
    <div>
      First Name:
      <input
        value={state.firstName}
        onChange={event => {
          dispatch({ type: "setFirstName", firstName: event.target.value });
        }}
      />
    </div>
  ), [firstName, dispatch]);
};

export default FirstName;

위 방법 역시 방법 2와 동일하게 FirstName 컴포넌트의 렌더를 막지는 못하지만, return에서 useMemo를 사용함으로써 렌더링을 강제적으로 막는 모습입니다.

위 방법은 관리하는 state, props가 늘어날 때마다 useMemo의 dependency에도 추가를 해줘야 해서 가장 유지보수가 힘든 방법입니다.

번외

이렇게까지 해봤다

위 Context 리렌더링 이슈를 접했을 시점이 에디터를 개발하고 있었을 시점이었습니다.

에디터 이미지

관리해야 하는 값이 많아서 props drilling으로 인해 컴포넌트의 유지보수가 힘들어 Context API를 사용해 위 문제를 해결하고자 했습니다.

Context API를 모두 적용한 결과! 이전보다 더욱 느려지고 더욱 렌더링이 활발하게 일어나 매우 느려진 에디터를 만나볼 수 있었습니다!

위 문제를 해결하기 위해 globalState를 관리하는 ContextAPI 기반 라이브러리를 작성했습니다.

1년도 더 전에 작성한 코드로 레거시 코드가 매우 많습니다.
아래 코드는 지금 사용하기 부적합합니다.

Context API 기반 createGlobalState.js

import React from "react";

const EMPTY_OBJECT = {};

const isFunction = fn => typeof fn === "function";

const updateValue = (oldValue, newValue) => {
  // 두 번째 인자로 함수를 전달받아 return 값으로 업데이트 가능합니다.
  if (isFunction(newValue)) {
    return newValue(oldValue);
  }
  return newValue;
};

const createGlobalStateCommon = initialState => {
  const keys = Object.keys(initialState);
  let wholeGlobalState = initialState;
  let providerMangeState = null;

  const calculateChangedBits = (a, b) => {
    let bits = 0;
    keys.forEach((k, i) => {
      if (a[k] !== b[k]) bits |= 1 << i;
    });
    return bits;
  };

  const Context = React.createContext(EMPTY_OBJECT, calculateChangedBits);

  const GlobalStateProvider = ({ children }) => {
    const [state, setState] = React.useState(initialState);

    React.useEffect(() => {
      if (!!providerMangeState) {
        console.warn(
          "GlobalStateProvider가 두 개 이상 선언될 경우 의도치 않은 에러를 유발할 수 있습니다."
        );
      }
      providerMangeState = setState;

      if (state !== initialState) {
        wholeGlobalState = state;
      } else if (state !== wholeGlobalState) {
        setState(wholeGlobalState);
      }

      return () => {
        providerMangeState = null;
      };
    }, [state]);

    return React.createElement(Context.Provider, { value: state }, children);
  };

  const validateName = name => {
    if (!keys.includes(name)) {
      console.error(`'${name}'을 initalState에서 찾을 수 없습니다.`);
      return false;
    }

    return true;
  };

  const setGlobalState = (name, update) => {
    if (!validateName(name)) {
      return null;
    }

    wholeGlobalState = {
      ...wholeGlobalState,
      [name]: updateValue(wholeGlobalState[name], update)
    };

    if (providerMangeState) {
      providerMangeState(wholeGlobalState);
    }
  };

  const getGlobalState = name => {
    if (!validateName(name)) {
      return null;
    }
    return wholeGlobalState[name];
  };

  const useGlobalState = name => {
    const index = keys.indexOf(name);
    const observedBits = 1 << index;

    const state = React.useContext(Context, observedBits);
    const updater = React.useCallback(update => setGlobalState(name, update), [
      name
    ]);

    // React의 Hooks는 조건식으로 진행 순서가 변경되면 안되어서 해당 조건을 Hooks 뒤로 분리
    if (!validateName(name)) {
      return null;
    } else if (state === EMPTY_OBJECT) {
      console.error("GlobalStateProvider를 선언해주세요");
      return null;
    }
    return [state[name], updater];
  };

  const getWholeGlobalState = () => wholeGlobalState;

  const setWholeGlobalState = state => {
    wholeGlobalState = state;
    if (providerMangeState) {
      providerMangeState(wholeGlobalState);
    }
  };

  return {
    GlobalStateProvider,
    setGlobalState,
    getGlobalState,
    useGlobalState,
    getWholeGlobalState,
    setWholeGlobalState
  };
};

// useState를 사용하는 방식
export const createGlobalState = initialState => {
  const {
    GlobalStateProvider,
    setGlobalState,
    getGlobalState,
    useGlobalState
  } = createGlobalStateCommon(initialState);
  return {
    GlobalStateProvider,
    useGlobalState,
    setGlobalState,
    getGlobalState
  };
};

// useReducer를 사용하는 방식
export const createStore = (reducer, initalState) => {
  if (!initalState) {
    initalState = reducer(undefined, { type: undefined });
  }

  const {
    GlobalStateProvider,
    useGlobalState,
    getWholeGlobalState,
    setWholeGlobalState
  } = createGlobalStateCommon(initalState);

  const dispatch = action => {
    const oldState = getWholeGlobalState();
    const newState = reducer(oldState, action);
    setWholeGlobalState(newState);
    return action;
  };

  return {
    GlobalStateProvider,
    useGlobalState,
    getState: getWholeGlobalState,
    setState: setWholeGlobalState,
    dispatch
  };
};

createGlobalState를 적용한 ContextAPI 예제

위 코드의 핵심은 createContextuseContextObservedBits를 추가해 렌더링을 제한하는 것입니다. createContext, useContext를 사용할 때 두 번째 인자를 추가해주면 렌더링을 제한할 수 있으나, 러닝 커브가 존재해 이를 줄이기 위해 createGlobalState를 가져다 사용할 수 있도록 했습니다.

이후에는 어떻게 됐나?

범용적이고 이해하기 쉬운 게 좋은거죠. react-hook-form 라이브러리를 사용해 에디터 관련 폼을 모두 변경했습니다.

이후에 하고 싶은 것

다시 보니, context 없이 생으로 관리하는 게 더 깔끔하겠는데? 싶었다. 위 코드에서 Context를 전부 버리고 깔끔하게 globalState를 관리하는 간단한? 라이브러리를 만들어보고 싶다.

또, 이 글을 적으면서 위 코드를 아예 개선할 수 있는 방법도 생각났는데 createContext 할 때 두 번째 인자로 항상 false을 반환하는 함수를 추가하고, globalState를 ref에 관리해 업데이트 자체를 막아버린 뒤, 특정 조건일 때 forceUpdate 시키는 코드를 만들면 매우 깔끔해질 것 같다.

위 방법은 여러 상태 관리 라이브러리에서 이전에 사용했던 방법으로 알고 있는데, 지금도 사용하는지 모르겠다.


참고
https://github.com/facebook/react/issues/14110
https://github.com/facebook/react/issues/15156#issuecomment-474590693
https://blog.axlight.com/posts/4-options-to-prevent-extra-rerenders-with-react-context/

728x90
728x90