원문보기

임포트 하기

import Perf from 'react-addons-perf' // ES6
var Perf = require('react-addons-perf') // ES5 with npm
var Perf = React.addons.Perf; // ES5 with react-with-addons.js

개요

React는 그냥 두어도 적당히 빨라보인다. 그러나, 애플리케이션의 1 온스의 성능을 쥐어 짜야하는 상황에서는 React의 diff 알고리즘에 최적화 힌트를 추가할 수 있는 shouldComponentUpdate() 훅을 제공한다.

일반적으로 앱의 전반적인 성능을 살펴볼 수는 있지만, 추가적으로 제공하는 프로파일링 도구 Perf를 이용하면 어디를 후킹하여 최적화할 필요가 있는지 알 수 있다.

Benchling Engineering Team에서 작성한 다음 두 기사에서 심도있는 프로파일링 툴 사용법을 소개하고 있다.

개발용 빌드 vs 제품용 빌드(Development vs. Production Builds)

만약 React 애플리케이션의 성능을 본다거나 혹은 벤치마킹 중이라면 제품용 빌드minified production build를 사용하여 테스트 중인지 확인해야 한다. 개발용 빌드를 사용하고 있다면 개발에 도움이 되는 추가적인 경고들을 포함하고 있기 때문에 성능 저하의 원인이 되기 때문이다.

하지만, 이 페이지에서 소개하고 있는 perf 도구는 개발용 빌드에서만 동작합니다. 따라서 프로파일러는 앱 중에서 상대적으로 문제가 되는 부분만을 표시한다.

Perf 사용하기

Perf 오브젝트는 개발 모드에서만 동작한다. 앱의 제품용 빌드에 포함해서는 안된다.

측정 방법

결과 출력

다음 메서드들은 Perf.getLastMeasurements()의 리턴된 측정값을 사용하여 결과를 보기 좋게 한다.


레퍼런스

start()

stop()

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

측정을 시작하고 멈춘다. 그 사이에 일어나는 React의 조작들이 기록되고 분석된다. 시간을 별로 들이지 않은 조작들은 무시된다.

측정을 멈추고 나서, Perf.getLastMeasurements()를 호출하여 측정값을 구할 필요가 있다.


getLastMeasurements()

Perf.getLastMeasurements()

바로 이전의 start-stop 세션에서의 측정치를 기술한 불투명한(opaque) 구조를 가진 데이터를 얻는다. 이 데이터를 저장면 Perf의 다른 출력 메서드에 전달할 수 있다.

유의사항

이 부분이 공개 API에 적용되는 부분이 되면 문서에 업데이트 할 것이므로, 반환값의 정확한 형식에 의존하지 말아라


printInclusive()

Perf.printInclusive(measurements)

전체 시간을 출력한다. 만약 아무런 아규먼트도 전달되지 않는다면 기본적으로 모든 측정값을 출력한다. 이 메서드는 다음 그림과 같이 멋진 형식의 테이블로 콘솔에 출력한다:


printExclusive()

Perf.printExclusive(measurements)

“Exclusive”는 컴포넌트를 마운트하는데 걸린 시간을 포함하지 않는다. 즉, props를 처리하고 componentWillMount, componentDidMount 등에 걸린 시간은 제외한다.


printWasted()

Perf.printWasted(measurements)

프로파일러에서 가장 중요한 부분.

“Wasted”는 아무것도 실제로 렌더링하지 않았을 때 소비된 시간이다. 즉, 렌더링 결과가 그대로여서 아무런 DOM도 터치하지 않은 시간이다.


printOperations()

Perf.printOperations(measurements)

기본 DOM 조작들을 출력한다. 즉, “set innerHTML” 혹은 “remove”을 뜻한다.


printDOM()

Perf.printDOM(measurements)

제거될 예정이고, deprecation 경고를 출력한다. printOperations()로 변경되었다.

원문보기

이 포스트는 React 성능 엔지니어링 시리즈의 첫 번째 파트다. Part 2 - A Deep Dive into React Perf Debugging 도 올라왔다!

이 포스트는 복잡한 React 애플리케이션을 작성한 사람들을 위한 것이다. 만약 단순한 것을 작성 중이라면 성능에 포커싱하는 일은 별로 필요하지 않을 것이다. 섣부른 최적화는 금물! 만드는 것이 먼저다!

하지만, DNA 디자인 도구나, 젤 형태 이미지 분석 소프트웨어, rich-text 에디터, full-feature 스프레드시트 등을 개발 중이라면 아마도 성능 병목 현상과 마주칠 것이고, 이를 해결해야만 한다. 우리는 Benchling에서 이 성능 병목 현상을 마주쳤고, 우리가 배운 것들 중 일부를 공유하려고 이 포스트를 작성했다. 그래서 Benchling 사람들과 인터넷 상의 사람들을 대상으로 작성했다.(그리고 맞다! 우리는 이런 종류의 문제를 좋아하는 사람을 고용 중이다!)

