React.memo, useCallback 사용으로 렌더링 최적화 하기(feat.React-Native,Redux)

shin6403·2021년 1월 29일
62

React Native 및 Redux로 계산기 앱을 구현중 useCallback, React.memo로 성능 개선 최적화를 진행함으로써 어떻게 어떤방식으로 최적화가 되는지 정리를 해보려고 한다.

1. 성능 향상을 위한 Memoization

Memoization의 정의는 아래와 같다.

  • 결과를 캐싱하고, 다음 작업에서 캐싱한 것을 재사용 하는 비싼 작업의 속도를 높이는 자바스크립트 기술
  • 이전 값을 메모리에 저장해 동일한 계산의 반복을 제거해 빠른 처리를 가능하게 하는 기술
  • 캐시에 초기 작업 결과를 저장하여 사용함으로 써 최적화 할 수 있다. 만약 작업을 다시 수행해야 한다면, 어딘가에 저장되어진 동일한 결과를 단순히 반환 해준다.

useMemo, useCallback, React.memo는 모두 이 Memoization을 기반으로 작동한다. 그럼 이 Memoization이 어떻게 사용되는지 확인해보자.

2. React.memo? useCallback? 🤔

2-1) React.memo

const Button: React.FC<IBtnProps> = ({btn,handleOnClick}) => {
  return (
    <TouchableOpacity
      onPress={() => handleOnClick(btn)}>
      <Text>{btn}</Text>
    </TouchableOpacity>
  );
};

export default memo(Button);

React.memo는 위와 같이 사용되며 직접 컴포넌트를 감싸서 사용한다. React.memo는 Button의 결과를 Memoization(이전 값을 메모리에 저장해 동일한 계산의 반복을 제거)해서 props가 변경될때까지 현재 memoized된 내용을 그대로 사용하여 리렌더링을 막는다.
이렇게 Memoized된 내용을 재사용하여 렌더시 가상 DOM에서 바뀐 부분이 확인하지 않아 성능이 향상된다.

React.memo는 props를 비교할 때 얕은 비교를 진행하는데, 원시 값의 경우는 같은 값을 갖는지 확인하고 객체나 배열과 같은 참조 값은 같은 주소 값을 갖고 있는지 확인한다.

React.memo는 매번 사용해야 할까?

React 최적화 방식을 공부하면서 배웠던 내용중 하나는 아무때마 무분별한 사용은 지양하라는 것이다.
그 이유는 이를 사용하는 코드와 메모제이션용 메모리가 추가로 필요하게 되고, 최적화를 위한 연산이 불필요한 경우엔 비용만 발생시키기 때문이다.

언제 React.memo를 사용할까?

  • 함수형 컴포넌트에 같은 props에 같은 렌더링 결과를 제공할 경우
  • UI element의 양이 많은 컴포넌트의 경우
  • Pure Functional Component 경우

언제 React.memo를 사용하지 말까?

일반적으로 class 기반의 컴포넌트를 래핑하는 것도 적절하지 않다. 이 경우 memoization을 해야겠다면, PureComponent를 확장하여 사용하거나 shouldComponentUpdate()를 사용하길 권장하고있다.

React.memo의 주의 사항 - 부모가 전달하는 callback 함수

