리액트 렌더링 최적화

박준수·2023년 4월 18일
0

들어가기전 최적화에 관하여

  • 리액트에는 여러 최적화 기법들이 있습니다.
    • React.memo, useMemo, useCallback
  • 이런 기법들은 처음 보았을 때 마치 개발의 은탄환처럼 사용하면 리렌더링을 줄여 막연히 최적화가 잘 될것 이라는 착각에 빠지게 하고
  • 그래서 여러 함수들, 컴포넌트에 무턱대고 남발하기 쉽습니다.
  • 하지만 특정한 상황이 아니면 오히려 이런 기법들을 사용하는 게 성능 최적화에는 독이 될 수 있습니다.
  • 그 이유는 리액트에서 함수나 컴포넌트를 렌더링할때 일반 요소들에 비해 이런 최적화 기법을 확인하는 비용이 들기 때문입니다.
  • 즉, 이 기법들이 올바르게 사용되어지지 않는다면 괜히 최적화는 되지도 않고 거기에 더해 최적화 확인 비용이 추가되는 경우가 생길 수 있습니다.
  • 따라서 이번 기회에 올바른 최적화 방법을 정리해보고자 합니다.

Memoization이란?

메모이제이션은 비용이 많이 드는 함수 호출의 결과를 저장하고 동일한 입력이 다시 발생할 때 캐시된 결과를 반환하여 컴퓨터 프로그램의 속도를 높이는데 주로 사용되는 최적화 기술입니다.

React.memo란?

  • React.memo는 구성 요소의 렌더링 결과를 메모하여 성능 최적화에 사용되는 React JavaScript 라이브러리의 고차 구성 요소(HOC)입니다.
  • React 컴포넌트의 메모화된 버전을 만들 수 있습니다.
  • 컴포넌트가 props가 변경된 경우에만 다시 렌더링됩니다.

다른예시

  • react에서는 먼저 컴포넌트를 랜더링 한 뒤, 이전 랜더링 된 결과와 비교하여 랜더링 결과가 이전과 다르다면,DOM을 업데이트 한다.
  • React.memo() 로 컴포넌트를 래핑하게 되면, React는 컴포넌트를 랜더링 하고,그 결과를 메모이징(Memoizing) 한다.
  • 그 뒤, 다음 랜더링이 일어났을 때 해당 컴포넌트의 props가 같다면,React는 메모이징 된 내용을 재사용한다.

React.memo는 왜 사용하는가?

  • react에서는 부모 컴포넌트가 리렌더링되면 자식 또한 리렌더링됩니다.
  • 이때 부모 컴포넌트 A에 자식 컴포넌트 B, C, D가 있고
  • 이 중 D에 CARD들이 무수히 많다면 그 CARD들 또한 전부 리렌더링되게 됩니다.
  • 이 경우 D컴포넌트를 React.memo로 묶게 되면 A가 리렌더링되어도 D는 리렌더링하지 않게되고 CARD들 또한 불필요한 리렌더링을 하지 않게 됩니다.
  • A에서 D로 props를 전달한다면 이 props를 비교하여 다를 경우에만 D를 리렌더링합니다.
    • 단! 이때 A에서 D로 전달하는 props가 복잡한 객체인 경우 React.memo는 props를 얕은 비교하여 다른 경우에만 D를 리렌더링합니다.
    • 얕은비교란 객체의 주소값을 비교한다는 의미입니다.
    • 만약 A컴포넌트에서 함수나 객체를 그대로 props로 전달한다면 D컴포넌트는 리렌더링 됩니다.
    • 왜냐면 A컴포넌트에서의 함수와 객체는 리렌더링되면 새로운 주소값을 가지게 되기 때문입니다.
    • 과거의 A컴포넌트에서의 객체 ≠ 리렌더링 이후 A컴포넌트에서의 객체
      • 겉보기에 완전히 그 값들이 같다고 하여도!!!
    • 따라서 props로 객체를 전달하기보다 string으로 그 값을 전달하는 것이 좋습니다.
      • 그렇다면 함수는? 아래 useCallback에서 다루겠습니다.

React.memo는 어떻게 사용하는가?

  • 일반함수일때와 화살표 함수일때가 다릅니다.
  • 일반함수의 경우
    import React from 'react';
    
    const MyComponent = ({ prop1, prop2 }) => {
      // Render component logic here
    };
    
    // Wrap the component with React.memo
    const MemoizedComponent = React.memo(MyComponent);
    
    export default MemoizedComponent;
  • 화살표 함수의 경우
    import React from 'react';
    
    const MyComponent = React.memo(({ prop1, prop2 }) => {
      // render component based on prop1 and prop2
    });
    
    export default MyComponent;

