가상돔은 렌더링 성능을 올릴 수 있는 대표적인 방법 중 하나입니다. 브라우저 렌더링에 대한 이해가 없는 경우 브라우저 렌더링 과정을 먼저 참고해주세요.
브라우저는 크게 스타일 -> 레이아웃 -> 페인트 -> 합성 과정을 통해 렌더링을 진행합니다.
이 렌더링은 DOM이 변경됨에 따라 반복하여 발생할 수 있습니다.
브라우저는 렌더링 과정에서 성능을 제일 많이 잡아먹습니다. 특히 Reflow가 많이 발생하는 순간이 가장 치명적입니다.
DOM이 변경되면 렌더트리를 재생성하고 레이아웃을 만들고 페인팅하는 과정이 반복되어 브라우저의 연산량이 매우 늘어나게 됩니다.
사용자와의 소통이 많은 현대의 웹은 다양한 상호작용을 통해 DOM을 변화시킵니다. 그러나 DOM API를 이용하여 DOM을 변화시킬때마다 예상치 못한 성능 저하가 발생하게 됩니다.
즉, DOM 조작에 대한 복잡도가 증가하여 문제가 발생하고 있다는 얘기죠.
이러한 문제점을 해결하기 위해 등장한 개념이 가상돔(VirtualDOM)입니다.
가상돔의 개념은 사실 어렵지 않습니다.
View에 변화가 있는 경우 현재 가상돔과 새 가상돔을 비교하여 변경된 내용만 DOM에 적용하는 개념입니다. 이를 통해 렌더링 과정을 줄여 성능을 개선시키는 것이죠.
변화를 모아서 한 번에 처리하는 일종의 Batch 작업입니다.
가상돔은 순수 객체로 추상화하기 때문에 브라우저 종속적이지 않습니다. 또한 테스트도 용이합니다.
물론 가상돔이 장점만 있는 것은 아닙니다. 이와 관련된 내용은 다음 포스팅에서 살펴보겠습니다.
리액트는 가상돔의 장점을 활용해 어플리케이션의 성능을 향상시킵니다. state나 props가 갱신되면 render() 함수는 새로운 React 엘리먼트 트리를 반환할 것 입니다. 이때 React는 방금 만들어진 트리에 맞게 가장 효과적으로 UI를 갱신하는 방법을 알아낼 필요가 있습니다. 따라서 이전 가상돔과 새로 만들어진 가상돔을 비교하여 차이를 확인해야 하죠.
하나의 트리를 다른 트리로 변환하는 최소한의 연산 수를 구하는 알고리즘 문제를 풀기 위한 해결책들이 있습니다. 하지만 이런 최첨단의 알고리즘도 n개의 엘리먼트가 있는 트리에 대해 O(n^3)의 복잡도를 가집니다.
이는 너무나도 비싼 연산입니다. React는 두 가지 가정을 기반으로 하여 O(n) 복잡도의 휴리스틱 알고리즘을 구현했습니다.
대부분의 상례에서 위 가정등은 들어 맞는다고 합니다.
두 개의 트리를 비교하면 React는 먼저 root 엘리먼트부터 비교합니다. 이때 크게 2가지의 경우가 존재합니다.
두 엘리먼트의 타입이 다르면, React는 이전 트리를 버리고 완전히 새로운 트리를 구축합니다. "a" 에서 "img", 또는 "Button"에서 "div"로 바뀌는 것 모두 트리 전체를 재구축하는 경우입니다.
트리를 버릴 때 이전 DOM 노드들은 모두 파괴됩니다. 컴포넌트 인스턴스는 componentWillUnmount()가 실행됩니다. 이후 새로운 트리가 만들어 질 때 새로운 DOM 노드들이 DOM에 삽입되며 UNSAFE_componentWillMount(), componentDidMount()가 실행됩니다. 이전 트리와 연관된 모든 state는 사라지게 됩니다.
루트 엘리먼트 아래의 모든 컴포넌트도 언마운트되고 그 state도 사라집니다. 예를 들어 아래와 같은 비교가 일어나면
<div>
<Counter />
</div>
<span>
<Counter />
</span>
이전 Counter는 사라지고, 새로 다시 마운트 됩니다.
같은 타입의 두 React DOM 엘리먼트를 비교할 때, React는 두 엘리먼트의 속성을 확인하여, 동일한 내역은 유지하고 변경된 속성들만 갱신합니다.
<div className="before" title="stuff" />
<div className="after" title="stuff" />
위와 같은 경우 React는 현재 DOM 노드 상의 className만 수정합니다.
style도 마찬가지로 변경된 속성만을 갱신합니다.
<div style={{color: 'red', fontWeight: 'bold'}} />
<div style={{color: 'green', fontWeight: 'bold'}} />
위 경우 React는 fontWeight는 수정하지 않고 color 속성 만을 수정합니다. DOM 노드의 처리가 끝나면, React는 이어서 해당 노드의 자식들을 재귀적으로 처리합니다.
컴포넌트가 갱신되면 인스턴스는 동일하게 유지되어 렌더링 간 state가 유지됩니다. React는 새로운 엘리먼트의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 갱신합니다.
다음으로 render() 메서드가 호출되고 비교 알고리즘이 이전 결과와 새로운 결과를 재귀적으로 처리합니다.
DOM 노드의 자식들을 재귀적으로 처리할 때, React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성합니다.
아래의 예를 살펴보겠습니다.
<ul>
<li>first</li>
<li>second</li>
</ul>
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
React는 두 트리에서 first, second가 일치하는 것을 확인하고 third를 트리에 추가합니다.
하지만 아래와 같은 경우는 다릅니다.
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>
React는 종속 트리를 그대로 유지하는 대신 모든 자식을 변경합니다. 비효율적으로 동작하죠. 즉, 순서가 바뀌는 경우 같은 엘리먼트를 구별할 수 있는 정보를 React에게 제공할 수 있어야 합니다.
이런 문제를 해결하기 위해 React는 key 속성을 지원합니다. 자식들이 key를 가지고 있다면, 이를 통해 기존 트리와 이후 트리의 자식들이 일치하는지 확인합니다.
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
이제 React는 2015, 2016은 기존에 있던 엘리먼트와 동일하다는 정보를 알 수 있고, 순서만 변경하여 앞에 2014를 추가하면 된다는 사실을 알 수 있습니다.
위와 같은 상황에서 그려지는 엘리먼트는 일반적으로 식별자를 가지고 있습니다. 따라서 그 데이터를 key로 사용할 수 있습니다.
<li key={item.id}>{item.name}</li>
만약 이러한 상황이 아니라면 데이터 구조에 ID 속성을 추가하거나 해시를 적용하여 key를 생성할 수 있습니다. 해당 key는 형제 사이에서만 유일하면 되고, 전역에서 유일할 필요는 없습니다. (Diffing 알고리즘을 생각하면 당연합니다. 같은 계층에서의 key값만 독립적이면 문제 없는 것이죠)
최후의 수단으로 배열의 인덱스를 사용할 수 있습니다. 재배열 되지 않는다면 잘 동작하겠지만, 재배열 되는 경우 비효율적으로 동작할 뿐만 아니라 state와 관련된 문제가 발생할 수 있습니다. 순서가 바뀌어 컴포넌트의 state가 엉망이 될 수 있기 때문이죠.
<출처>
잘 보고 갑니다!