virtual DOM
리액트를 공부하다 보면 항상 마주치는 내용이 virtual DOM입니다. 그렇다면 과연 이것이 무엇이길래 그럴까요? DOM virtual DOM을 보기 전에 DOM(document object model)에 대해서 알아봅시다. 이전에 포스팅을 한 적이 있지만, 그때는 ...
리액트를 공부하다 보면 항상 마주치는 내용이 virtual DOM입니다. 그렇다면 과연 이것이 무엇이길래 그럴까요?
DOM
virtual DOM을 보기 전에 DOM(document object model)에 대해서 알아봅시다. 이전에 포스팅을 한 적이 있지만, 그때는 javascript로 활용하는 내용을 설명했다면 지금은 브라우저 렌더링 관점에서 좀 더 설명해보겠습니다.
브라우저 렌더링 엔진은 위와 같은 과정으로 렌더링하게 됩니다. HTML 파일을 읽어 DOM 트리를 만들고, CSS 파일을 읽어 CSSOM트리를 만들어 둘을 합친 뒤 렌더 트리를 구성하게 됩니다.
여기서 DOM트리와 렌더 트리는 1:1 대응 관계로 생성되지 않습니다. 이게 무슨 소리냐면, DOM트리에서 <head>나 display: none과 같이 비시각적인 태그나 css 속성을 가진 요소들은 렌더트리에 생성되지 않는다는 소리죠.
렌더트리를 생성한 이후에는 layout혹은 reflow라는 단계를 거치게 됩니다. 이는 렌더트리에서 생성된 정보를 가지고 요소들의 위치와 크기 등을 정확한 픽셀 단위로 계산하는 작업을 수행하는 단계입니다. 이 작업이 완료되면 paint작업을 거치고 실제로 화면에 보여지게 됩니다. paint 작업은 요소의 위치나 크기 정보가 아닌 배경색과 같은 정보를 가지고 수행되게 됩니다.
이것들을 왜 알아봤을까요? virtual DOM을 사용하는 이유가 바로 DOM이 느려서가 아니라는 이유입니다. 실제로 DOM자체는 그렇게 느리지 않습니다. 트리 구조는 데이터를 탐색하는 데 효율적인 자료구조입니다. 탐색속도가 빠르기 때문에 업데이트가 필요한 노드를 빠르게 찾게 되고 이로 인해 업데이트 역시 빠르게 진행됩니다. 대신 DOM의 변화가 생기게 되면 위의 렌더트리 생성 후 과정이 다시 진행되기 때문에 이를 최적화하는 것이 반드시 필요할 뿐이죠. 실제로 많은 비용이 소모되는 것은 바로 렌더트리 생성 이후의 레이아웃, 페인트 과정입니다.
virtual DOM
그렇다면 virtual DOM은 무엇일까요? 이는 DOM과 비슷한 형태의 자바스크립트 객체를 통해 현재 DOM트리의 상태를 저장하고 이후에 변경되는 사항을 먼저 반영한 뒤 한 번에 실제 DOM에 반영하여 렌더링을 한 번만 할 수 있도록 만든 것이라고 생각하면 됩니다.
virtual DOM은 DOM과 달리 api 메소드를 가진 것이 아니기 때문에 더 가볍다는 이점이 있습니다. 또한, 트리를 생성하는 과정이 브라우저에서 렌더링 시에 발생하지 않고, 메모리 상에서 이루어지기 때문에 속도도 더 빠릅니다.
리액트와 뷰의 경우 이 virtual DOM을 이용해서 좀 더 효율적인 렌더링 과정을 만들 수 있게 지원하고 있습니다. 리액트에서는 virtual DOM을 diffing 알고리즘을 이용해서 비교한 뒤 React.DOM을 이용해서 실제 DOM에 반영하게 됩니다. 리액트에선 이를 재조정(Reconciliation)이라고 하고, 이때 필요한 비교 알고리즘이 diffing 알고리즘인 것입니다.

