toggle menu

[React.js] Crafting a high-performance TV user interface using React 번역

2017. 1. 23. 12:33 React.js

넷플릭스 기술 블로그에 올라온 Crafting a high-performance TV user interface using React 라는 글의 번역입니다.

글에서도 언급하고 있지만, 대상은 TV UI 지만 시도한 개선 방법은 범용적인 접근법이기 때문에 도움될만한 부분이 많은 것 같습니다. 번역이 잘못된 부분이 있다면 꼭 말씀해주세요.


넷플릭스 TV 인터페이스는 회원들을 위한 최고의 경험을 알아내기 위한 우리의 노력을 통해 지속적으로 진화하고 있다. 예를들어, A/B testing, eye-tracking research, 고객 피드백 이후에 우리는 무엇을 시청해야하는지 고객들이 더 나은 결정을 내릴 수 있도록 돕는 video preview를 출시했다. 우리는 이전에 우리의 TV 어플리케이션이 어떻게 디바이스상에 네이티브로 설치된 SDK와 언제든 업데이트 될 수 있는 JavaScript 어플리케이션과 Gibbon 으로 알려진 렌더링 레이어로 구성되어 있는지에 대해 쓴 적이 있다. 이번 포스트에서 우리는 JavaScript 어플리케이션 성능을 최적화하기 위한 방법을 적용했던 우리의 일부 전략에 대해 주목해보고자 한다.


React-Gibbon

2015년에 우리는 TV UI 아키텍쳐 전부를 새로 작성하고 모듈화하는 작업에 착수했다. 우리 React를 사용하기로 했는데, 그 이유는 어플리케이션을 좀더 쉽게 추론할 수 있도록 만들어주는 단방향 데이터 흐름과 UI 개발에 대한 선언적 접근이라는 특성을 갖고 있기 때문이었다. 확실히 우리는 우리만의 향을 내는 리액트가 필요했었는데, 왜냐하면 그때엔 리액트가 DOM에만 타겟팅되어 있었기 때문이다. 우리는 매우 빠르게 Gibbon에 타겟팅된 프로토타입을 만들어볼 수 있었다. 이 프로토타입은 결국 React-Gibbon 으로 진화하게 되었고 우리는 새로운 React 기반 UI 개발 작업을 시작할 수 있었다

React-Gibbon의 API는 React-DOM을 사용해본 사람이라면 누구나 친숙하게 느낄 수 있을 것이다. 가장 중요한 차이점이라면 div, span, input 대신에, iniline 스타일링을 지원하는 원시 요소인 "widget" 하나를 그린다는 것이다

React.createClass({
    render() {
        return <Widget style={{ text: 'Hello World', textSize: 20 }} />;
    }
});


성능은 핵심 과제다

우리의 앱은 PS4 Pro와 같은 최신 게임 콘솔에서부터 제한된 메모리와 연상 성능을 가진 저예산 사용자를 위한 기기에 이르기까지 수백가지 종류의 디바이스에서 동작한다. 우리가 대상으로하는 low-end 장치는 때로 sub-GHz single core CPU 에 적은 메모리와 제한된 그래픽 가속 능력을 갖고 있다. 좀더 상황을 어렵게 만들기 위해 Javascript 환경은 JavaScriptCore의 오래된 non-JIT 버전이다. 이러한 제한 사항으로 인해 반응이 빠른 60fps 경험이 특히 까다로웠고, React-Gibbon과 React-DOM 간의 많은 차이점을 발생시켰다.


측정, 측정, 측정

성능 최적화에 접근할 때, 중요한 것은 노력에 따른 성공을 측정할 때 사용하게 될 지표를 세우는 것이다. 우리는 전반적인 어플리케이션 성능을 측정할 지표들로 다음을 사용했다.

  • Key Input Responsiveness - 키 입력에 대한 반응으로 변경된 내용을 렌더링하는데 걸린 시간
  • Time To Interactivity - 어플리케이션을 구동하는데 걸린 시간
  • Frames Per Second - 애니메이션의 일관성과 부드러움
  • Memory Usage

아래에 대략적으로 설명한 전략들은 주로 키 입력 반응성의 향상에 초점을 맞추고 있다. 이것들은 모두 우리의 디바이스들에서 식별되고 테스트되고 측정되었지만 다른 환경에서는 반드시 적용 할 수있는 것은 아니다. 모든 "Best Practice" 제안들은 당신의 환경과 유즈 케이스에서 동작하는지 의심해보고 검증하는 것이 중요하다.


관찰: React.createElement 는 비용이 든다

Bebel 이 JSX 를 트랜스파일링할 때, 바벨은 렌더링할 다음 컴포넌트에 대한 내용을 생성하는 시점에 평가되는 여러 개의 React.createElemen 함수 호출로 변환한다. 만약 우리가 React.createElemen 함수가 생성하게 될 것을 예측할 수 있다면, 우리는 런타임이 아니라 빌드타임에 기대하는 결과와 함께 그 호출을 인라인화 할 수 있을 것이다.

// JSX
render() {
    return <MyComponent key='mykey' prop1='foo' prop2='bar' />;
}

