리액트 렌더러를 최적화하는 간단한 트릭


원문: https://kentcdodds.com/blog/optimize-react-re-renders

banner Photo by Evan Dvorkin

React.memo, PureComponent 또는 shouldComponentUpdate 를 사용하지 않고 최적화하는 방법

필자는 리액트의 리렌더링과 관련된 주제로 블로그 글을 준비하다가 당신이 감사할만한 이 작은 보석같은 리액트 지식을 발견했다.

(트위터 내용: 당신이 마지막 렌더링 때와 동일한 리액트 엘리먼트를 넘긴다면, 리액트는 그 엘리먼트를 리렌더링하지 않을 것이다.)

twitter

이 글을 읽고 난 후, Brooks Lybrand는 이 트릭을 적용하였고 아래와 같은 결과를 얻었다.

(트위터 내용: 리액트 컴포넌트의 최적화 이전과 이후를 나타낸 것이다. 필자는 메모이제이션을 사용하지 않고, 13.4ms 에서 3.6ms로 렌더링 시간을 줄일 수 있었다. 그리고 이것은 27줄의 코드를 옮기는 것만으로 가능했다.)

twitter

흥미롭지 않은가? 그럼 인위적으로 작성한 간단한 예제를 보고 실제 앱에서 어떻게 적용할 수 있을지 이야기 해보자.

예제

// codesandbox: https://codesandbox.io/s/react-codesandbox-g9mt5

import React from "react";
import ReactDOM from "react-dom";

function Logger(props) {
  console.log(`${props.label} rendered`);
  return null; // 여기서 반환되는 값은 부적절하다...
}

function Counter() {
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(c => c + 1);
  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      <Logger label="counter" />
    </div>
  );
}

ReactDOM.render(<Counter />, document.getElementById("root"));

위의 예제 코드가 실행되면 초기에 "counter rendered" 가 콘솔에 기록되며, count 값이 증가 할 때마다 "counter rendered" 가 콘솔에 기록될 것이다. 이는 버튼을 클릭 할 때, 상태가 변경되고 리액트가 해당 상태 변경을 기반으로 렌더링 할 새로운 리액트 엘리먼트를 가져와야하기 때문에 발생한다. 그리고 리액트는 새로운 엘리먼트들을 얻으면 이것들을 DOM에 적용하여 렌더링한다.

여기서 흥미로운 점이 있다. <Logger label="counter" /> 엘리먼트는 렌더링할 때 절대 변하지 않는다는 사실이다. 이는 정적인 엘리먼트이고 따로 추출할 수 있다. 한 번 재미삼아 작업을 해보자.(필자는 당신이 이 방법을 적용하는 것을 추천하지 않는다. 실용적인 권장 사항은 블로그 글 후반에서 다루고 있으니 기다려달라.)

// codesandbox: https://codesandbox.io/s/react-codesandbox-o9e9f

import React from "react";
import ReactDOM from "react-dom";

function Logger(props) {
  console.log(`${props.label} rendered`);
  return null; // 여기서 반환되는 값은 부적절하다...
}

function Counter(props) {
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(c => c + 1);
  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      {props.logger}
    </div>
  );
}

ReactDOM.render(
  <Counter logger={<Logger label="counter" />} />,
  document.getElementById("root")
);

변경된 점을 보았는가? 그렇다! 우리는 초기의 로그는 볼 수 있지만, 더 이상 버튼을 누를 때 새로운 로그는 볼 수 없다! WHAAAAT!?

자세한 기술적인 내용은 모두 넘기고 "이것이 어떤 의미가 있는가" 알고 싶다면, 글의 아래 부분으로 넘어가라.

어떻게 된 것 일까?

무엇이 이런 차이점을 발생시킨 것일까? 그것은 리액트 엘리먼트와 관련이 있다. 원인을 살펴보기 전에 내 블로그의 "What is JSX?"를 읽으며 리액트 엘리먼트와 JSX 의 관계에 대해 간략히 이해하는 것도 좋다.

리액트가 counter 함수를 호출하면, 아래와 유사한 무언가를 반환한다.

