Description
원문 : https://calendar.perfplanet.com/2013/diff/
작성자 Christopher Chedeau
React는 UI 인터페이스 개발을 위해 페이스북이 개발한 자바스크립트 라이브러리입니다.
이 라이브러리는 특히 처음부터 성능을 염두에 두고 개발되었습니다.
이 글에서는 diff 알고리즘과 렌더링의 동작 방식과 이 알고리즘이 어떻게 최적화하는지에 대한 얘기를 다루려고 합니다.
Diff 알고리즘
자세한 구현사항을 보기 전에 전반적으로 리액트가 어떻게 동작하는지 보는 것이 중요합니다.
var MyComponent = React.createClass({
render: function() {
if (this.props.first) { return <div className="first"><span>A Span</span></div>; }
else { return <div className="second"><p>A Paragraph</p></div>; }
}
});
언제든지 여러분들은 여러분의 UI가 어떻게 보이는지를 설명할 수 있습니다.
렌더링의 결과물은 실제 DOM 노드가 아님을 이해하는 것이 아주 중요합니다.
그들은 단지 가벼운 자바스크립트 객체일 뿐이고 우리는 그것을 가상 DOM이라고 부릅니다.
React는 이전 렌더링에서 다음 렌더링으로 넘어갈 때 밟아야 하는 단계를 최소한으로 줄이기 위해 이 표현식을 활용할 겁니다.
예를 들어 <MyComponent first={true} />
를 마운트하고, <MyComponent first={false} />
로 대체하려면 전의 컴포넌트를 언마운트해야 하는데 이에 대한 DOM 명령문들은 다음과 같습니다.
가장 처음으로
- 노드 생성:
<div className="first"><span>A Span</span></div>
두번째 노드로
- 어트리뷰트 대체:
className="first"
에서className="second"
으로 - 노드 대체:
<span>A Span</span>
에서<p>A Paragraph</p>
으로
두번째에서 다시 처음으로
- 노드 제거:
<div className="second"><p>A Paragraph</p></div>
수준별
두 임의 트리 사이에서 변경사항의 최솟값을 찾는 것은 O(n^3)의 시간 복잡도를 가집니다.
여러분도 예상할 수 있듯이, 이는 우리의 상황에 전혀 적합한 시간복잡도가 아닙니다.
그래서 리액트는 더 간단하지만 꽤 강력한 휴리스틱을 사용하여 괜찮은 추정치를 찾는데 바로 O(n)의 시간복잡도로 이를 해결합니다.
배열
한번 반복할 때마다 5개의 컴포넌트를 렌더링하는 컴포넌트가 있고 이후에 배열 중간에 새로운 컴포넌트가 추가되는 경우라고 가정해봅시다.
이 정보만으로는 두 컴포넌트 배열 간의 매핑을 어떻게 해야 할지 알 수 없습니다.
기본적으로 리액트는 이전 배열의 첫 번째 컴포넌트에 다음 배열 첫 번째 컴포넌트를 매핑시킵니다.
리액트가 매핑 정보를 알아내기 위해 여러분은 컴포넌트에 key
어트리뷰트를 제공할 수 있습니다.
실제로 자식들 사이에 유니크한 key를 찾는 것은 보통 쉬운 일이죠.
컴포넌트
리액트 앱은 사용자들이 정의한 여러 컴포넌트들로 구성되며 결국 주로 div
로 구성된 트리로 변경됩니다. 리액트는 클래스가 동일한 컴포넌트만 일치하므로 diff 알고리즘은 추가적인 정보를 고려하여 동작합니다.
예를 들어 <Header>
는 <ExampleBlock>
으로 대체되는데 리액트는 헤더를 제거하고 example block을 생성합니다. 유사성이 없어보이는 두 컴 포넌트를 일치시키기 위해 소중한 시간을 쓰지 않아도 됩니다.
이벤트 위임
DOM 노드들에 이벤트리스너를 추가하는 것은 아주 느리고 메모리를 많이 잡아먹습니다. 대신에 리액트는 "이벤트 위임"이라고 하는 아주 유명한 기술을 구현합니다. 더 나아가 W3C 호환 이벤트 시스템을 재구현합니다. 이는 IE 8 이벤트 핸들링 버그는 이제 과거의 일이며, 모든 이벤트 이름이 브라우저 사이에서 모두 일치함을 의미합니다.
어떻게 구현되었는지 설명하겠습니다. 하나의 이벤트리스너는 document의 root에 추가됩니다. 이벤트가 발생하면, 브라우저는 타겟 DOM 노드를 저희에게 알려줍니다. DOM 계층을 통해 이벤트를 전파하기 위해 React는 가상 DOM 계층을 반복하지 않습니다.
대신에 모든 리액트 컴포넌트가 각 계층을 인코딩하는 고유한 ID를 가지고 있다는 사실을 활용합니다. 간단한 문자열 조작을 통해 모든 부모들의 신원을 파악할 수 있습니다. 이벤트 리스너들을 해시 맵에 저장함으로써 가상 DOM에 연결하는 것보다 성능이 우수하다는 것을 발견했는데요. 다음은 가상 DOM을 통해 이벤트가 디스패치될 때 발생하는 동작의 예시입니다.
// dispatchEvent('click', 'a.b.c', event) clickCaptureListeners['a'](event); clickCaptureListeners['a.b'](event);
// clickCaptureListeners['a.b.c'](event); clickBubbleListeners['a.b.c'](event); clickBubbleListeners['a.b'](event);
// clickBubbleListeners['a'](event);
브라우저는 각 이벤트와 이벤트 리스너에 새로운 이벤트 객체를 생성합니다. 이 객체는 이벤트 객체를 계속 참조하거나 수정할 수 있는 좋은 프로퍼티를 가지고 있습니다. 그러나 이는 아주 많은 메모리 할당을 차지하게 됩니다. 리액트는 시작할 때 이러한 객체의 풀을 할당하고 이벤트 객체가 필요할 때마다 풀에서 재사용합니다. 이는 가비지 컬렉션을 극적으로 줄여줍니다.
렌더링
배치
컴포넌트에서 setState
를 호출할 때마다 리액트는 컴포넌트를 dirty하다고 표시할 겁니다. 이벤트 루프의 끝에서 리액트는 dirty한 컴포넌트들을 찾아 리렌더링합니다.
이 배치 작업은 이벤트 루프에서 DOM을 업데이트하는 순간은 오직 한번 뿐임을 의미합니다. 이 속성은 퍼포먼스가 좋은 앱을 제작하기 위해 아주 중요하지만 일반적으로 작성된 자바스크립트로 달성하기 아주 어렵습니다. React 앱을 사용하면 기본적으로 이를 가능케 해주죠.
서브트리 렌더링
setState
가 호출될 때 컴포넌트는 자식들에 대한 가상 DOM을 다시 렌더링합니다. 루트 엘리먼트에서 setState
를 호출한다면 전체 리액트 앱이 리렌더링됩니다. 모든 컴포넌트들은 변경사항이 없다 할지라도 render
메소드가 호출됩니다. 이는 무섭고 비효율적으로 들릴 수도 있지만 현실적으로 실제 DOM을 건드리지 않기 때문에 괜찮습니다.
먼저, 저희는 UI 디스플레이에 대해 얘기하고 있습니다. 화면 공간은 제한적이므로 일반적으로 한 번에 수백 개에서 수천 개의 엘리먼트 순서로 보여집니다. 자바스크립트는 전체 인터페이스를 관리할 수 있을만큼 비즈니스 로직이 충분히 빨라졌습니다.
다른 중요한 포인트는 리액트 코드를 작성할 때 일반적으로 변경사항이 있을 때마다 루트 노드의 setState를 호출하지 않습니다.
변경 이벤트를 받은 컴포넌트나 그 위의 컴포넌트 몇 개를 호출합니다. 최상위까지 거슬러 올라가는 일은 흔치 않습니다.
이는 유저가 상호작용할 때 변경사항은 지역적임을 의미합니다.
선택적인 서브 트리 렌더링
마지막으로 몇 서브 트리가 다시 렌더링되는 것을 막을 수 있는 방법이 있습니다. 컴포넌트에 다음 메소드를 구현하는 경우가 그렇습니다.
boolean shouldComponentUpdate(object nextProps, object nextState)
컴포넌트의 이전과 다음 props/state에 기반하여 리액트에게 이 컴포넌트가 변경되지 않았고 리렌더링될 필요가 없다고 말할 수 있습니다.
적절하게 구현될 때 이는 꽤 큰 성능의 향상을 보여줍니다.
이를 활용하려면 자바스크립트 객체를 비교할 수 있어야 합니다. 비교 수준이 얕거나 깊어야 하는 문제들이 많이 있습니다. 너무 깊으면 불변의 데이터 구조를 사용하거나 딥 카피를 해야 합니다.
또한 이 함수는 항상 호출되므로 이를 연산하는 시간이 컴포넌트를 렌더링하는 데 걸리는 시간보다 적어야 합니다. 비록 리렌더링이 엄격하게 필요하지 않더라도 말입니다.
결론
리액트를 빠르게 하는 이 기술은 새로운 기술이 아닙니다. DOM 조작은 비용이 많이 들고, 쓰기와 읽기 작업은 배치성으로 수행해야 하며, 이벤트 위임이 더 빠르다는 것은 오랫동안 알고 있던 사실들입니다.
실제로 이를 일반적인 자바스크립트로 구현하기 어렵기 때문에 사람들은 여전히 이 문제에 대해 이야기합니다. 리액트를 더욱 돋보이게 하는 것은 이런 모든 최적화 작업들이 기본적으로 발생한다는 것입니다. 이 문제들은 여러분을 힘들게 하고 앱을 느리게 합니다.
리액트의 성능 비용 모델은 아주 단순합니다. 모든 setState는 전체 서브트리의 리렌더링을 일으킵니다. 성능을 더 좋게 만들고 싶다면, 최소한 적게 setState를 호출하고 큰 서브트리의 리렌더링을 막는 shouldComponentUpdate을 사용하세요