재조정
리액트에서는 virtual DOM을 비교할 때 diffing알고리즘을 이용한다고 했습니다. 그렇다면 이것은 무엇일까요?
먼저 리액트는 가상 돔을 2개를 유지하고 있습니다. 하나는 변경이 일어나기 전의 가상 돔, 다른 하나는 변경된 후의 가상 돔이죠. render()가 호출되었을 때 리액트는 새로운 가상 돔 트리를 만들 것입니다. 이때, 이전 상태의 가상 돔 트리가 어떻게 해야 효율적으로 새로 만들어진 돔 트리의 형태로 변할 수 있는지에 대해 고민하고 나온 알고리즘이 바로 diffing알고리즘입니다.
단순히 하나의 트리에서 다른 트리의 형태로 변환하는 알고리즘 문제를 푸는 것이지만, 이에 대한 최적의 알고리즘도 O(n^3)의 시간 복잡도를 가진다고 합니다.(리액트 공식문서에서 그렇다고 하네요) 여기서 n은 컴포넌트의 수라고 생각하면 됩니다. 즉, 컴포넌트가 1000개만 되어도 10억번의 비교 연산을 해야된다는 소리죠. 그래서 도입한 것이 휴리스틱 알고리즘입니다.
휴리스틱 알고리즘은 단순히 모든 경우를 다 보면서 이상적인 답을 찾는 것이 아니라, 현실적으로 만족할만한 답을 찾는 것입니다. 즉, 위의 경우에는 완전히 효율적인 방법으로 트리를 변경하는 방법을 찾는 것이 아니라는 소리죠. 그런데 어떻게 이 방법이 이전 알고리즘보다 효율적일까요? 애초에 휴리스틱 알고리즘은 아래와 같은 의미를 담고 있습니다.
불충분한 시간이나 정보로 인하여 합리적인 판단을 할 수 없거나, 체계적이면서 합리적인 판단이 굳이 필요하지 않은 상황에서 사람들이 빠르게 사용할 수 있게 보다 용이하게 구성된 간편추론의 방법이다. - 위키 백과
실제로 리액트는 트리가 변경되는 경우를 아래와 같이 생각하고 필요할 때 과감하게 새로운 트리를 붙이는 방식을 선택했습니다.
- 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
-
개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.
-
서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
타입이 다르다는 것은 요소가 변경되었다는 소리입니다. 예를 들면 <a>태그가 <img>태그로 변경된 경우가 있죠. 타입이 같은 경우는 속성만 확인해 동일한 것은 두고 변경된 것만 반영합니다. 여기서 속성은 className, style등을 말합니다.
- 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.
아래의 코드를 봅시다.
// 변하기 전
<ul>
<li>first</li>
<li>second</li>
</ul>
// 변한 이후
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
위의 경우에는 ul의 자식을 재귀적으로 처리할 때, 두 트리 사이에서 첫번째 li와 두번째 li는 일치하는 것을 확인합니다. 그리고 단순히 마지막 li를 추가합니다.
// 변하기 전
<ul>
<li>first</li>
<li>second</li>
</ul>
// 변한 이후
<ul>
<li>third</li>
<li>first</li>
<li>second</li>
</ul>
하지만 위의 경우엔 첫번째 li가 다르다는 것을 확인하자마자 뒤의 자식들을 확인하지 않고 모든 자식을 변경하게 됩니다. 이런 문제를 해결하기 위해서 key prop을 이용해야 합니다.
<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>
자식들이 key속성을 가지고 있으면 key를 이용하여 일치하는 자식들을 확인합니다. 그러면 2015, 2016을 가진 자식들은 그냥 이동만 하면 되는 것을 알게 되죠. 이렇게 key를 이용해서 변경될 필요가 없는 자식을 알 수 있습니다.
key는 전역에서 유일할 필요는 없고 단순히 형제 사이에서만 유일하면 됩니다. 만약 자식들의 순서가 바뀌게 되는 경우 인덱스를 key로 두었을 때 예상하지 못한 결과를 볼 수 있습니다.
항상 virtual DOM이 좋을까?
그렇다면 DOM이 아닌 virtual DOM을 같이 사용하는 것이 항상 좋을까요? 그것은 아닙니다. svelte의 경우는 컴파일 방식을 사용하기 때문에 실제로 virtual DOM을 사용하지 않습니다. 기본적으로 virtual DOM도 트리를 저장해야 하기 때문에 추가적인 메모리가 필요하고, 노드들을 비교하는 비용이 들게 됩니다. 또한, CSS를 잘 사용하면, 렌더링 시 특정 단계를 생략하는 방식으로 최적화를 할 수 있기도 합니다. 대신, 요즘 같이 SPA 어플리케이션의 경우 DOM조작이 빈번하게 일어나기 때문에 virtual DOM을 이용하는 것이 효과적일 것입니다. 또한, 이 virtual DOM을 직접 만들고 적용하기 어렵기 때문에 이를 알아서 지원하는 리액트와 뷰 같은 라이브러리나 프레임워크를 사용하는 것이죠.