이 포스트에서는 React의 Perf 도구 이용의 기초, React 렌더링 병목현상의 일반적인 이슈를 다루며, 또한 디버깅 도중 염두해 둘만한 팁들을 다룬다.

React 기초(Baseline React)

브라우저 성능을 세 문장으료 요약하면: 초당 60프레임으로 렌더링하고 프레임 당 16.7ms를 남겨 두는 것이 이상적이다. 앱이 느리다는 것은 사용자 이벤트에 응답하는 것이 오래 걸리거나, 데이터를 처리는데 시간이 오래 걸리거나, 새 데이터를 다시 렌더링하는 것이 오래 걸리는 것이다. 대다수의 경우에는 데이터를 처리하는 것이 아니라 다시 렌더링하는 것에 시간을 낭비하고 있다.

React를 사용하면 별다른 작업 없이도 즉시 성능향상을 이뤄낼 수 있다.

왜냐하면 React가 모든 DOM 조작을 다루기 때문이다. 따라서 DOM을 파싱하고 레이아웃하는 이슈를 크게 회피할 수 있다. 장막 뒤에서는 React가 자바스크립트 내에서 virtual DOM을 관리하고 있으며, 원하는 상태의 문서를 만들어내는데 필요한 최소한의 변화만을 빠르게 결정하여 사용한다.

왜냐하면 React 컴포넌트의 상태는 자바스크립트에 저장되어 있기 때문에 DOM에 직접 접근하는 것을 피할 수 있다. 고전적인 성능 이슈는 DOM을 부적절한 순간에 접근하기 때문이다. 일반적으로 이런 부적절한 순간 문제란 강제로 layout 동기화 같은 문제(예: someNode.style.left를 읽으면 브라우저는 강제로 프레임을 렌더링한다)다.

다음과 같이 하는 대신에,

someNode.style.left = parseInt(someNode.style.left) + 10 + "px";

선언적으로 “<SomeComponent style=/>”과 같이 DOM 상태를 읽지 않고도 컴포넌트가 움직이도록 간단하게 업데이트할 수 있다.

this.setState({left: this.state.left + 10}).

더 명확히 하자면, 이런 최적화는 React 없이도 가능하다 - 여기서 말하고자 하는 바는 바로 React가 이런 문제를 미리 해결하는 경향이 있다는 것이다.

단순한 애플리케이션에서는 이 성능 최적화가 React를 사용하는 것만으로 충분하다 - 나는 그것이 선언적 프레임워크가 실현될 수 있는 최소한의 작업이라고 생각한다. 그러나 보다 복잡한 뷰들을 개발하고, 관리하고, virtual DOM을 비교하는 것은 비용이 많이 드는 작업이 될 수 있다. 다행히도, React는 성능 문제가 존재하는 곳을 감지하고 이를 방지하기 위한 수단을 몇 가지 툴을 통해 제공한다.

디버깅으로 인한 성능 이슈(Performance issues caused by debugging)

조심! - 디버깅하는 것 자체만으로 오버헤드가 생길 수 있고 제품에서는 생기지도 않는 디버깅 세션의 혼란을 야기할 수도 있다.

Elements pane

Elements pane은 어떤 것이 다시 렌더링되는지 보여주는 훌륭하고 단순한 방법이다 - 속성이 변경되거나 갱신/추가/치환되는 DOM node를 깜빡이는 컬러로 보여준다. 그러나 이 깜빡임이 바로 성능에 영향을 준다! 나는 종종 Console pane으로 전환해서 FPS에 대한 정확한 감각을 유지한다.

PropTypes

개발 빌드의 React에서는 컴포넌트를 렌더링할 때 PropType의 유효성 검사가 일어난다 - 컴포넌트가 전달받는 props를 확인해서 디버깅과 개발을 돕는다. 크롬의 JS 프로파일러를 사용할 때 보면, React component가 validate 메서드에서 가장 많은 시간을 소비하는 것을 볼 수 있을 것이다.