useMemo란?

  • 리액트가 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술입니다.
  • 동일한 값을 반환하는 함수를 반복적으로 호출해야한다면 처음 값을 계산할 때 해당 값을 메모리에 저장해 필요할 때마다 다시 계산하지 않고 메모리에서 꺼내서 재사용하는 것입니다.
  • useMemo()를 사용하면 연산된 결과 값을 캐싱해주기 때문에, 매렌더링마다 고비용 연산이 일어나는 것을 방지할 수 있습니다.
  • useMemo()는 연산된 결과값이 바뀌지 않는다면 재연산이 일어나지 않기 때문입니다.

useMemo는 왜 사용하는가?

  • 고비용 연산을 방지하기 위해 사용합니다.
    • 하지만 여기에는 큰 함정이 있는데 바로 메모할 만큼 어려운 연산이 아닌경우 오히려 제거함으로써 파일에서 약간의 공간을 절약할 수 있다는 점입니다.
  • 그럼 올바르게 사용하는 경우는 언제일까요?
  • React의 공식문서를 확장하면, 병목현상을 만드는 고비용 연산이란 하위 렌더트리를 렌더링하는 것을 말하며 React가 의도한 useMemo는 render tree 내부의 특정 부분을 메모할 때 사용하는 것입니다.
    • (물론 2^n, n!과 같은 계산을 메모할때 사용하는 것도 맞습니다.)

useMemo는 어떻게 사용하는가?

일반적인 예시

import React, { useMemo } from 'react';

const MyComponent = ({ data }) => {
  // Use useMemo to memoize the result of the expensive computation
  const result = useMemo(() => {
    // Expensive computation using data as input
    // ...
    return computedResult;
  }, [data]); // data is the dependency array

  // Render the result in the component
  return (
    <div>
      <p>Result: {result}</p>
      {/* ... */}
    </div>
  );
};

export default MyComponent;
  • 의존성 배열 data가 바뀔때만 함수가 실행됩니다.
  • 즉, 이 MyComponent가 다른 이유로 리렌더링되어도 data가 바뀌지 않으면 result함수는 새로 실행되지 않습니다.

하위 렌더트리를 렌더링하는 예시

function List({ countries }) {
	const sortedCountries = countries.sort()

    return (
      <>
        {sortedCountries.map((country) => (
          <Item country={country} key={country.id} />))}
      </>);
}
  • List라는 컴포넌트는 props로 countries를 받고 여기에는 250개의 나라가 담겨있습니다.
  • React에서 무거운 계산은 컴포넌트를 리렌더링하고 업데이트하는 계산입니다.

https://velog.velcdn.com/images/hyunjine/post/590a0aa5-67e6-4194-bd69-be0230bdd1de/image.png

  • 즉 위에서 표시한대로 주목해야할 부분은 컴포넌트를 다시 그리는 부분입니다.
function List({ countries }) {
	const content = useMemo(() => {
    	const sortedCountries = countries.sort()
        return sortedCountries.map((country) => <Item country={country} key={country.id} />);
    }, [countries])

    return content
}

useCallback이란?

  • useCallback은 불필요하게 다시 렌더링하지 않고 자식 구성 요소에 소품으로 전달할 수 있도록 함수를 메모화하는 데 사용됩니다.
  • 즉, useMemo()와 그 메커니즘은 같지만 useMemo() 가 연산된 “값” 을 캐싱했다면, useCallback() 은 값이 아닌 “함수 그 자체”를 캐싱합니다.

useCallback은 왜 사용하는가?

  • 위의 React.memo에서 봤듯이 props로 함수를 전달한다면 부모 컴포넌트의 리렌더링시 자식 또한 리렌더링 되고 이때 React.memo를 쓰면 객체나 함수를 제외하고는 props가 같은 경우 리렌더링을 안하게 할 수 있습니다.
  • 객체의 경우는 위에서 가능한 string값만 전달해서 그 값이 필요한 당사자인 컴포넌트에서 직접 값을 가공하는 것이 좋다고 했습니다.
  • 그리고 함수의 경우 useCallback을 사용합니다.
  • useCallback으로 함수를 묶으면 이 함수는 의존성배열이 변하지 않는 한 리렌더링시 주소가 변하지 않게 됩니다.
  • 따라서 자식 컴포넌트로 해당 함수를 props로 전달해주어도 자식에서는 부모의 리렌더링시 같이 리렌더링 하지 않습니다.

