Front End/React

Virtual DOM

Virtual DOM

React에는 Virtual DOM이라는 가상의 DOM 객체가 있다. 이는 실제 DOM의 사본 개념으로, React는 실제 DOM 객체에 접근하여 조작하는 대신 이 가상의 DOM 객체에 접근해 변화 전과 변화 후를 비교하고 바뀐 부분을 적용한다.

탄생 배경

Real DOM (DOM)

Real DOM은 우리가 알고 있는 DOM과 같은 것으로, Virtual DOM과 구분 짓기 위한 이름으로 이해하면 된다. DOM은 Document Object Model의 약자로, JavaScript와 같은 스크립팅 언어가 HTML 태그에 접근하고 조작할 수 있도록 태그들을 트리 구조로 객체화 시킨 것이다. 다시 말해 브라우저가 트리 구조로 만든 객체 모델이다.

트리 구조로 이루어진 DOM 객체는 JavaScript가 쉽게 DOM 객체에 접근 및 조작을 할 수 있다. 이렇게 프로그래밍 언어로 조작하는 DOM은 어플리케이션의 UI 상태가 변경될 때마다 해당 변경 사항을 나타내기 위해 업데이트 되는데, 이런 DOM 조작이 잦다면 성능에 영향(Reflow, Repaint)을 미치게 되고, DOM의 렌더링은 브라우저의 파워에 의존하기 때문에 조작 속도가 느려지게 된다.

DOM은 계층적 구조로 이루어진 트리이다. 자료구조 중 트리는 저장된 데이터를 효과적으로 탐색하기 위해 사용되는, 다시 말해 빠른 자료 탐색 성능이 장점인 자료구조라 볼 수 있기 때문에 트리 구조로 된 DOM은 JavaScript같은 스크립팅 언어가 접근하고 탐색하는 속도가 빨라 변경 및 업데이트 속도 또한 빠르다.

하지만 DOM의 변경 및 업데이트는 브라우저의 렌더링 엔진 또한 Reflow한다는 것을 의미하기 때문에 업데이트된 요소와 그의 자식 요소들에 의해 DOM 트리를 재구축하여 다시 렌더링 과정을 거쳐 UI를 업데이트 해야 한다. 브라우저의 Reflow와 Repaint는 결국 다시 연산 과정을 거쳐야 하기 때문에 속도가 그만큼 느려진다. 따라서 JavaScript로 조작하는 DOM 요소가 많을수록 모든 DOM 업데이트에 대한 Reflow가 진행되므로 DOM 업데이트에 대한 코스트가 많이 들게 된다.

일례로, 6개의 컨텐츠 중 단 1개의 컨텐츠만 색상이 바뀌는 동작을 한다면 대부분의 JavaScript 프레임워크는 1개의 컨텐츠만 색상을 바꾸는 것이 아닌 나머지 모든 컨텐츠도 다시 렌더링하게 된다. 이러한 비효율적인 업데이트는 극단적인 예로 프레임 드랍같은 치명적인 UX 문제가 발생하게 된다. 결국 바뀐 부분만 비교해서 그 부분만 렌더링하는 효율적인 아이디어를 기반으로 React는 Virtual DOM을 만들게 되었다.

Virtual DOM의 기본 개념

Virtual DOM은 모든 DOM 객체에 대응하는 가상의 DOM 객체이며, 상대적으로 무거운 DOM에 비해 Virtual DOM은 실제 DOM 객체와 동일한 속성을 가지고 있음에도 훨씬 가벼운 사본이라 할 수 있다.

Virtual DOM은 가상의 UI 요소를 메모리에 유지시키고, 유지시킨 가상의 UI요소를 ReactDOM과 같은 라이브러리를 통해 실제 DOM과 동기화시킨다. 가상의 DOM 객체는 화면에 표시되는 내용을 실제 DOM 객체를 직접 변경하는 것이 아니기 때문에 훨씬 속도가 빠르다.

Virtual DOM이 Real DOM보다 빠른 이유