개발 빌드에서 나타나는 경고들은 디버깅시에는 유용하고, 그 코스트는 제품에는 반영되지 않는다. 나는 개발 빌드에서의 느린 반응속도에 대한 잘못된 감각을 무시하기 위해 가끔 React의 제품 빌드로 전환한다. (제품 빌드를 사용하려면 NODE_ENV를 production으로 세팅한다: https://facebook.github.io/react/downloads.html#npm)

React.addons.Perf와 성능 이슈 식별하기

일반적인 수정사항에 들어가기에 앞서, 측정할 수 있었던 문제에 대해서만 시간을 투자해야 한다는 것을 강조하는 것이 중요하다. 훈련하지 않았다면 어둠 속에서 측정을 마치기 일쑤다 - 다시 말하자면 개발에 주력하고 핵심 성능 병목 현상을 해결하는 데만 시간을 투자하자.

표준적인 디버깅 도구를 이용해서 병목 현상을 식별하는 것은 여전히 가능하지만 도구가 React측 코드에 시간을 소비할 수 있으므로 데이터를 해석하기가 어렵다. (예: 빠르게 실행되도록 작성한 복잡한 렌더 메서드를 사용하면 가상 DOM에 대한 계산 결과가 훨씬 비싸진다.) 그래서 React측에서 가시적인 병목 현상을 유발한 코드가 무엇인지 식별하기 어렵게 된다.

다행히도 React는 React의 개발 빌드에서 사용할 수 있는 몇 가지 perf 도구들과 함께 번들로 제공됩니다. 0.13에서는 React.addons.Perf에서 찾을 수 있고 0.14 이상에서는 자체적인 react-addons-perf 패키지에서 찾을 수 있다.

사용법

Perf를 사용하려면 콘솔에서 Perf.start()를 호출하면 된다. 그리고 나서 기록하고 싶은 행동을 하고, 다시 Perf.stop()을 선언하면 된다. 그리고 나서 다음 메서드들 중 하나를 호출해서 측정값을 출력해서 확인하면 된다.

성능 디버깅 모드에서 나는, 간단하게 start/stop 레코딩 버튼을 만들어서 성능을 측정한다. (코드는 정말 간단하다 - 컴포넌트를 화면 한 쪽에 놓고 React.addons.Perf를 호출하도록 한다.) React DevTools 처럼 Chrome Extension으로 사용할 수도 있다. Jeff가 start/stop에 단축키를 바인드하는 환상적인 팁을 알려줬다.

Perf.printWasted()

Perf.printWasted()는 가장 유용하다. 최종적으로 DOM 수정이 없는 경우인데도 render 트리를 생성하고 virtual DOM 비교를 하는 작업에 얼마나 많은 시간을 낭비했는지 찾아서 알려준다. 여기에 나타난 컴포넌트는 PureRenderMixin이나 다른 테크닉으로 수정되야할 주요 후보들이다.

Perf.printInclusive() / Perf.printExclusive()

이 출력 함수들은 컴포넌트를 렌더링 하는데 얼마나 많은 시간이 들었는지를 보여준다. 나는 렌더링 병목현상이 렌더링하지 않음으로 렌더링이 빨라지는 경우에 의해 해결되는 경우가 잦아서 이 함수들의 유용함을 찾지 못했었다. 그러나, 라이프사이클 메서드들 중 컴포넌트의 계산 성능이 많이 요구되는지 찾는 데에 도움이 될 수 있다. 나는 보통 printWasted 이슈를 해결한 후, 내 애플리케이션의 코드가 성능요구가 많다는 것을 알게되었다. 이 시즘에서는 Chrome DevTool의 표준 JS Profiler를 사용하고 가장 비싼 함수 호출이 무엇인지 직접 살펴보는 것이 좋다.

Perf.printDOM()

Perf.printDOM()은 React tree를 렌더링할 때 발생하는 모든 DOM 연산을 리턴한다. 내 경험상, 정확히 무엇이 일어났는지 설명하는 긴 항목이므로 각 속성 변경과 각각의 DOM 삽입에 대한 해석/시각화가 어렵다. 그리고 만약 애플리케이션이 충분히 복잡하다면 출력 내용은 굉장히 큰 변화로 나타날 것이다.

처음 컴포넌트가 렌더링된 이후에, 향후의 렌더링에서 기존 DOM 노드를 다시 사용하거나 업데이트를 하고 새로운 DOM 노드를 생성하지 않기를 기대하고 결국 이것이 React의 virtual DOM이 제공하는 최적화입니다.

나는 가끔 이 함수를 사용해서 이상한 브라우저 버그를 발견하거나, 예기치 못한 대량의 DOM 수정을 발견했다.

shouldComponentUpdate로 렌더링 피하기

React는 값비싼 DOM 연산을 피하기 위해서 virtual DOM 표현을 유지하는 놀라운 일을 하지만, virtual DOM 표현을 유지하는 것 역시 비용이 많이 든다. 아주 크고 복잡한 렌더 트리를 상상해보자. 만약 어떤 노드의 props라도 갱신하게되면, React는 렌더 트리상의 모든 leaf노드까지 내려가면서 virtual DOM 비교를 위한 계산을 다시 해야 한다. 운 좋게도 React는 이 재 계산을 피할 수 있는 shouldComponentUpdate 라는 이름의 메커니즘을 제공한다. 이 메서드에서 false를 리턴하면 렌더링을 위해 이 컴포넌트의 전체 서브트리를 괴롭히는 일은 하지 않게 된다. 우리는 어떻게/언제 false를 리턴해야 하는지만 알아내면 된다.

이 이점을 취하는 가장 간단한 방법은 render 메서드를 pure하게 유지하는 것이다 - 컴포넌트를 state와 props에만 의존하여 렌더하도록 하는 것(반대로는 DOM을 읽거나, 쿠키 혹은 다른 어떤 것을 읽는 것이다)이다. 이 “pure rendering” 테크닉은 꽤나 자주 언급되곤 하지만 컴포넌트의 존재 이유에 대해 알기 쉽게 하는 좋은 습관이므로 다시 한번 강조해도 무방하다. 그래도 종종 외부에 상태를 갖게 될 때가 있다 - 외부 상태에 의존하는 몇몇 컴포넌트를 독립적으로 유지하고 나머지는 pure하게 유지하도록 노력하라.

이렇게 함으로써 컴포넌트는 PureRenderMixin을 사용할 수 있다. 소스를 보면 mixin은 바로 shallowCompare를 호출한다.(만약 ES6 클래스를 사용하는 경우에는 shallowCompare를 직접 사용하는 것도 좋다.)

var ReactComponentWithPureRenderMixin = {
  shouldComponentUpdate: function(nextProps, nextState) {
    return shallowCompare(this, nextProps, nextState);
  },
};

만약 props/state에 변화를 감지하지 않았다면, 다시 렌더링하지 않을 것이다 컴포넌트의 올바른 동작을 위해서, 컴포넌트는 반드시 다음과 같아야한다:

render() 는 반드시 props와 state에만 의존해야 한다. 즉 어떠한 전역 상태로부터 값을 읽어오는 일이 없어야 한다. props와 state는 절대 mutate되서는 안된다 - shallowCompare가 최상위 props에 대해서만 동등성 검사를 하므로, 어떠한 변화라도 반드시 새로운 변수가 생성되어야 한다. react-addons-update이 불변 업데이트를 도와줄 것이다. 또한 Object.assign/_.extend과 같은 간단한 경우에도 마찬가지다. ImmutableJS는 더 중대한 변화가 요구되지만 PureRenderMixin을 쉽게 사용할 수 있다. this.state.myItem.stars++와 같은 일을 하고 싶은 충동에 주의하라. 상태를 직접적으로 변경하고 있다는 일은 잊기 쉽고 특히 다른 상태가 변경되면서 변경이 함께 일어나는 경우가 있다.

만약 pure components를 고수한다면 병목 현상을 발견했을때 PureRenderMixin을 사용하기가 훨씬 쉬워진다.

작은 유의점

PureRenderMixin을 사용한다면 성능 향상에 대한 잘못된 감각을 가질 수 있다 - 이것은 자식 컴포넌트들의 propType 유효성 검사 또한 회피하기 때문이다. 어차피 이 propType 유효성 검사는 제품 빌드에서는 PureRenderMixin 없이도 건너뛰는 것들이다.

더 큰 유의점

더욱 엄격한 정책을 고수하더라도, PureRenderMixin의 혜택을 즉시 누리지 못할 수도 있다. 상술한 바와 같이, React는 재 렌더링의 필요성을 결정하기 위해 deep 비교가 아닌 shallow-equal 비교를 수행한다. shallow equal이 아니라 의도치 않게 deep-equal 비교를 해버리는 너무나 많은 방법들이 있다.(나중에 더 설명함)

한가지 빠른 방법은 _.isEqual을 사용하는 것이다.

shouldComponentUpdate(nextProps, nextState) {
  return !_.isEqual(this.props, nextProps) ||
    !_.isEqual(this.state, nextState);
}

대부분의 props를 재사용한 경우 _.isEqual이 처음에 shallow 비교를 하기 때문에, 성능은 괜찮아 보였다. 실제로 _.isEqual로 충분한 경우에는 deep compare와 성능상의 이슈를 발견하지는 못했다.

또한 컴포넌트에 맞게 재단된 custom shouldComponentUpdate를 작성해도 되지만, 나는 단순한 컴포넌트에만 이를 적용했다. 만약 이 custom 메서드가 적절하게 관리되지 않는다면, 실제로 갱신이 필요한데도 갱신이 되지 않는 경우가 발생한다.

Optimizing for shallow-equal props

새 객체를 만들지 않는 best practice를 사용하면, 렌더링 최적화에 자연스럽게 도움이 되는 경우가 종종 있다.

Function.bind() / inline (anonymous) functions

Function.bind는 컴포넌트의 메서드를 맥락에 맞게 호출할 수 있는 편리한 방법이다. 불행히도, Function.bind의 호출은 새로운 함수를 생성한다:

console.log.bind(null, 'hi') === console.log.bind(null, 'hi')
false
function(){console.log(hi');} === function(){console.log(‘hi');}
false
// New function each time
render() {
  return <MyComponent onClick={() => this.setState(...)} />
}

prop 검사는 더이상 도움이 되지 않으며 컴포넌트는 항상 다시 렌더링된다. (react/jsx-no-bind eslint rule을 통해 bind나 arrow 함수를 jsx의 props로 전달하는 일을 막을 수 있다)

우리가 찾은 가장 간단한 해결방법은 bind 되지않은 함수를 전달하고 필요한 인자를 instance 메서드를 사용해 전달하는 것이다. 예를 들면:

const TodoItem = React.createClass({
  deleteItem() {
    this.props.deleteItem(this.props.index);
  },
});

서브 컴포넌트가 index를 되돌려주는 제약사항과 함께 더 general한 메서드를 노출하는 것은 이상한 일이기 때문에, 우리는 id와 같은 인자를 컨텍스트에 바인딩하려는 목적으로 IntermediateBinder를 사용한다. IntermediateBinder는 id를 prop으로 취하고 자체적인 method를 바인딩해서 자식 컴포넌트에 이 바인딩된 메서드를 전달한다.

const React = require('react/addons');

const IntermediateBinder = React.createClass({
  displayName: 'IntermediateBinder',
  propTypes: {
    boundArg: React.PropTypes.any.isRequired,
    children: React.PropTypes.func.isRequired,
  },
  _rebindFns(props, bindAll) {
    const newFns = {};
    for (const name in props) {
      const value = props[name];
      if (name !== 'boundArg' && name !== 'children') {
        if (bindAll || value !== this.props[name]) {
          newFns[name] = value.bind(null, props.boundArg);
        } else {
          newFns[name] = this._boundFns[name];
        }
      }
    }
    this._boundFns = newFns;
  },
  componentWillMount() {
    this._rebindFns(this.props, true);
  },
  componentWillReceiveProps(nextProps) {
    this._rebindFns(nextProps, this.props.boundArg !== nextProps.boundArg);
  },
  render() {
    return this.props.children(this._boundFns);
  },
});

module.exports = IntermediateBinder;

이는 다음과 같이 작성하는 것을 허용한다:

<IntermediateBinder
  deleteItem={this.deleteItem}
  boundArg={item.id}
>
  {(boundProps) => <TodoItem deleteItem={boundProps.deleteItem} />}
</IntermediateBinder>

(우리가 조사한 또 다른 가능한 방법은, 실제로 변경되지 않은 bind된 함수들을 찾는 더 나은 check 함수와 함수 자체에 메타 데이터를 저장하는 custom bind 함수를 조합해서 사용하는 것이다. 그러나 이는 우리 취향과 명백히 맞지 않았다.)

리터럴 array/object 생성

간단하지만 종종 묵과된다. array 리터럴은 종종 PureRenderMixin을 깨트린다.

['important', 'starred'] === ['important', 'starred']
false

만약 이 오브젝트가 변경되지 않을 것으로 기대된다면, 모듈의 상수나 컴포넌트의 static 변수로 이동시키면 된다.

const TAGS = ['important', 'starred'];

서브 컴포넌트들

한 컴포넌트와 그 서브 컴포넌트간의 컨텐츠 경계를 정의하면 성능 최적화를 쉽게 수행할 수 있다 - 캡슐화가 잘된 컴포넌트 인터페이스는 자연스러운 업데이트 성능을 제공한다. 업데이트를 줄여주고 PureRenderMixin를 사용하는 중간 컴포넌트들을 잘 리팩토링하라:

<div>
  <ComplexForm props={this.props.complexFormProps} />
  <ul>
    <li prop={this.props.items[0]}>item A</li>
    ...1000 items...
  </ul>
</div>

위와 같은 경우, complexFormProps와 items가 같은 스토어로부터 온다면, ComplexForm 안에서 타이핑하는 것은 스토어를 업데이트 할 것이고, <ul> 전체를 다시 렌더링하게 할 것이다. Virtual DOM의 diffing은 훌륭하지만, 여전히 모든 <li>를 확인할 것이다. 대신 <ul>을 this.props.items를 취하는 서브컴포넌트로 따로 빼내고, this.props.items가 변경될 때만 업데이트되도록 리팩토링 하라:

<div>
  <CustomList items={this.props.items} />
  <ComplexForm props={this.props.complexFormProps} />
</div>

값비싼 계산에 대한 캐시(Cache for Expensive Computations)

“single source of state” 원칙에 반하겠지만, prop을 계산하는 것이 값비싼 경우에는 컴포넌트에 prop을 캐시할 수 있다. render 메서드에서 doExpensiveComputation(this.prop.someProp)을 직접 사용하는 것 대신, prop이 변경되지 않은 경우에는 캐시를 호출하도록 감쌀 수 있다:

getCachedExpensiveComputation() {
  if (this._cachedSomeProp !== this.prop.someProp) {
    this._cachedSomeProp = this.prop.someProp;
    this._cachedComputation = doExpensiveComputation(this.prop.someProp);
  }
  return this._cachedComputation;
}

이 최적화에 대한 후보군은 기존의 JS Profiler를 이용하여 쉽게 발견할 수 있을 것이다.

React의 Two Way Binding Helpers는 간단한 컨트롤의 값 전달에 유용하고, 자식 컴포넌트의 새로운 상태를 부모 컴포넌트에 전달하는 것을 허용한다. React 폼 컴포넌트의 valueLink만이 함께 쓰인다면, React의 폼 입력이 매우 간단하기 때문에 나쁘지는 않다. 그러나 더 많은 컴포넌트를 통해 스레딩을 시작하면 문제가 발생할 수 있다. linkState는 다음과 같이 구현한다:

linkState(key) {
  return new ReactLink(
    this.state[key],
    ReactStateSetters.createStateKeySetter(this, key)
  );
}

linkState에 대한 모든 호출은 상태가 변경되지 않은 경우에도 새 객체를 return 한다! 즉, shallowCompare는 절대 제대로 작동하지 않을 것이다. 우리의 해결방법은 유감스럽게도 linkState를 사용하지 않는 것이다. 만약 linkState를 getter prop과 setter prop으로만 flatten 하는 대신, 새로운 오브젝트를 생성하는 것을 피할 수 있다. 예) nameLink={this.linkState(‘name’)}을 name={this.state.name} setName={this.setName}으로 치환할 수 있다. (우리는 스스로를 캐시하는 linkState를 작성하는 것을 고려했다.)