// Transpiled
render() {
    return React.createElement(MyComponent, { key: 'mykey', prop1: 'foo', prop2: 'bar' });
}

// With inlining
render() {
    return {
        type: MyComponent,
        props: {
            prop1: 'foo',
            prop2: 'bar'
        },
        key: 'mykey'
    };
}

보는 것과같이, 우리는 createElement 호출에 소모되는 비용을 완전히 제거하며, "우리는 할 수 없습니까?" 소프트웨어 최적화 학교를 승리로 이끌었다.

우리는 이러한 테크닉이 우리의 전체 어플리케이션에 적용가능할지 그리고 createElement 호출을 전부 피할 수 있을지에 대해 궁금해졌다. 우리가 찾은 것은 만약 우리가 element에 대한 ref 를 사용하고 있다면, 런타임에 소유자를 연결하기 위해 createElement 호출이 필요하다는 것이다. 이는 또한 (뒤에서 다루겠지만) ref 를 포함하는 값에 대해 spread operator 를 사용할 경우에도 해당된다.


문제: Higher-order Component들은 인라인화 할 수 없다

우리는 믹스인에 대한 대안으로 Higher-order Components(HOCs)를 사랑한다. HOC는 관심사의 분리를 유지하면서 동작을 쉽게 계층화해준다. 우리는 우리의 HOC들에 대해서도 인라인의 유익을 얻길 원했지만, 한가지 이슈에 맞닥들이게 됐는데 그건 HOC는 대개 자신의 Props를 그대로 전달한다는 점이었다. 이는 자연스럽게 spread operator의 사용으로 이어지는데, 이로 인해 inline 으로 변환해주는 바벨 플러그인을 방해하게 된다.

우리 어플리케이션을 다시 작성하는 프로세스를 시작했을 때, 우리는 모든 렌더링 레이어의 인터렉션이 선언적 API를 통과하기로 결정했다. 예를 들면, 아래와 같이 사용하지 않는 것이다.

componentDidMount() {
    this.refs.someWidget.focus()
}

어플리케이션의 focus를 특정 위젯으로 옮기기 위해서, 우리는 아래와 같이 렌더링하는 동안 포커스된다는 것을 묘사하도록 하는 선언적인 focus API를 구현했다.

render() {
    return <Widget focused={true} />;
}

이런 결정은 어플리케이션에서 ref 의 사용을 피하도록 해주었기 때문에 운좋게도 부작용(?)이 있었다. 결과적으로 코드가 스프레드를 사용했는지 여부에 관계없이 인라이닝을 적용 할 수 있었다.

// before inlining
render() {
    return <MyComponent {...this.props} />;
}

// after inlining
render() {
    return {
        type: MyComponent,
        props: this.props
    };
}

이것은 훌륭하게 많은 함수 호출들과 속성 병합들을 줄여주었지만 그래도 여전히 완전히 제거해주진 않았다.


문제: 속성 인터셉션은 여전히 merge가 필요하다

컴포넌트들을 인라인할 수 있게 된 뒤에도, 우리 어플리케이션은 여전히 HOC 안에서 속성값들을 병합하는데 많은 시간을 소모하고 있었다. 이건 놀라울게 없었는데, HOC가 감싸고 있는 컴포넌트에게 전달하기 전에 특정 값을 변경하거나 추가하기 위해 자주 prop 들을 가로채기 때문이다.

우리는 HOC의 스택이 우리 디바이스 중 하나에서 prop의 갯수 및 컴포넌트 깊이에 따라 어떻게 조정되었는지 분석했으며 그 결과는 유익했다.

그들은 스택을 통해 이동하는 prop의 갯수와 주어진 컴포넌트 깊이의 렌더링 시간 사이에 대략적인 선형 관계가 있음을 보여주었다.


수많은 props 로 인한 죽음

우리가 알아낸 것에 기초해서 우리는 스택을 통해 전달되는 prop 의 갯수를 제한함으로써 어느정도 우리 앱의 성능을 향상시킬 수 있음을 꺠닫게 되었다. 우리는 prop 의 그룹들은 종종 연관되어 있고 항상 동시에 변경된다는 것을 알게되었다. 이 경우, 관련 prop 들을 하나의 네임 스페이스 prop으로 묶는 것이 합리적이다. 만약 네임스페이스 prop 을 immutable 한 값으로 모델화할 수 있다면, shouldComponentUpdate 호출에서 (객체에 대한) 깊은 비교를 수행하는 대신 참조 동등성만 검사하여 추가로 최적화 할 수 있다. 이것은 우리에게 좋은 승리를 주긴 했지만 결국 우리는 정말 가능한 한 prop 수를 줄였다는 걸 알게됐다. 이제는 보다 극단적인 수단에 의지 할 시간이었다.


key iteration 없는 props 병합

경고! 이것은 권장되지 않으며 이상하고 예상치 못한 방식으로 많은 것을 깨뜨릴 가능성이 크다.

우리 앱을 이동하는 props들을 줄인 뒤, 우리는 HOC 간에 props을 병합하는데 소모되는 시간을 줄이기 위한 다른 방법들을 실험하고 있었다. 우리는 key iteration 을 피하면서 동일한 목표를 달성하기 위해 prototype chain 을 사용할 수 있다는 것을 깨닫게 되었다.

