Today I Learned
- Virtual DOM
- 재조정 (Reconciliation)
우선, 브라우저의 동작과 그에 따른 DOM 조작의 비효율성을 알아야 Virtual DOM이라는 것이 왜 필요한 것인지 알 수 있다.
DOM Tree 생성
CSSOM Tree 생성
Render Tree 생성
Layout (reflow)
Paint (repaint)
Real DOM
을 직접적으로 조작하는 경우, 어떤 인터렉션에 의해 DOM에 변화가 발생할 때마다 모든 요소들의 스타일을 다시 계산해 Render Tree를 재생성하고 reflow, repaint하는 과정을 반복한다.
즉, 변경이 필요하지 않은 요소까지 해당 과정을 거쳐 변경되기도 한다. DOM 조작으로 인해 불필요한 소모적 비용 발생하게 되는 것이다. 이런 점에서 착안해 바뀐 부분만 비교해서 해당 부분만 변경해주는 React의 Virtual DOM
이 등장한 것이다.
Virtual DOM (VDOM)
은 UI의 이상적인 또는 “가상”적인 표현을 메모리에 저장하고 ReactDOM과 같은 라이브러리에 의해 “실제” DOM과 동기화하는 프로그래밍 개념이다. 이 과정을 재조정이라고 한다.
Real DOM
의 가벼운 버전의 사본으로 생각할 수도 있다. Virtual DOM
은 자바스크립트 객체로 이루어진 가상의 DOM Tree를 사용해 Real DOM
조작을 최소화하여 성능을 향상 시킬 수 있다.
Virtual DOM
은 Real DOM
과 동기화되어, 상태가 변경될 때마다 Virtual DOM
을 새로 생성하여 이전 상태와 비교한다. 그리고 이전 Virtual DOM
의 내용과 업데이트 후의 내용을 비교해서 실제 바뀐 부분만 Real DOM
에 적용시킨다.
html
<ul id='items'>
<li>Item 1<li>
<li>Item 2<li>
</ul>
javascript
const virtualDom = {
tagName: 'ul',
attributes: { id: 'items' },
children: [
{
tagName: 'li',
textContent: 'Item 1'
},
{
tagName: 'li',
textContent: 'Item 2'
},
],
};
아래 예시로 변경사항이 발생했을 때 리액트의 동작 방식을 알아보자.
function FoodList() {
return (
<div>
<div>Menu</div>
<Coffee />
</div>
)
}
<FoodList>
컴포넌트 안에 <Coffee>
컴포넌트를 <Bread>
컴포넌트로 변경할 때 리액트는 어떻게 동작할까?
모든 React DOM 객체는 그에 대응하는 Virtual DOM 객체가 있다. Virtual DOM 객체는 DOM 객체 하나하나에 매핑된다.
그리고 데이터가 업데이트되면 바뀐 데이터를 바탕으로 React.createElement()
를 통해 JSX element를 렌더링한다. 이때 모든 각각의 Virtual DOM
객체가 업데이트된다. Virtual DOM
은 빠르게 업데이트되기 때문에 비용이 많이 들지 않는다. Virtual DOM
이 업데이트되면 React는 Vitual DOM
을 업데이트 전의 Virtual DOM
스냅샷과 비교하여 정확히 어떤 Virtual DOM
이 바뀌었는지 검사한다.
이 과정은 diffing 알고리즘
, 재조정 과정
에 해당한다.
diffing 알고리즘
은 element의 속성 값만 변한 경우에는 속성 값만 업그레이드 하고 해당 엘리먼트의 태그나 컴포넌트가 변경된 경우에는, 해당 노드를 포함한 하위의 모든 노드들을 언마운트 즉, 제거한 후에 새로운 Virtual DOM
으로 대체한다. 이런 변경이나 업데이트가 모두 마무리된 이후에 딱 한 번 Real DOM에
이 결과를 업데이트 한다.
정보 제공만 하는 웹 페이지, 즉 아무런 인터렉션이 발생하지 않는 웹 페이지라면 DOM 트리에 변화가 발생하지 않으므로 일반 DOM의 성능이 더 좋을 수도 있다.
리액트는 기존 Virtual DOM과 새롭게 변경된 Virtual DOM을 비교해 변경된 새로운 Virtual DOM Tree에 맞게 기존의 UI를 효율적으로 갱신하는 방법을 알아낼 필요가 있었다.
기존의 DOM 트리를 새로운 트리로 변환하기 위하여 최소한의 연산을 하는 최신 알고리즘을 사용해도 n개의 노드가 있을때 O(n^3)의 복잡도를 가진다. 이는 1000개의 엘리먼트를 그리려면 10억 번의 비교 연산 수행해야하는 비싼 연산이다.
그래서 대신, 리액트는 두 가지의 가정을 가지고 O(n) 복잡도의 새로운 휴리스틱 알고리즘을 구현했다.
React는 두 개의 Virtual DOM을 비교할 때, 트리의 레벨 순서대로 순회하는 방식으로 탐색한다. 즉 같은 레벨(위치)끼리 비교한다. 이런 식으로 동일 선상에 있는 노드를 파악한 뒤, 다음 자식 세대의 노드를 순차적으로 파악해 나간다.
DOM 트리는 각 HTML 태그마다 각각의 규칙이 있어 그 아래 들어가는 자식 태그가 한정적이라는 특징이 있다. (ex. <ul>
태그 밑에는 <li>
태그만 와야 한다.) 자식 태그의 부모 태그 또한 정해져 있기 때문에, 부모 태그가 달라진다면 리액트는 이전 트리를 버리고 새로운 트리를 구축한다.
<div>
<Counter />
</div>
// 부모 태그가 div에서 span으로 바뀝니다.
<span>
<Counter />
</span>
이렇게 부모 태그가 바뀌어버리면, 리액트는 기존의 트리를 버리고 새로운 트리를 구축하기 때문에 이전의 DOM 노드들은 전부 파괴된다. 부모 노드였던 <div>
가 <span>
으로 바뀌어버리면 자식 노드인 <Counter />
는 완전히 해제된다. 즉 이전 <div>
태그 속 <Counter />
는 파괴되고 <span>
태그 속 새로운 <Coutner />
가 다시 실행된다. 새로운 컴포넌트가 실행되면서 기존의 컴포넌트는 완전히 해제돼버리기 때문에 <Counter />
가 갖고 있던 기존의 state 또한 파괴된다.
componentWillUnmount()
: DOM 파괴될 때 실행
componentWillMount()
: DOM 삽입되기 전에 실행
componentDidMount()
: DOM 삽입된 후에 실행
반대로 타입이 바뀌지 않는다면 리액트는 최대한 렌더링을 하지 않는 방향으로 최소한의 변경 사항만 업데이트 한다. 업데이트 할 내용이 생기면 Virtual DOM
내부의 프로퍼티만 수정한 뒤, 모든 노드에 걸친 업데이트가 끝나면 그때 단 한번 실제 DOM으로 렌더링을 시도한다.
// 변경 전
<div className="before" title="stuff" />
// 변경 후
<div className="after" title="stuff" />
위의 두 엘리먼트를 비교하면, React는 현재 DOM 노드 상에 className
만 수정한다.
// 변경 전
<div style={{color: 'red', fontWeight: 'bold"}} />
// 변경 후
<div style={{color: 'green', fontWeight: 'bold"}} />
위의 경우, style
이 갱신되는데 React는 fontWeight
는 수정하지 않고 color
속성만 수정한다. 이렇게 DOM 노드를 처리가 끝나면 리액트는 이어서 해당 노드들 밑의 자식들을 순차적으로 순회하면서 차이가 발견될 때마다 변경한다. 이를 재귀적으로 처리한다고 표현한다.
컴포넌트가 갱신되면 인스턴스는 동일하게 유지되어 렌더링 간 state가 유지된다. React는 새로운 엘리먼트의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 갱신한다.
componentWillReceiveProps()
: props 갱신 전 호출
componentWillUpdate()
: 컴포넌트 업데이트 전 호출
componentDidUpdate()
: 컴포넌트 업데이트 후 호출
render()
메소드 호출 후 이전과 새로운 결과를 비교하여 재귀적으로 처리
DOM 노드의 자식들을 재귀적으로 처리할 때, React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성한다.
자식의 끝에 엘리먼트를 추가한 경우
// 변경 전
<ul>
<li>first</li>
<li>second</li>
</ul>
// 변경 후
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
React는 두 트리에서 자식 노드를 순차적으로 위에서부터 아래로 비교한다. <li>first</li>
가 일치하는 것을 확인하고, <li>second</li>
가 일치하는 것을 확인한다. 그리고 마지막으로 <li>third</li>
를 트리에 추가한다.
리액트는 위에서 아래로 순차적으로 비교하기 때문에, 반대로 리스트의 맨 앞에 엘리먼트를 삽입하게 되면 이전의 코드에 비해 성능이 저하된다.
자식의 맨 앞에 엘리먼트를 추가한 경우
// 변경 전
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>
// 변경 후
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>
맨 앞의 자식 노드는 각각 <li>Duke</li>
와 <li>Connecticut</li>
로, React는 자식 노드가 서로 다르다고 인지하고 종속 트리를 그대로 유지하는 대신 모든 자식을 변경한다. 이는 비효율적이다.
이러한 문제를 해결하기 위해, React는 key
속성을 지원한다. 만약 자식 노드들이 key
를 갖고 있다면, 리액트는 그 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는 "2014" key
를 가진 엘리먼트가 새로 추가되었고, "2015"와 "2016" key
를 가진 엘리먼트는 그저 이동만 하면 되는 것을 알 수 있다. 즉, 이전과 달리 모든 자식을 변경하는 것이 아니라 추가된 엘리먼트만 변경할 수 있다.