컴파일러 최적화(Compiler Optimizations)

새로운 버전의 바벨과 React는 인라이닝을 지원하고 상수형 React elements를 자동으로 호이스팅한다. 아직 이것들과 많이 놀아보지는 않았지만, 아마도 React.createElement를 호출하오 DOM을 재조정하는 속도를 올리는데 도움이 될 것이다.

요약

조금 많이 알아본 것 같지만(본래 목록은 훨씬 많았다!), 키 포인트는 1)프로파일링에 익숙해지자, 2) shouldComponentUpdate라는 먼 길을 가야한다 두 가지다. 아무쪼록 유용했길 바란다. 어떤 제안이나 댓글이나 우리가 잃어버린 것 같은게 있다면? 알려달라 - benchling.com

파트 2에서는 디버깅 워크플로우에 대해서 논의하고, 성능이 좋지 않은 코드의 실제 예제들을 살펴본 다음 수정할 것이다.

원문보기

내부적으로 React는 몇 가지 테크닉을 통해 UI를 업데이트할 때 필요한 DOM 조작을 최소화한다. 다른 많은 애플리케이션에 대해서 React를 사용하게되면 별다른 성능 최적화 작업이 없이도 빠르게 반응하는 유저 인터페이스를 제공할 수 있을 것이다. 그래도 React 애플리케이션의 속도를 빠르게 하는 몇 가지 방법이 역시 존재한다.