// before proto merge
render() {
    const newProps = Object.assign({}, this.props, { prop1: 'foo' })
    return <MyComponent {...newProps} />;
}

// after proto merge
render() {
    const newProps = { prop1: 'foo' };
    newProps.__proto__ = this.props;
    return {
        type: MyComponent,
        props: newProps
    };
}

위의 예제에서 우리는 깊이 100, prop 100 케이스에서 ~500ms의 렌더링 타임을 ~60ms로 줄였다. 이 방법을 사용하면 어떤 재미있는 버그가 발생하는데, this.props 가 frozen object 라는 것이다. 이 경우 프로토타입 체인 접근은 newProps 객체가 만들어진 후 proto가 할당 된 경우에만 작동한다. 말할 것도없이 newProps의 소유자가 아니라면 프로토타입을 할당하는 것은 어리석은 일이다.


문제: 스타일 "Diffing" 은 느리다

React는 element가 렌더될 필요가 있음을 알게되면 반드시 실제 DOM에 적용되어야할 작은 변화가 있는지 결정하기 위해 이전 값과 비교하게 된다. 프로파일링을 통해 우리는 이 프로세스가 (특히 마운트 중에) 많은 스타일 속성을 반복할 필요가 있기 때문에 비용이 많이 드는 것으로 나타났다.


변경될 가능성이 있는 것을 기반으로 스타일 props을 분리하라

우리가 설정한 많은 스타일 값들이 실제로는 절대 변경되지 않는다는 것을 알게 되었다. 예를들어, 동적 텍스트 값을 표시하는데 사용되는 위젯이 있다고 가정해보자. 그 위젯은 text, textSize, textWeight, textColor 라는 속성을 가진다. text 속성은 이 위젯이 있는 동안 변경될 수 있지만 나머지 속성은 그대로 유지되기를 원한다. 4개의 위젯 style props를 비교하는 비용은 각각 그리고 모든 렌더링마다 소요된다. 우리는 바뀔 수 있는 것과 그렇지 않은 것을 분리 시켜서 이 비용을 줄일 수 있었다.

const memoizedStylesObject = { textSize: 20, textWeight: ‘bold’, textColor: ‘blue’ };

<Widget staticStyle={memoizedStylesObject} style={{ text: this.props.text }} />

memoizedStylesObject 객체를 주의 깊게 memoize 하면 React-Gibbon은 레퍼런스가 같은지 검사하고 false로 판명되면 값을 비교하게 된다. 이것은 위젯이 마운트될 떄에는 영향을 주지 못하지만 이후 이뤄지는 모든 렌더에서는 이득을 얻을 수 있다.


왜 이터레이션을 피하지 않는가?

이 아이디어를 더 깊이 생각해봤을때, 만약 우리가 어떤 style props가 특정 위젯에 설정되어 있는지 알면 키를 반복하지 않고도 동일한 작업을 수행하는 함수를 작성할 수 있다. 우리는 컴포넌트 렌더 메소드들에 대한 정적 분석을 수행하는 사용자 정의 Babel 플러그인을 작성했다. 그것은 어떤 스타일이 적용될 지 결정하고 커스텀 diff-and-apply 함수를 빌드한 다음 위젯 props에 연결한다.

// This function is written by the static analysis plugin
function __update__(widget, nextProps, prevProps) {
    var style = nextProps.style,
        prev_style = prevProps && prevProps.style;

    if (prev_style) {
        var text = style.text;
        if (text !== prev_style.text) {
            widget.text = text;
        }
    } else {
        widget.text = style.text;
    }
}

React.createClass({
    render() {
        return (
            <Widget __update__={__update__} style={{ text: this.props.title }}  />
        );
    }
});

내부적으로 React-Gibbon 은 "특별한"update prop의 존재 여부를 찾고 이전 스타일 및 다음 스타일 prop 의 일반적인 반복을 건너 뛰고 대신 속성이 변경된 경우 해당 위젯에 직접 적용한다. 이는 배포할 스크립트 사이즈를 늘리는 대신 렌더링 시간에 큰 영향을 미쳤다.


성능은 기능이다

우리의 환경은 유니크하지만, 우리가 성능 향상의 기회를 식별하기 위해 사용한 테크닉은 그렇지 않다. 우리는 실제 디바이스 상에서 우리가 변경한 모든 것에 대해 측정하고 테스트하고 검증했다. 이러한 투자는 우리를 'key iteration 은 비싸다' 라는 한가지 공통 테마를 발견하도록 이끌었다. 결과적으로 우리는 애플리케이션에서 병합이 일어나는 부분을 식별하고 최적화 할 수 있는지 여부를 결정하기 시작했다. 다음은 성능 향상을 위해 수행한 몇 가지 다른 작업 목록이다

  • Custom Composite Component - 우리의 플랫폼용으로 극도로 최적화된
  • Pre-mounting screens to improve perceived transition time
  • Component pooling in Lists
  • Memoization of expensive computations