// 몇 가지를 제거한 예제
const counterElement = {
  type: "div",
  props: {
    children: [
      {
        type: "button",
        props: {
          onClick: increment, // 클릭 이벤트 핸들러 함수
          children: "The count is 0"
        }
      },
      {
        type: Logger, // Logger 컴포넌트 함수
        props: {
          label: "counter"
        }
      }
    ]
  }
};

이것들은 UI 기술 객체라고 불리우며, 리액트가 DOM에서 만들어야 하는 UI를 표현한다(react native에서는 native component들을 표현한다). 버튼을 눌렀을 때 이 객체에 어떤 변화가 생기는지 살펴보자.

const counterElement = {
  type: "div",
  props: {
    children: [
      {
        type: "button",
        props: {
          onClick: increment,        // 변경
          children: "The count is 1" // 변경
        }
      },
      {
        type: Logger,
        props: {
          label: "counter"
        }
      }
    ]
  }
};

우리는 button 엘리먼트의 props 인 onClickchildren 만 변경되었다고 말할 수 있다. 하지만 전체가 완전히 새로운 것으로 변경된다! 이전부터 리액트를 사용하여, 당신은 렌더링할 때마다 이런 새로운 객체들을 만들어왔다 (운좋게, 모바일 브라우저조차도 이런 작업을 굉장히 빠르게 수행하기 때문에 중요한 성능의 문제는 없었다).

아마 실제로는 리액트 엘리먼트들의 트리 구조에서 렌더링 사이에 동일한 부분을 찾는 것이 더 쉬운 일일 것이다. 여기 버튼을 누르기 전과 누른 후 두 렌더링 사이에 변하지 않는 부분을 나타냈다.

const counterElement = {
  type: "div",             // 불변
  props: {
    children: [
      {
        type: "button",    // 불변
        props: {
          onClick: increment,
          children: "The count is 1"
        }
      },
      {
        type: Logger,       // 불변
        props: {
          label: "counter"  // 불변
        }
      }
    ]
  }
};

모든 엘리먼트들의 type은 동일하며(이것은 당연한 것이다.), Logger 엘리먼트의 label 속성도 변하지 않는다. 그러나 객체의 속성이 이전의 props 객체와 같더라도, props 객체 자체는 매 번 렌더링할 때마다 변경된다.

바로 여기에 해답이 있다. Logger props 객체는 변경되기 때문에, 리액트는 새로운 props 객체(또는 props 객체 변화에 따라 실행되는 부수적 효과)에 기반한 JSX를 얻기 위해 Logger 함수를 다시 실행한다. 하지만 만약 렌더링 사이에 props 객체가 변경되는 것을 막을 수 있다면 어떨까? 리액트는 props 객체가 변경되지 않으면, 우리가 발생시킨 변경으로 인해 리렌더링할 필요가 없고, JSX가 변경되어서 안된다는 것을 알고 있다 (리액트는 우리의 렌더링 메소드가 멱등수가 되어야한다는 사실에 의존하기 때문이다). 리액트는 처음 나온 이후부터 계속 이런 방식으로 동작하였고, 해당 부분의 리액트 코드는 여기서 볼 수 있다.

하지만 문제는 리액트가 리액트 엘리먼트를 생성할 때마다 props 객체를 새로 만든다는 것이다. 그러면 우리는 렌더링 사이에 props 객체가 변화하지 않는다는 것을 어떻게 확실하게 할 수 있을까? 이제 당신이 위의 두 번째 예제에서 왜 Logger 엘리먼트가 리렌더링 되지 않았는지 이해했으면 좋겠다. 우리가 JSX 엘리먼트를 한 번 만들고, 그것을 재사용한다면 우리는 항상 같은 JSX 엘리먼트를 얻을 수 있는 것이다!

같이 이전으로 돌아가 보자

다시 두 번째 예제를 보자.(스크롤을 올려 되돌아갈 필요가 없다)

// codesandbox: https://codesandbox.io/s/react-codesandbox-o9e9f