Production Build 사용하기

만약 벤치마킹한 React 애플리케이션에서 성능 문제를 경험했다면, 프로덕션 빌드를 이용해서 테스트했는지 확인해보자:

  • Create React App를 이용할 때에는, npm run build 명령어를 실행하고 다음 지시사항을 따를 필요가 있다.
  • single-file 빌드 시에는, 제품 레벨에서 사용가능한 .min.js 버전을 제공한다.
  • Browserify를 이용할 때는, NODE_ENV=production과 함께 실행해야 한다.
  • Webpack을 이용할 때는, production config에 다음 플러그인을 추가해야 한다.
new webpack.DefinePlugin({
  'process.env': {
    NODE_ENV: JSON.stringify('production')
  }
}),
new webpack.optimize.UglifyJsPlugin()

development 빌드는 애플리케이션 개발에 도움이 되는 추가적인 경고를 포함하고 있어서 느려지게하는 원인이 된다.

재보정 피하기(Avoid Reconciliation)

React는 렌더링된 UI를 내부적으로 다른 식으로 관리하고 있다. React가 내부적으로 관리하고 있는 이 모델은 컴포넌트가 리턴한 React element를 포함하고 있다. 이 모델을 통해 React는 DOM node를 생성하는 것을 피하고 이미 존재하는 DOM node에 대해 불필요하게 접근하는 것을 피한다. 이미 존재하는 DOM node에 대한 접근은 JavaScript 오브젝트를 조작하는 것보다 종종 느릴 수 있다. 이전에는 “virtual DOM”이라고 일컬어졌지만, 이제는 React Native에서도 같은 방법으로 동작한다.

