React 렌더링과 성능 알아보기


React는 간단하면서도 충분히 빠르다. 하지만 착각하지 말자. 대충 만들어도 빠르다는 것은 아니다. React 애플리케이션은 잘 만들어야 빠르다. React의 동작 원리를 이해하고 애플리케이션이 느려질 수 있는 상황들을 찾아 해결하는 것이 중요하다. 다행히 React는 간단하고 이해하기 쉬운 라이브러리다.

React의 성능 최적화를 위해서는 React의 Element, Component, Instance의 의미와 렌더링에 대한 정확한 이해가 선행되어야 한다. 이에 관해서는 [Dan Abramov의 React Components, Elements, and Instances](https://medium.com/@danabramov/react-components-elements-and-instances-90800811f8ca)에서 잘 설명하고 있으니 꼭 읽어보길 바란다._

Render

(이 글에서 말하는 엘리먼트는 HTMLElement가 아닌 ReactElement를 의미한다)

React는 UI의 구조를 내부적으로 컴포넌트가 반환하는 엘리먼트들을 트리 형태로 관리하고 표현한다. 그리고 이 표현에 일반 객체(Plain object)를 사용한다. 내부적으로 먼저 관리하고 변경이 필요한 부분만 찾도록 구현했기 때문에 불필요한 DOM 노드의 생성이나 제어를 최소화한다. 보통 이 구조를 Virtual-DOM이라고 부르기도 하지만 IOS나 Android의 UI를 처리하는 React Native도 같은 방식으로 동작한다. (때문에 엄밀히 따지면 Virtual-DOM은 살짝 오용되고 있다.)

컴포넌트의 Props나 State의 변경이 있을 때 React는 컴포넌트의 이전 상태 엘리먼트와 새로 만들어진 엘리먼트를 비교하고 실제 DOM의 업데이트 여부를 결정한다. 엘리먼트를 비교하여 찾은 변경 점에 대해서만 갱신하는 것이다.

그리고 엘리먼트는 기본적으로 Immutable이기 때문에 속성들(Props)을 직접 수정할 수 없다. 각 렌더링에 항상 새로운 엘리먼트(DOM Node가 아닌 일반 객체라는 점을 잊지 말자.)를 만든다. 엘리먼트는 영상의 한 프레임과 같다고 생각하면 된다.

ReactDOM.render(element, document.getElementById("root"));

위의 코드는 ReactDOM.render() API로 렌더링을 수행한다. 하지만 매번 모든 변화에 대해서 직접 ReactDOM.render()를 호출할 필요는 없다. 컴포넌트의 setState() 메서드가 수행되면 해당 컴포넌트를 변경 대상 컴포넌트(Dirty component)로 등록하고, 다음 이벤트 루프에서 배치 작업으로 대상 컴포넌트들의 엘리먼트를 렌더링한다.

이런 React의 렌더링을 쪼개보면 변경 점을 찾는 과정(Reconciliation의 Diffing 알고리즘)과, 변경 점을 실제 UI에 적용하는 작업으로 나눌 수 있다. 브라우저 기준으로는 React-core의 Reconciliation 작업과 DOM 조작 작업(ReactDOMComponent.updateComponent)으로 생각할 수 있다.

Reconciliation: The diffing algorithm

React의 Reconciliation은 어떤 변경에 대한 전/후 엘리먼트 트리를 비교(Diff)하여 갱신이 필요한 부분만을 찾아 업데이트하는 것을 의미한다. React는 렌더링에서 Reconciliation 작업을 선행하기 때문에 플랫폼 UI에 대한 제어를 최소화 시키는 것이다(보통 UI 제어 비용은 비싸기 때문이다). 즉 브라우저에서 DOM에 대한 제어를 최소화시키는 것이다.

다시 한번 정리해보면, React 컴포넌트는

  1. render()에서 새로운 엘리먼트 트리를 생성하고,
  2. 이전 엘리먼트 트리와 비교하여 변경 점을 찾아 업데이트한다.

그런데 기존의 Diff 알고리즘은 O(n^3)의 시간복잡도를 가지고 있다. 그래서 React는 다음 두 가지 가정을 가지는 휴리스틱 알고리즘으로 O(n)에 근사할 수 있도록 구현하였다.

  1. 다른 타입의 두 엘리먼트는 다른 트리를 만들 것이다.
  2. 각 렌더링에서 유지되는 엘리먼트에 key 프로퍼티를 통해 같은 엘리먼트라는 것을 알린다. (같은 레벨에서만 유효하다.)

이제 Diff 방식을 조금 더 자세히 알아보자.

Level By Level

트리를 비교할 때 기본적으로 서브트리들의 위치(level-by-level)를 기준으로 비교한다.

level-by-level

Elements Of Different Types

같은 위치에서 엘리먼트의 타입이 다른 경우,

  1. 기존 트리를 제거 후 새로운 트리 만든다.
  2. 기존 트리 제거시 트리 내부의 엘리먼트/컴포넌트들은 모두 제거한다.
  3. 새로운 트리를 만들 때 내부 엘리먼트/컴포넌트들도 모두 새로 만든다.
{
  /* Before */
}
<div>
  <Counter /> {/* Will unmount */}
</div>;

{
  /* After */
}
<span>
  <Counter /> {/* Will mount, Did mount */}
</span>;

DOM Elements Of The Same Type

같은 위치에서 엘리먼트가 DOM을 표현하고 그 타입이 같은 경우,

  1. 엘리먼트의 attributes를 비교한다.
  2. 변경된 attributes만 업데이트한다.
  3. 자식 엘리먼트들에 diff 알고리즘을 재귀적으로 적용한다.
{/* Before */}
<div className="before" title="stuff" />

{/* After */}
<div className="after" title="stuff" /> {/* Update className */}

Component Elements Of The Same type

같은 위치에서 엘리먼트가 컴포넌트를 표현하고 그 타입이 같은 경우

  1. 컴포넌트 인스턴스 자체는 변하지 않는다.(때문에 컴포넌트의 state가 유지된다.)
  2. 컴포넌트 인스턴스의 업데이트 전 라이프 사이클 메서드들이 호출되며 props가 업데이트된다.
  3. render()를 호출하고, 컴포넌트의 이전 엘리먼트 트리와 다음 엘리먼트 트리에 대해 diff 알고리즘을 재귀적으로 적용한다.
{
  /* Before */
}
<Counter value="3" />;

{
  /* After */
}
{
  /* Will recevie props, Will update, Render --> diff algorithm recurses */
}
<Counter value="4" />;

Recursing On Children

기본적으로 자식 엘리먼트들에 대해 반복적인 비교를 할 때, React는 이전/다음 상태의 자식 엘리먼트 목록을 함께 반복하고 그 차이를 본다. 따라서 엘리먼트들의 정렬과 같은 상황에 취약하다.

{
  /* Before */
}
<ul>
  <li>first</li> {/* prev-first */}
  <li>second</li> {/* prev-second */}
</ul>;

{
  /* After (with reordering) */
}
<ul>
  <li>second</li> {/* Compares prev-first --> Update dom */}
  <li>first</li> {/* Compares prev-second --> Update dom */}
  <li>third</li> {/* Compares prev --> Insert dom */}
</ul>;

Keys

엘리먼트들에게 Key 속성을 명시적으로 부여하여 위와 같은 상황에 발생하는 필요 없는 업데이트를 최소화시킬 수 있다. 단, (현재 구현된 React는) 형제 노드 사이에서 이동되었다는 것은 표현할 수 있지만, 형제 노드 사이가 아닌 다른 어딘가로 이동되었다는 것은 표현할 수 없다. key는 하나의 서브 트리에서만 유니크한 값을 가지면 되고 각 렌더링에서 변경이 없어야 한다. 그리고 다른 서브 트리와는 무관하다.

{
  /* Before */
}
<ul>
  <li key="first">first</li> {/* prev-first */}
  <li key="second">second</li> {/* prev-second */}
</ul>;

{
  /* After (with reordering) */
}
<ul>
  <li key="second">second</li>{" "}
  {/* Compares prev-second --> Update X, Reorder dom */}
  <li key="first">first</li> {/* Compares prev-first, --> Update X, Reorder dom */}
  <li key="thrid">third</li> {/* Compares prev --> Insert dom */}
</ul>;

Avoid Reconciliation

앞서 설명한 대로 React는 Reconciliation에서 O(n)의 시간복잡도를 가지고 있으며, 필요 이상의 DOM 접근이나 업데이트를 피하기 때문에 일반적인 경우 성능에 대해 고민 하지 않아도 된다.

하지만 컴포넌트가 렌더링하는 엘리먼트가 수천 수만 개라면? O(n)도 느리다.

개발자는 일부 경우에 실제 렌더링이 필요 없는 상황을 알고 있다. 그래서 컴포넌트가 렌더링 전에 호출하는 라이프사이클 메서드 shouldComponentUpdate()를 오버라이드하여 성능을 향상시킬 수 있다. React의 shouldComponentUpdate 기본 구현은 return true이기 때문에, 오버라이드하지 않은 경우에는 항상 Reconciliation을 포함한 렌더링 작업을 수행한다. 개발자는 컴포넌트 렌더링이 필요 없는 때에만 return false를 통해 React의 불필요한 렌더링 작업을 방지할 수 있다.

ShouldComponentUpdate In Action

아래와 같은 엘리먼트 트리의 렌더링 과정을 보자.

should-component-update

C2에서 SCU(shouldComponentUpdate)가 false를 반환하여 렌더링을 시도하지 않고 따라서 C4, C5의 SCU는 발생하지 않는다.

C1, C3는 SCU에서 true를 반환한다. 그래서 React는 자식 컴포넌트를 순회하며 렌더링 여부를 확인한다. C6은 SCU에서 true를 반환하고, 이전 상태의 엘리먼트와 새로 만들어진 엘리먼트의 차이를 감지해 DOM 업데이트를 수행한다.

C8의 경우를 보자. SCU에서 true를 반환했기 때문에 엘리먼트를 렌더링한다. 그런데 이전 상태의 엘리먼트와 다음 상태의 엘리먼트의 차이가 없기때문에 DOM을 업데이트하지 않는다. 이런 경우 React의 렌더링 과정은 불필요하고 당연히 성능 저하를 유발한다.

전체적으로 볼 때 React는 C6에 대해서만 DOM 업데이트를 한다. C8의 경우 새로 만들어진 엘리먼트를 비교하고 차이가 없어 DOM 업데이트를 수행하지 않았고, C2와 C7은 SCU로부터 false를 반환받아 엘리먼트 비교 없이 업데이트도 하지 않았다. (render()도 호출되지 않은 것이다.)

PureComponent

React.PureComponentshouldComponentUpdate API를 제외하고 React.Component와 같다. PureComponent는 renderer에서 shouldComponentUpdate 라이프사이클 로직을 수행할 때 기본적으로 shallow-compare를 수행한다. 즉 우리가 흔히 말하는 순수 함수처럼 같은 입력에는 같은 출력이 나오는 의미에서 Reconciliation 동작을 수행하지 않겠다는 의미다. 다만 n-depth의 복잡한 자료구조에 대해서 deep-compare를 수행하면 오히려 배보다 배꼽이 더 큰 경우가 될 수 있기 때문에 shallow-compare로 제한하고 있다. React를 개발한다면 state나 props를 최대한 가볍게, 혹은 Immutable 객체를 사용하여 개발하는 것을 추천한다.

여기에서 주의할 점이 하나 있는데, 보통 PureComponent가 shouldComponentUpdate에서 shallow-compare를 한다고 설명한다. 그렇지만 PureComponent class가 shouldComponentUpdate를 정의하고 있다는 뜻은 아니며 실제로 정의하고 있지도 않다(React v15.4.2 기준).

renderer의 동작을 부분적으로 조금 더 자세히 설명하자면 아래와 같다. (참고: PR#7195)

//...
if (instance.shouldComponentUpdate) {
  shouldUpdate = instance.shouldComponentUpdate(
    nextProps,
    nextState,
    nextContext
  );
} else if (instance.isPureComponent) {
  // PureComponent는 shouldComponentUpdate의 구현체가 없고 renderer에서 직접 shallow-compare를 수행한다.
  shouldUpdate =
    !shallowEqual(prevProps, nextProps) ||
    !shallowEqual(instance.state, nextState);
}
//...

return shouldUpdate;

때문에 다음과 같은 코드는 에러가 발생한다.

class Foo extends PureComponent {
  //....

  shouldComponentUpdate(nextProps, nextState) {
    // PureComponent에 shouldComponentUpdate의 구현체가 없기때문에 아래 코드는 에러가 발생한다.
    const result = super.shouldComponentUpdate(nextProps, nextState);
    log("Foo: shouldComponentUpdate", result);

    return result;
  }

  //...
}

결론적으로 PureComponent에서 shouldComponentUpdate를 작성하는 것은 PureComponent 구현을 무시하는 것이기 때문에 작성하지 않아야 한다(현재 글을 작성하는 시점에서 PureComponent를 상속받은 후 shouldComponentUpdate를 따로 작성하는 경우, 개발 모드에서 경고 메시지를 나타내자는 의견이 있다. - Issue #9239).

React-Addons-Perf

React는 일반적으로 reconciliation, avoid-reconciliation, production build 등의 기법을 통해 빠르게 동작한다. 하지만 일반적인 방식만으로는 복잡한 앱을 개발하기 어려울 때가 있고, 개발자가 실수할 수도 있고, 너무 많은 데이터 처리 등의 여러 상황에 따라 원하는 성능을 얻지 못할 수 있다.

앞서 살펴본 ShouldComponent In Action의 C8의 경우는 불필요한 성능 하락의 원인인데, 개발자는 이런 경우를 찾아 없애야 한다.

그런데 성능 문제가 있는 컴포넌트를 코드만 보고 찾기는 어렵다. 성능 측정 도구인 react-addons-perf를 통해 렌더링 성능을 정확히 측정하고, 성능 하락의 원인을 찾을 수 있다.

측정하기

Perf.start();
// ....
Perf.stop();

const measurements = Perf.getLastMeasurements();
  1. start(): 측정 시작
  2. stop(): 측정 끝
  3. getLastMeasurements(): 가장 마지막 측정 결과 가져오기

결과 출력

Perf.printInclusive(); // Last measurements
Perf.printInclusive(measurements);

Perf.printExclusive(); // Last measurements
Perf.printExclusive(measurements);

Perf.printWasted(); // Last measurements
Perf.printWasted(measurements);

Perf.printOperations(); // Last measurements
Perf.printOperations(measurements);
  • printInclusive(): 전체 소요 시간 출력 perf-inclusive
  • printExclusive(): 컴포넌트가 마운트 되는 시간 제외하여 출력 (props 처리, componentWillMount(), componentDidMount() 등) perf-exclusive
  • printWasted(): 실제 렌더링이 없는 컴포넌트에서 소비된 시간 (ex - Diff 결과 차이가 없어 실제 DOM 변화가 없음) perf-wasted
  • printOperations(): DOM 조작에 관한 로그 perf-operations

사용성

보통 성능 이슈는 printWasted() API로 측정하고 해결할 수 있다. 그리고 대부분의 경우 PureComponent 분리를 통해 해결할 수 있다.

printInclusive()printExclusive()를 통해 컴포넌트의 마운트/업데이트 등에 대한 비용을 확인할 수 있다. 특히 LifeCycle의 로직이 복잡한 경우를 쉽게 확인할 수 있다. printOperations()는 React가 실제로 DOM을 생성하거나 업데이트하는 로그를 나타낸다. 예상치 못한 DOM 접근/수정 등을 확인할 수 있다.

Bad Cases

다음은 개발하면서 실수하기 쉬운 경우들이다. 사실 렌더링과 개발 시 주의할 점에 대해서 충분히 이해하고 넘어갈 수 있지만, 큰 애플리케이션을 개발하다 보면 쉽게 실수하고 놓칠 수 있는 부분들이다. 실수해서 놓치는 것은 빈번히 발생할 수 있다. 하지만 표면적으로 문제가 발생했을 때 이를 바로바로 알아채기 위해서 다음 2가지 경우는 기억하도록 하자.

  1. 분리되지 않은 컴포넌트
  2. 잘못된 Props 전달

사실 위 2가지 경우 모두 같은 원인을 가지고 있다. 그리고 위 경우가 아니더라도 React 렌더링에서 성능 저하가 발생할 수 있는 경우는 많지만, 그 경우들도 대부분은 모두 불필요한 Reconciliation에서 처리하는 시간이 문제일 것이다.

분리되지 않은 컴포넌트

컴포넌트의 적절한 분리가 이루어지지 않는다면 가독성, 유지보수 등뿐만 아니라 성능적으로도 매우 큰 손해를 볼 수 있다. 다음 예시를 살펴보자.

테스트용 애플리케이션의 App 컴포넌트는 Root 컴포넌트로 자신의 titlelistItems를 관리한다. 따라서 다음과 같은 state를 가지고 있다.

state = {
  listItmes: [],
  title: "Test app"
};

다음은 List를 하나의 컴포넌트로 분리하지 않은 코드이다. (테스트에서 Item 컴포넌트는 PureComponent를 상속받아 테스트에 큰 영향이 없도록 하였다.)

// 아래는 간단히 나타낸 App 컴포넌트의 render 메서드이다.
render() {
  <div className="app">
    ...
    <div className="app-intro">
      {this.state.title}
    </div>
    <div className="list-container">
      <ul>
      {
        this.state.items.map((item) => {
          return <Item key={item.id} {...item} />
        })
      }
      </ul>
    </div>
  </div>
}

다음은 List를 하나의 컴포넌트로 분리한 코드이다. 이때 List 컴포넌트는 PureComponent를 상속받도록 하였다.

// 아래는 간단히 나타낸 App 컴포넌트의 render 메서드이다.
render() {
  <div className="app">
    ...
    <div className="app-intro">
      {this.state.title}
    </div>
    <List items={this.state.items} />
  </div>
}

이렇게 렌더링하는 두 애플리케이션에서 title을 변경하면 무엇이 더 빠를지 생각해보자.

두 애플리케이션의 App 컴포넌트에서 componentWillUpdate()부터 componentDidUpdate()까지 시간을 UserTimingAPI로 측정하였다.

  • List 컴포넌트로 분리 되지 않은 경우(App-bad)
    app-bad
  • List 컴포넌트로 분리된 경우(App)
    app
  • App의 트리 app-tree

테스트용 앱은 매우 간단한 앱임에도 불구하고 약 30~40배의 확연한 차이가 있었다. 조금 더 복잡한, 조금 더 큰 애플리케이션이라면 그 차이는 더욱 벌어질 것이다.

그리고 또 중요한 점은, title을 변경할 때 Perf.start(), Perf.stop(), Perf.printWasted() API로 성능을 측정했지만, 두 경우 모두 WastedTime이 발생하지 않았다(ReactPerf.js에서 아무런 로그를 남기지 않았다). 즉 컴포넌트를 제대로 분리하지 않으면 성능에 손해가 있어도 쉽게 알아챌 수 없다는 것이다.

물론 printInclusive(), printExclusive(), printOperations() API, 브라우저 개발자도구 등을 가지고 위와 같은 문제 현상을 측정할 수는 있지만, 컴포넌트 분리로 간단하게 측정하고 해결할 수 있는 문제를 굳이 어렵게 측정하고 해결할 필요는 없다.

잘못된 Props 전달

컴포넌트를 적절히 분리하고, PureComponent를 사용해도 여전히 의도치 않은 성능 하락을 일으킬 수 있다. 다음 코드를 살펴보자.

// 아래는 간단히 나타낸 App 컴포넌트의 render 메서드이다.
render() {
  return (
    <div className="app">
      ...
      <div className="app-intro">
        {this.state.title}
      </div>
      <List items={this.state.items} deleteItem={id => this.deleteItem(id)}/>
    </div>
  );
}
// List는 PureComponent를 상속받고 있으며,
// List의 Item은 일반적인 SFC이다.

// 아래는 간단히 나타낸 List 컴포넌트이다.
class List extends PureComponent {
  static propTypes = {
    items: PropTypes.array,
    deleteItem: PropTypes.func
  };

  render() {
    const items = this.props.items.map(item => {
      return (
        <Item
          key={item.id}
          {...item}
          onClickDeleteButton={this.props.deleteItem}
        />
      );
    });

    return <ul>{items}</ul>;
  }
}

List에서 Item을 제거하기 위한 deleteItem이라는 함수를 props를 통해 전달했다. 언뜻 보면 별문제 없어 보이지만 사실은 엄청난 성능 하락을 일으키고 있다.

App 컴포넌트에서 title을 변경하는 경우에 대해 react-addons-perf로 측정한 성능을 보자.

app-bad2

이런 허비 시간이 발생하는 이유는 바로 deleteItem={id => this.deleteItem(id)}구문이다. App의 render()에서 List로 넘어가는 deleteItem이 항상 새로운 함수로 생성되기 때문에, List가 PureComponent라도 Reconciliation 작업에 포함된 것이다. 그래서 보통 props로 넘어가는 함수는 생성자에서 미리 바인딩하고 deleteItem={this.deleteItem} 구문처럼 새로운 함수 생성 없이 전달하는 것을 추천한다.

그리고 react-redux의 connect HOC 에서도 이와 같은 경우가 자주 있는데, 아래 mapStateToProps를 보자.

// State의 items는 Immutable객체이다.
const mapStateToProps = state => {
  items: state.items.toArray();
};

export default connet(mapStateToProps)(List);

위 코드는 아까보다 훨씬 더 큰 성능 저하를 일으킨다. Store의 모든 업데이트에서 React는 List를 항상 렌더링 작업에 포함한다. 그래서 이런 경우는 Immutable 객체를 array와 같은 형태로 변환하지 말고 그대로 컴포넌트에 전달하는 것을 추천한다.

const mapStateToProps = state => {
  items: state.items;
};

// 이 경우 List는 items를 array가 아닌 Immutable 객체로 처리해야 한다.
export default connet(mapStateToProps)(List);

마치며

React는 직접적인 UI 제어를 최소화시켜 동작하기 때문에 기본적으로 빠르다고 생각할 수 있지만, 제어를 최소화시키기 위해 선행되는 작업에 대한 비용을 무시해서는 안 된다.

Reconciliation은 기본적인 개념이면서도 의도하지 않은 큰 성능 하락을 일으킬 수 있다. 사실 개발자가 모든 성능 하락에 대한 경우를 다 기억하고, 코드를 작성할 때 바로바로 파악하는 것은 어렵다. 처음부터 성능에 최적화된 애플리케이션을 고려하는 것 자체가 비효율적이다. 하지만 컴포넌트를 적절히 분리하는 것은 중요하다. 아무리 간단한 엘리먼트와 로직이라도 기능, 책임, 재사용성 등으로 컴포넌트를 적절히 분리하여 이슈를 빠르게 파악하고 대응할 수 있도록 개발하길 권장한다.

Reference

React Docs