React는 새로운 요소가 UI에 추가되면 트리 구조로 표현되는 가상의 DOM을 생성한다. 이런 요소의 상태가 변경되면 다시 새로운 가상의 DOM 트리가 만들어지게 되는데, React는 이전의 가상 DOM과 새로 만들어진 가상 DOM의 차이를 비교해 실제 DOM에 변경을 수행할 수 있는 최상의 방법을 계산하여 최소한의 작업만으로 렌더링을 할 수 있게 된다. 이는 실제 DOM의 업데이트 코스트를 줄여 더 빠른 렌더링이 가능해진다. 다시 말해, 여러 변경사항에 대한 작업은 가상 DOM에서 모두 진행 한 후 그 결과를 Real DOM에 한번에 업데이트 하는 것이다.

Virtual DOM의 형태

Virtual DOM은 추상화된 자바스크립트 객체의 형태를 가지고 있다. 실제 DOM과 마찬가지로 HTML 문서 객체를 기반으로 하고 있지만 추상화되었을 뿐 평범한 자바스크립트 객체이기 때문에 실제 DOM을 건드리지 않고도 필요한 만큼 자유롭게 조작 가능하다.

const vDom = {
	tagName: "html",
	children: [
		{ tagName: "head" },
		{ tagName: "body",
			children: [
				tagName: "ul",
				attributes: { "class": "list"},
				children: [
					{
						tagName: "li",
						attributes: { "class": "list_item" },
						textContent: "List item"
					}
				]
			]
		}
	]
}

React Diffing Algorithm

React가 기존 가장 DOM과 변경된 새로운 가상 DOM을 비교할 때, 변경된 새로운 가상 DOM 트리에 부합하도록 기존의 UI를 효율적으로 갱신하는 방법을 알아낼 필요가 있었다. 즉 하나의 트리를 다른 트리로 변형시키는 가장 작은 조작 방식을 알아내야만 했는데, 알아낸 조작 방식 알고리즘은 O(n3)O(n^3)의 복잡도를 가지고 있었다.

하지만 이 알고리즘은 React에 적용하기에 1000개의 엘리먼트라면 10억번(1000n31000*n^3)의 연산을 해야 하는 너무 비싼 코스트의 연산이기 때문에 두 가지의 가정을 가지고 시간 복잡도 O(n)O(n)의 새로운 휴리스틱 알고리즘을 구현해냈다. 두 가지 가정은 아래와 같다.

  1. 각기 서로 다른 두 요소는 다른 트리를 구축할 것이다.
  1. 개발자가 제공하는 key 프로퍼티를 이용해 여러 번 렌더링을 거쳐도 변경되지 말아야 하는 자식 요소가 무엇인지 알아낼 수 있을 것이다.

실제 이 두 가정은 거의 모든 실 사용 사례에 들어맞게 된다. 여기서 React는 비교 알고리즘(Diffing Algorithm)을 사용한다.

React가 DOM 트리를 탐색하는 방법

React는 기존의 가상 DOM 트리와 변경된 가상 DOM 트리를 비교할 때 트리의 레벨 순서대로 순회하는 일종의 너비 우선 탐색(BFS)을 한다. 즉, 같은 레벨(위치)끼리 비교한다는 뜻이다. 동일 선상의 노드를 파악한 뒤 다음 자식 세대의 노드를 순차적으로 파악해나간다.

다른 타입의 DOM 엘리먼트인 경우

DOM 트리는 각 HTML 태그마다 각각의 규칙이 있어 그 아래 들어가는 자식 태그가 한정적이라는 특징이 있다. (ex. ul > li, p 내부의 p는 사용 못하는 등) 이는 자식 태그의 부모 태그 또한 정해져 있다는 특징이 있기 때문에, 부모 태그가 달라지만다면 React는 이전 트리를 버리고 새로운 트리를 구축한다. 이 과정에서 이전의 DOM 노드들은 전부 파괴되기 때문에, 자식 노드로 상태를 가진 컴포넌트가 있다면 기존의 컴포넌트는 완전히 해제(Unmount)되며 기존의 state 또한 파괴된다.

<div>
	<Counter />
</div>