컴포넌트의 props나 state가 변경되었을 때, React는 이전에 렌더된 React element와 새로 리턴된 React element를 비교하여 실제 DOM을 갱신할 필요가 있는지를 결정한다. 이 둘이 같지 않을 경우에 React는 DOM을 갱신한다.

어떤 경우에는 컴포넌트의 라이프사이클 함수 shouldComponentUpdate를 전부 오버라이드해서 속도를 올릴 수 있다. 이 함수는 리렌더링 프로세스가 시작하기 직전에 트리거되는 함수다. 이 함수의 기본 구현은 true를 리턴하여 React로 하여금 업데이트를 수행하도록 하는 것이다:

shouldComponentUpdate(nextProps, nextState) {
  return true;
}

만약 컴포넌트가 업데이트할 필요가 없는 상황인지 알고있다면, render()를 호출하는 것을 포함한 전체 렌더링 프로세스를 건너뛸 수 있도록 shouldComponentUpdate 함수에서 false를 리턴하면 된다.

실전 shouldComponentUpdate

아래에 컴포넌트와 그 서브트리가 있다. SCUshouldComponentUpdate를 뜻하고, vDOMEq는 렌더된 React element가 동일한지를 뜻한다. 각 원의 색은 컴포넌트가 재조정되야하는지 아닌지 여부를 뜻한다.