import React from "react";
import ReactDOM from "react-dom";

function Logger(props) {
  console.log(`${props.label} rendered`);
  return null; // 여기서 반환되는 값은 부적절하다...
}

function Counter(props) {
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(c => c + 1);
  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      {props.logger}
    </div>
  );
}

ReactDOM.render(
  <Counter logger={<Logger label="counter" />} />,
  document.getElementById("root")
);

그럼 렌더링 사이에 동일한 부분을 확인해보자.

const counterElement = {
  type: "div",         // 불변
  props: {
    children: [
      {
        type: "button", // 불변
        props: {
          onClick: increment,
          children: "The count is 1"
        }
      },
      // Logger element 자체가 불변한다.
      {
        type: Logger,
        props: {
          label: "counter"
        } 
      }
    ]
  }
};

Logger 엘리먼트 자체는 전혀 변경되지 않았기 때문에(props 도 역시 변경되지 않았다), 리액트가 자동으로 이런 최적화를 제공할 수 있고 다시 렌더링 할 필요가 없는 Logger 엘리먼트를 렌더링하지 않는다. 이 방법은 각각의 props를 모두 체크하는 것 대신에, 리액트가 props 객체 전체만 체크한다는 것을 제외하고 기본적으로 React.memo 동작 방식과 같다.

그래서 이것이 어떤 의미가 있는가?

요약하면, 성능 문제를 겪고 있다면 이것을 시도해봐라:

  1. 렌더링 비용이 비싼 컴포넌트는 부모 컴포넌트와 같은 레벨로 "끌어 올려서" 렌더링을 횟수를 줄인다.
  2. 그리고 해당 컴포넌트를 prop으로 내려준다.

당신은 거슬리는 반창고와 같이 코드베이스 전체에 React.memo를 적용할 필요없이 성능 문제를 해결할 수 있다.🤕😉

데모

리액트에서 느린 앱의 실용적인 데모를 만드는 것은 다소 어려운 일이다. 그 이유는 앱 전체를 빌드해야하기 때문이다. 하지만 여기 당신이 이전 / 이후를 확인하고 수정할 수 있는 인위적인 예제 앱이 있다.

데모 링크

한 가지 추가적으로 말하고 싶은 것은 이 코드의 더 빠른 버전을 사용하여도, 초기 렌더링의 성능은 여전히 좋지 않다는 것이다. 그리고 다른 하향식 렌더링을 다시 수행해야 한다면(또는 행/열을 업데이트하거나) 성능이 매우 좋지 않을 것이다. 이는 자체적으로 처리해야 하는 성능상의 문제이다(리렌더링이 얼마나 필요한지와 관계없다). 또한 codesandbox에서 당신에게 제공하는 리액트의 버전은 편리한 개발의 경험을 제공해줄 뿐이고, 리액트의 production 버전보다 느리게 수행된다는 것을 기억해달라.

그리고 이 방법은 앱의 최상위 레벨에서만 유용한 것이 아니다. 렌더링 성능 문제를 해결하기 위해 당신의 앱 어디에서든 적용 가능하다. 내가 이 방법을 좋아하는 이유는 다음과 같다 "이 방식은 자연스러운 컴포넌트의 구성 방식이면서 최적화에도 도움을 줄 수 있다."(Dan이 말한 것이다). 필자는 자연스럽게 이 방법을 적용하였고 더불어 성능 향상의 효과까지 얻었다. 그리고 그것이 항상 내가 리액트에서 좋아했던 부분이다. 리액트는 기본적으로 리액트 앱이 빠르게 동작할 수 있도록 작성되었고, 당신이 필요한 경우 사용할 수 있는 여러 최적화 헬퍼들을 제공한다.

행운을 빈다!

필자는 만약 당신이 기존 context를 사용하는 경우라면 리액트 내부의 특별한 처리때문에 이 최적화 방법을 적용할 수 없을 거라 알려주고 싶다. 따라서 성능에 관심이 있는 사람들은 기존 context에서 마이그레이션 작업을 해야만 할 것이다.