const CalculatorContainer = () => {
{...}
  const handleOnClick = useCallback((btn: string | number) => {
          {...}
          if (btn === 0 || btn === 'C') {
            return (
              <Button
                handleOnClick={()=>handleOnClick(btn)} // 이 부분 주목!!
                {...}
              />
            );
          }
{...}
  );
};

export default CalculatorContainer;

위와 같은 경우 <Button />컴포넌트는 handleOnClick props를 전달받게 된다. <Button />컴포넌트가 React.memo로 래핑된 함수 컴포넌트라고 할 때, CalculatorContainer가 re-rendering 되더라도 <Button />컴포넌트에 전달되는 props값이 동일하다면 <Button />컴포넌트는 re-rendering을 피할 수 있을까? 정답은 NO 🙅🏻‍♂️❌다.

한번 테스트 해볼까?

저 부분에서 문제는 함수를 실행하면서 props로 넘겨주기 때문에 계속 함수가 새롭게 렌더되어 memo로 감싸도 렌더가 계속 발생된다.

왜냐하면 handleOnClick 에 인라인 함수를 넘기고 있기 때문에 매 랜더링 마다 새로운 함수가 prop으로 전달되고, Button 컴포넌트는 다시 랜더링이 일어나기 때문이다.

handleOnClick의 callback 함수는 CalculatorContainer가 re-rendring이 될 때마다 새로운 참조값을 갖게 된다. 함수의 내용은 같더라도 참조값이 다르다면 Button re-rendering이 발생하고, React.memo는 오히려 memoization에 쓸데없는 메모리만 낭비하는 것이다.

그렇다면 해결방법은 없는걸까? 정답은 NO 🙅🏻‍♂️❌다.
그럼 어떻게 해야할까??

인라인 함수를 넘겨주는 것이 아닌 함수를 바로 넘겨주는 것이다!

const CalculatorContainer = () => {
{...}
  const handleOnClick = useCallback((btn: string | number) => {
          {...}
          if (btn === 0 || btn === 'C') {
            return (
              <Button
                handleOnClick={handleOnClick} // 이 부분 주목!!!!!!!!!!
                {...}
              />
            );
          }
{...}
  );
};

export default CalculatorContainer;

그렇다면 리렌더링이 되는지 확인해 볼까?

리렌더링이 안되는걸 확인할 수 있다.

2-1) useCallback

useCallback 사용전🙅🏻‍♂️


 const CalculatorContainer = () => {
  const [darkMode, setDarkMode] = useState(false);
  const dispatch = useDispatch();
  const calculator: ICalculatorState = useSelector(
    (state: RootState) => state.calculation,
  );

  const handleOnClick = (btn: string | number) => { //이 부분 주목
    if (typeof btn === 'number' || btn === '.') {
      dispatch(click(btn));
    }
    if (btn === '+' || btn === '-' || btn === '*' || btn === '/') {
      dispatch(addOperator(btn));
    }
    if (btn === '=') {
      dispatch(print());
    }
    if (btn === 'C') {
      dispatch(reset());
    }
  };

  return (
    <View>
    {...}
        {BUTTONS.map((btn: string | number, index: number) => {
          const getBackgroundColor = (btn: string | number): string => {
            {...}
          if (btn === '.' || btn === 'DEL') {
            return (
              <Button
                handleOnClick={handleOnClick}
	        {...}
              />
            );
          } 
          {...}  
        }
      </View>
    </View>
  );

useCallback 사용후🙆🏻‍♂️

 const CalculatorContainer = () => {
  const [darkMode, setDarkMode] = useState(false);
  const dispatch = useDispatch();
  const calculator: ICalculatorState = useSelector(
    (state: RootState) => state.calculation,
  );

  const handleOnClick = useCallback((btn: string | number) => { //이 부분 주목!!
    if (typeof btn === 'number' || btn === '.') {
      dispatch(click(btn));
    }
    if (btn === '+' || btn === '-' || btn === '*' || btn === '/') {
      dispatch(addOperator(btn));
    }
    if (btn === '=') {
      dispatch(print());
    }
    if (btn === 'C') {
      dispatch(reset());
    }
  }, []);

  return (
    <View>
    {...}
   
        {BUTTONS.map((btn: string | number, index: number) => {
          const getBackgroundColor = (btn: string | number): string => {
            {...}
          if (btn === '.' || btn === 'DEL') {
            return (
              <Button
                handleOnClick={handleOnClick}
                {...}
              />
            );
          } 
          {...}  
        }
      </View>
    </View>
  );

dispatch로 인한 상태값이 변경 되었을때, Button 컴포넌트는 리렌더링 되지 않아야 한다. 만약 handleOnClick 함수에 useCallback이 없었다면, Button 컴포넌트는 state의 변경으로 인해 re-rendering 될 것이고, handleOnClick 함수는 새로 생성되어 Button 컴포넌트는 re-rendering 될 것이다. 하지만 handleOnClick 함수에 useCallback을 사용함으로써 두개의 함수는 재 생성이 되지 않고 (2번째 파라미터인 배열에 아무것도 없을 경우 재 생성되지 않음) 변경된 state를 참조하는 Button만 re-rendering 되게 된다.

3. 성능 개선 평가

성능 개선 확인을 해보기 전에 react dev tool로 성능 개선 평가 확인방법을 알아보자.

그럼 이제 react dev tool로 성능이 얼마나 개선되었는지 확인보도록 하자.

위의 Flamegraph 차트를 보면 CalculatorContainer 컴포넌트는 렌더링하는데 26.6ms가 걸렸다.

그리고 react dev tool Ranked 차트를 살펴보면, Button 컴포넌트의 렌더링 시간이 제일 오래걸렸다는 것도 알 수 있다.

React.memo, useCallback으로 렌더링 최적화 성능 테스트하기

그럼 이제 하나하나 비교해가며 렌더링 최적화가 얼마나 되는지 확인해 보도록 하자.

3-1) 아무것도 적용 안했을때 (React.memo,useCallback 🙅🏻‍♂️) - 총 렌더 시간 27.2ms

3-2) useCallback만 적용 했을때 - 총 렌더 시간 18.1ms

3-3) React.memo만 적용 했을때 - 총 렌더 시간 18.4ms

3-1) React.memo,useCallback만 적용 했을때 - 총 렌더 시간 5.4ms

비교 💁🏻‍♂️

4. 정리 📝

렌더링 최적화를 사용함으로써 성능 개선에 큰 의미를 두었지만, 큰 규모가 아닌 이상 렌더링하는 시간이 아주 짧아서 메모를 적용하지 않았을 때 컴포넌트를 렌더링하는 시간과 메모를 적용했을 때 큰 차이는 느끼지 못한다.

하지만, 그래도 직접 성능 개선을 시도했다는 것에 의미를 두고 있다.

또한 React.memo, React.PureComponent 등의 활용을 할 때는 반드시 먼저 퍼포먼스 측정을 해볼 것을 강조하고 있다. “무작정 적용해놨다가 ‘왜 컴포넌트가 바뀌지 않지?’ 하고 한참 삽질하고 나니 굳이 필요 없는 곳에 React.memo 를 적용하고 있었다더라” 같은 사례도 분명 있기 때문이다.

무턱대고 React.memo, useCallback과 같은 훅을 적용하는 것보다, 어떤 부분이 느린지 정확히 판단하고성능을 개선한 뒤에 얼마나 개선됐는지 다시 측정해서 효과가 있는지 보는 게 정말 중요하다.

그리고 React API 문서에는 React.memo가 props를 비교할때 shallow 비교를 수행한다.
하지만 React.memo를 리렌더링 방지에 사용하면 버그를 유발할 수 있으며, 성능 최적화에만 사용하는 것을 권장하고 있다. 그러므로 React.memo를 사용하여 성능을 최적화 할때는 React dev tool Profiling을 꼭 사용하시길 바란다.

출처 | React Profiler를 사용하여 성능 측정하기
출처 | React 최적화, useMemo, useCallback, React.memo

profile
생각하는대로 살지 않으면, 사는대로 생각하게 된다.

8개의 댓글

comment-user-thumbnail
2021년 1월 29일

와...하나하나 비교 분석 정말 짱이에요!!!!!!!!!!!!!!!!!!!!!!

답글 달기
comment-user-thumbnail
2021년 1월 29일

와 정말 꿀정보 정리 너무 잘되있어요!!

답글 달기
comment-user-thumbnail
2021년 1월 29일

뿌듯하네요 세원님.. 😇

답글 달기
comment-user-thumbnail
2021년 1월 29일

와......세원신 진짜 많이 좋아해

답글 달기
comment-user-thumbnail
2021년 1월 29일

와........세원님 대단하다 진짜

답글 달기
comment-user-thumbnail
2021년 1월 29일

크으... 계산기까지 리렌더 방지해주는 능력자

답글 달기
comment-user-thumbnail
2021년 2월 10일

메모이제이션 분석에 대해 잘 읽었습니다!
한가지 궁금한 게 있는데, React.memo/React.useCallback 과 같은 메모이제이션을 이용했을 때, 리렌더링 성능이 나아지는 만큼 메모이제이션에 대한 비용도 생길텐데, 그 부분에 대한 분석/파악은 어떻게 할 수 있을까요?

답글 달기
comment-user-thumbnail
2021년 2월 21일

useMemo를 사용할경우 메모캐시를 설정하고 값을 저장하기 위해 초기렌더링은 오히려 더 느려지고 5000개 이상의 데이터와 여러번의 리랜더링을 처리할경우에만 이점이 있다는 글도 있네요.
https://medium.com/swlh/should-you-use-usememo-in-react-a-benchmarked-analysis-159faf6609b7

답글 달기