서브트리 C2의 shouldComponentUpdatefalse를 리턴하기 때문에 React는 C2를 렌더하려하지 않는다, 그리고 그 결과 C4와 C5의 shouldComponentUpdate 역시 호출하지 않는다.

C1과 C3은 shouldComponentUpdatetrue를 리턴하기 때문에, React는 아래쪽을 쭉 따라 내려가면서 확인해야만 한다. C6의 shouldComponentUpdatetrue를 리턴하고 따라서 렌더링된 엘레멘트가 동일하지 않게 되기 때문에 React는 DOM을 갱신해야만 한다.

마지막으로 흥미로운 것은 C8이다. React는 이 컴포넌트를 렌더해야만 하지만 React elements가 이전에 리턴된 React elements와 동일한 것이기 때문에, DOM을 갱신해야만 하는 것은 아니다.

React는 피할수없는 C6만을 위해 DOM을 변경해야만 한다는 것을 유의하자. C8은 렌더된 React elements와의 비교를 통해 구제되며 C2의 서브트리와 C7에서는 비교할 필요조차 없이 shouldComponentUpdate에서 구제되며, render는 호출되지 않았다.

예제

만약 컴포넌트를 변경하는 유일한 방법이 props.colorstate.count를 변경하는 것이라면 shouldComponentUpdate에서는 다음과 같이 확인하도록 작성하면 된다:

class CounterButton extends React.Component {
  constructor(props) {
    super(props);
    this.state = {count: 1};
  }

  shouldComponentUpdate(nextProps, nextState) {
    if (this.props.color !== nextProps.color) {
      return true;
    }
    if (this.state.count !== nextState.count) {
      return true;
    }
    return false;
  }

  render() {
    return (
      <button
        color={this.props.color}
        onClick={() => this.setState(state => ({count: state.count + 1}))}>
        Count: {this.state.count}
      </button>
    );
  }
}

이 코드에서, shouldComponentUpdate는 그저 props.colorstate.count에 변화가 있는지만 확인한다. 만약 값이 변하지 않았다면 컴포넌트를 갱신하지 않는다. 만약 컴포넌트가 조금 더 복잡한 경우에는 컴포넌트 업데이트를 결정하기 위해 propsstate의 모든 필드에 대해 “shallow comparison”이라는 패턴을 사용할 수 있다. 이 패턴은 React에서 이 로직을 사용하기 위한 헬퍼를 제공할 정도로 일반적이다 - 그냥 React.PureComponent만 상속하면 된다. 따라서 이 코드는 더 간단한 방법으로 위 코드와 같은 일을 할수 있다:

class CounterButton extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = {count: 1};
  }

  render() {
    return (
      <button
        color={this.props.color}
        onClick={() => this.setState(state => ({count: state.count + 1}))}>
        Count: {this.state.count}
      </button>
    );
  }
}

대부분의 경우 shouldComponentUpdate를 작성하는 대신 React.PureComponent를 사용할 수 있다. 다만 이것은 shallow comparison을 할 것이므로, props나 state에 mutate를 하는 경우에는 shallow comparison은 제대로 동작하지 않을 것이다.

이는 더 복잡한 데이터 구조에서 문제가 될 수 있다. 예를 들어, 콤마로 분리된 단어 목록을 보여주는 ListOfWords라는 컴포넌트가 있다고 하고, 버튼을 클릭하여 단어를 추가하는 부모인 WordAdder 컴포넌트가 있다고 하자. 이 코드는 올바르게 동작하지 않는다:

class ListOfWords extends React.PureComponent {
  render() {
    return <div>{this.props.words.join(',')}</div>;
  }
}