//부모 태그가 div에서 span으로 바뀌면 기존 <Counter />는 해제된다.
<span>
	<Counter /> {// 새롭게 다시 실행되는 <Counter />}
</span>

같은 타입의 DOM 엘리먼트인 경우

타입이 바뀌지 않는다면 React는 최대한 렌더링을 하지 않는 방향으로 최소한의 변경사항만 업데이트한다. 이것이 가능한 이유는 React가 가상 DOM을 사용해 조작하기 때문이며, 업데이트 할 내용이 생기면 Virtual DOM 내부의 프로퍼티만 수정한 뒤 모든 노드에 대한 업데이트가 끝나면 그때 단 한 번 실제 DOM으로의 렌더링을 시도한다.

<div className="before" title="stuff" />

//기존의 엘리먼트가 태그는 바뀌지 않은 채 className만 바뀌었을 때
<div className="after" title="stuff" />

// style이 아래와 같이 정의되어 있다면 
// React는 color 스타일만 바뀌고 있음을 확인하고
// 바뀌는 속성만 수정한다.
.before {
	color: red;
	font-size: 16px;
}

.after {
	color: blue;
	font-size: 16px;
}

이런 형식으로 하나의 DOM 노드를 처리하면 React는 뒤이어 해당 노드들 밑의 자식을 순차적으로 동시에 순회하며 차이가 발견될 때마다 변경한다. 이를 재귀적으로 처리한다고 표현한다.

자식 엘리먼트의 재귀적 처리

예를 들어 자식 엘리먼트의 변경이 아래처럼 진행된다고 가정해보자.

<ul>
  <li>first</li>
  <li>second</li>
</ul>

//자식 엘리먼트의 끝에 새로운 자식 엘리먼트를 추가
<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

React는 기존 <ul>과 새로운 <ul>을 비교할 때 자식 노드를 순차적으로 위에서부터 아래로 비교하며 바뀐 점을 찾는다. 그렇기 때문에 첫 번째 자식 노드와 두번째 자식 노드가 일치하는 것을 확인한 뒤 세 번째 자식 노드를 추가한다. 이렇게 위에서부터 아래로 순차적으로 비교하는 동작 방식을 고려하지 않고 리스트의 처음에 엘리먼트를 삽입하면 이전 코드에 비해 훨씬 나쁜 성능을 내게 된다.

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

//자식 엘리먼트를 처음에 추가
<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

이렇게 구현한다면 React는 최소한으로 동작하지 못하게 된다. React는 처음의 노드들을 비교하게 되기 때문이다. 처음의 자식 노드를 비교할 때, <li>Duke</li><li>Connecticut</li> 로 자식 노드가 서로 다르다고 인지하게 된 React는 리스트 전체가 바뀌었다고 받아들이기 때문에, 실제로는 두 자식 노드가 같아 유지해도 된다는 것을 깨닫지 못하고 전부 새로 렌더링하게 된다. 이는 매우 비효율적인 동작 방식이다.

키(key)

React는 이 문제를 해결하기 위해 key라는 속성을 지원하고, 이는 효율적으로 가상 DOM을 조작할 수 있도록 한다. 개발 당시(특히 .map을 이용해 여러 요소를 반복해 렌더링 할 때) key라는 속성을 사용하지 않으면 경고를 띄우는 이유도 key 값이 없는 노드는 비효율적으로 동작할 수 있기 때문이다.

만일 자식 노드들이 이 key를 가지고 있다면, React는 그 key를 이용해 기존 트리의 자식과 새로운 트리의 자식이 일치하는지 확인할 수 있다.

<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

//key가 2014인 자식 엘리먼트를 처음에 추가
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

React는 key 속성을 통해 2014라는 자식 엘리먼트가 새롭게 생겼고, 2015, 2016키를 가진 엘리먼트는 위치만 이동했다는 것을 알게 된다. 따라서 React는 기존 동작 방식대로 다른 자식 엘리먼트는 변경하지 않고 추가된 엘리먼트만 변경하게 된다.

key 속성에는 보통 데이터 베이스 상의 유니크한 값(ex. Id)을 부여해주면 된다. 전역적으로 유일할 필요는 없고, 형제 엘리먼트 사이에서만 유일하면 된다. 만약 이런 유니크한 값이 없다면 최후의 수단으로 배열의 인덱스를 key로 사용할 수 있지만, 배열이 다르게 정렬될 경우 인덱스는 내용이 바뀌어도 그대로 유지되기 때문에 배열의 전체가 바뀌었다고 받아들여 새로운 DOM 트리를 구축해버리기 때문에 이는 비효율적으로 동작하게 된다.


Uploaded by N2T