useCallback은 어떻게 사용하는가?

  • React.memo가 쓰인 상황에서
const Page = () => <Item />;
const PageMemoized = React.memo(Page);
const App = () => {
  const [state, setState] = useState(1);

  return (
    ... // 이전과 동일
      <PageMemoized />);
};
  • useCallback을 안쓰면 계속 리렌더링 되지만
const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = () => {
    console.log('Do something on click');
  };
  return (
    ...// 위와 동일한 코드 생략
    // onClick이 계속 새로 만들어지기 때문에 memo를 사용했어도 계속 리렌더링이된다.
    <PageMemoized onClick={onClick} />);
};
  • useCallback을 쓰면 리렌더링을 막습니다.
const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = useCallback(() => {
    console.log('Do something on click');
  }, []);

  return (
    // onClick을 memoization 해서 Page 리렌더링을 막는다.
    <PageMemoized onClick={onClick} />
  );
};

끝으로

(출처: https://velog.io/@hyunjine/React-Rendering-Optimization)

  • 대부분의 경우에 useMemo와 useCallback을 제거해야합니다.
  • 수백개의 컴포넌트가 존재하는 앱이 있다고 해보겠습니다. 글에서 설명한바와 같이 useMemo와 useCallback은 첫 렌더링 때 React가 그 값을 캐시해두어야합니다. 이것은 시간이드는 작업이죠. 100개의 컴포넌트를 성능개선하겠다고 useMemo, useCallback으로 도배를 해놓았다면 어떨까요? 1ms, 2ms,... 100ms 점점 늘어납니다.
  • 반면에 리렌더링은 어떨까요? 애플리케이션을 잘 설계하면 리렌더링은 특정 부분에서만 일어납니다. 특정 부분에서만 일어나는 리렌더링에서 만들어야할 함수와 값은 개수가 적습니다. 개수가 적을수록 함수를 만드는 연산과 참조 동일성을 체크하는 연산 자체를 비교하는 것 자체가 무의미해지게 됩니다.
  • 이렇기 때문에 초기렌더링에 유리하도록 필요없는 useMemo와 useCallback을 없애고 리렌더링은 변경되어야하는 부분만 일어나게 만들어서 애플리케이션을 최적화할 수 있습니다.
  • 사람들은 성능 개선에는 trade-off가 있다고 많이 말합니다. React에서 성능을 개선하는 것은 useMemo와 useCallback으로 애플리케이션 내부에 모든 함수와 값들을 감싸는 것이 아닙니다. 이렇게 하면 컴퓨터 자원만 의미 없이 사용하게되고 심지어 애플리케이션이 더 느려질 수 있습니다.(역효과)
  • React가 의도한 성능 최적화는 memo, useMemo, useCallback을 사용해서 컴포넌트와 그 하위 컴포넌트들이 리렌더링되는 것을 막거나(memo), 컴포넌트 하위 트리를 메모이제이션해서 사용하거나(useMemo), memo로 감싸진 컴포넌트에 props를 전달할 때 값이 변하지 않도록 해주는 것(useCallback)을 말합니다.
  • 단순히 값이나 함수를 메모이제이션하는 것은 성능에 미미한 영향을 끼치거나 오히려 애플리케이션이 더 느려질 수 있습니다. React에서 최적화란, 이런 Pure한 JavaScript를 최적화하는 것이아닌 보다 훨씬 오래걸리는 하위 트리의 렌더링을 막는 것입니다.
  • 역설적으로, 성능에 대해 고민하는 것보다 애플리케이션이 렌더링되고 렌더링되지 않아야 할 부분을 잘 설계하는데 집중하는게 성능을 더 좋게 만들 수 있는 길입니다.

출처:

https://velog.io/@hyunjine/React-Rendering-Optimization

https://velog.io/@jinyoung985/React-useMemo란

https://ssdragon.tistory.com/106

https://leetrue-log.vercel.app/react-rendering

https://ryulog.tistory.com/164

https://velog.io/@integer/React.memo와-useMemo

profile
심플한 개발자를 향해 한걸음 더 (과거 블로그 : https://crablab.tistory.com/)

0개의 댓글