class WordAdder extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      words: ['marklar']
    };
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    // This section is bad style and causes a bug
    const words = this.state.words;
    words.push('marklar');
    this.setState({words: words});
  }

  render() {
    return (
      <div>
        <button onClick={this.handleClick} />
        <ListOfWords words={this.state.words} />
      </div>
    );
  }
}

문제는 PureComponent가 단순히 this.props.words의 이전 값과 새 값을 비교할 뿐이라는 것이다. WordAdder의 메서드인 handleClickwords를 mutate하는 방식이기 때문에, 배열 내의 실제 단어들이 변경되었더라도, this.props.words의 이전값과 새 값은 동일한 값이라고 비교될 것이다. 따라서 ListOfWords는 새 단어가 추가되더라도 갱신되지 않을 것이다.

가변적이지 않은 데이터의 힘(The Power Of Not Mutating Data)

이 문제를 피할 수 있는 가장 간단한 방법은 바로 props와 state의 값을 mutating하지 않는 것이다. 예를 들어 위의 handleClick 메서드를 다음과 같이 concat을 사용하도록 재작성할 수 있다:

handleClick() {
  this.setState(prevState => ({
    words: prevState.words.concat(['marklar'])
  }));
}

ES6는 배열을 쉽게 조작할 수 있도록 spread syntax를 제공한다. 만약 Create React App을 사용한다면 기본적으로 이 문법이 사용가능하다.

handleClick() {
  this.setState(prevState => ({
    words: [...prevState.words, 'marklar'],
  }));
};

또한 오브젝트를 mutate하는 코드를 mutate하지 않도록 재작항 할 수 있다. 예를 들어, colormap이라는 오브젝트가 있고 colormap.rightblue로 변경하고 싶다고 하자. 이를 다음과 같이 작성할 수 있다:

function updateColorMap(colormap) {
  colormap.right = 'blue';
}

원본 오브젝트를 mutating하는 방법이다. 이를 원본 오브젝트를 mutating하지 않는 방법으로 작성하기 위해서 Object.assign 메서드를 사용하면 된다:

function updateColorMap(colormap) {
  return Object.assign({}, colormap, {right: 'blue'});
}

updateColorMap은 이제 mutating된 이전 오브젝트가 아닌 새로운 오브젝트를 리턴한다. ES6의 Object.assign은 polyfill이 필요하다.

자바스크립트에 추가될 제안 중 object spread properties 또한 mutation 없이 간단히 오브젝트를 업데이트할 수 있는 방법을 제공한다:

function updateColorMap(colormap) {
  return {...colormap, right: 'blue'};
}

만약 Create React App을 사용한다면 Object.assign과 object spread 문법을 둘다 기본적으로 사용 가능하다.

불변 데이터 구조 사용하기(Using Immutable Data Structures)

Immutable.js 는 이 문제를 해결하기 위한 또다른 방법이다. 이 라이브러리는 내부적으로 구조를 공유하도록 작성된 immutable하고 persistent한 컬렉션을 제공한다.

  • Immutable: 일단 한번 생성되면, 컬렉션은 변경될 수 없다.
  • Persistent: set과 같은 컬렉션은 이전 컬렉션으로부터 새로운 컬렉션이 생성될 수 있다. 원본 컬렉션은 새로운 컬렉션이 생성된 이후에도 아직 유효하다.
  • Structural Sharing: 오리지널 컬렉션으로부터 생성된 새로운 컬렉션은 가능한한 오리지널 컬랙션과 같은 구조를 갖게 되며 성능 향상을 위해 카피를 줄인다.

불변성은 값 변화에 대한 추적비용을 감소시킨다. 값 변화는 항상 새로운 오브젝트를 리턴하기 때문에 오브젝트의 레퍼런스가 변경되었는지만 확인하면 된다. 예를 들면 다음과 같은 보통 자바스크립트 코드에서는:

const x = { foo: "bar" };
const y = x;
y.foo = "baz";
x === y; // true

y가 편집되더라도 x와 같은 레퍼런스를 유지하기 때문에 비교 결과는 true이다. immutable.js를 이용한 비슷한 코드를 작성해보자:

const SomeRecord = Immutable.Record({ foo: null });
const x = new SomeRecord({ foo: 'bar'  });
const y = x.set('foo', 'baz');
x === y; // false

이 경우에는 x를 mutate할 경우 새로운 레퍼런스를 리턴하기 때문에, 안전하게 x가 변경되었다고 가정할 수 있다.

불변 데이터를 지원할 수 있도록 도와주는 두 개의 다른 라이브러리들이 있다. seamless-immutableimmutability-helper.

불변 데이터 구조는 shouldComponentUpdate에 필요한 object 변경에 대한 추적을 쉽게 해준다. 아마도 훌륭한 성능 부스터로 쓰일 수 있을 것이다.