Virtual DOM에 대해 알아보자!

young_pallete·2021년 6월 24일
2

React

목록 보기
1/1

1. 시작하며 👍

어제는 Reflow와 Repaint에 대해 알아보자라는 글을 썼습니다.
이는 사실, 이번 Virtual DOM이라는 아이를 알기 위한 선수 지식이기에 썼습니다. 😅

현재 svelte는 쓰지 않지만, 리액트의 경우 virtual DOM을 쓰고 있죠. 리액트가 virtual DOM을 쓰는 이유에 대해 오늘은 살펴보려 합니다.


2. 본론 📃

자, 이제 아주 재미있는 virtual DOM의 세계로 빠져봅시다.

2-1. virtual DOM이란?

React 공식 문서를 살펴볼까요?

Virtual DOM (VDOM)은 UI의 이상적인 또는 “가상”적인 표현을 메모리에 저장하고 ReactDOM과 같은 라이브러리에 의해 “실제” DOM과 동기화하는 프로그래밍 개념

이라고 적혀있습니다.

그렇다면 우리는 이 글을 봤을 때, 이런 의문이 들 수 있습니다.

왜 '가상'적인 표현을 메모리에 저장하는가?

그렇지 않나요? React에게 "이 컴포넌트는 이래야 돼!"라고 선언해서, 리액트가 DOM의 상태를 조작하기 보다는, 우리가 직접 DOM에다 변경된 부분을 바로 넣으면 되는 거니까요.

우리는 왜 virtual DOMDOM을 추상화했는지를 생각해야 할 필요가 있겠습니다.

2-2.Virtual DOM이 필요해진 이유 - DOM

이럴때, 우리는 기존에 공부했던 Reflow를 떠올릴 필요가 있습니다.
최근의 자바스크립트는 매우 동적으로 사용자와 상호작용하며 데이터를 주고받습니다. 그러다 보면, DOM 조작을 통해 새로운 노드를 추가시켜야 할 때도 있고, 삭제시키기도 하는 등 복잡한 연산들이 발생합니다.

결국, 리플로우는 반드시 발생하게 되는데, 매순간마다 DOM 조작으로 인해 리플로우가 발생한다면, 성능저하의 원인이 됩니다.

virtual DOM은 이러한 배경에서 파생된 아이디어인 거죠!
진짜 DOM이 아니라, 이러한 DOM의 변화를 일차적으로 비교해주고, 변경사항을 전달해주는 객체인 것입니다. 이 역시, React 공식 문서에 이렇게 나와 있죠.

“virtual DOM”은 특정 기술이라기보다는 패턴에 가깝기 때문에 사람들마다 의미하는 바가 다릅니다. React의 세계에서 “virtual DOM”이라는 용어는 보통 사용자 인터페이스를 나타내는 객체이기 때문에 React elements와 연관됩니다.
그러나 React는 컴포넌트 트리에 대한 추가 정보를 포함하기 위해 “fibers”라는 내부 객체를 사용합니다. 또한 React에서 “virtual DOM” 구현의 일부로 간주할 수 있습니다.


2-3. virtual DOM의 동작 방식

일반적으로 리액트에서는 stateprops의 변화가 발생한다면, render()를 통해 새로운 React 엘리먼트 트리를 return합니다.

virtual DOM이 비교를 통해 변화될 부분을 찾아내고 바꾸는 과정. 이를 재조정이라고 부릅니다.

그런데 말이죠, virtual DOM은 엄연히 객체입니다. 정확히 말하면 DOM 같이 엘리먼트간 상하관계가 있는 트리구조의 객체이죠.

이러한 트리구조의 객체를 최소한의 연산으로 바꾸는 알고리즘의 경우 O(n3)의 복잡도를 가진다고 합니다. (출처 - 리액트 공식문서)

따라서 일반적인 엄청 엘리먼트가 많을 수록, 성능은 엄~청 느려지는 셈이죠.
따라서, 리액트는 다음과 같은 꼼수(?)를 통해 복잡도를 O(n)까지 줄이는 데 성공하게 됩니다. 살펴보죠!

2-3-1. 핵심 아이디어

리액트는 2가지 핵심 아이디어를 통해 휴리스틱을 구현해냅니다. 그것은 바로,

  1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
  2. 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

그럼, 비교를 시작해봅시다!

2-3-1-a. 만약 루트가 다른 타입의 엘리먼트인 경우

공식문서의 예제를 갖고 와볼게요.

//Before
<div>
  <Counter />
</div>

//After
<span>
  <Counter />
</span>

비교 후 엘리먼트가 달라짐을 확인하면 아예 BeforeAfter과 아예 다른 트리다!라고 생각하고 버려버립니다. 밑에 달린 하위 요소들까지 싹다 말이죠. (애초에 다르니까요!)

이전 DOM Node는 없어지는 절차를 밟게 됩니다. 만약 컴포넌트라면
1. 먼저 componentWillUnmount()를 통해 언마운트시킵니다.
2. 이후 새로운 노드를 componentDidMount()시키는 거죠.
3. 이렇게 되면, 새로운 부분이 이제 화면상에 보여지는 거죠!

그렇다면 반대로, 엘리먼트가 같다면 어떻게 될까요?

2-3-1-b. 만약 루트 엘리먼트 타입이 같다면?

일단 OK! 하고 속성을 비교하게 되는 거죠.

//Before
<div className="A"/>

//After
<div className="B"/>

이제 속성이 다르다면, 다른 부분만 캐치해서 virtual DOM에 표기합니다.
만약 컴포넌트 인스턴스라면, componentDidUpdate가 실행되겠죠?


많은 것이 이해됐습니다! 이제 우리는 virtual DOM에 대해 어느정도 원리를 파악한 셈입니다.
하지만 다음과 같은 예외 상황도 있겠죠.

//Before
<div className="comment">
	<div>hi!</div> // a
   	<div>hello!</div> // b
</div>

//After
<div className="comment">
	<div>nice To meet You!</div> // 다
   	<div>hi!</div> // 가
   	<div>hello!</div> // 나
</div>

일단 지금껏 배운 걸 응용하자면, 루트의 엘리먼트와 속성은 같습니다.
그런데 문제는 말이죠, 안의 자식 노드들을 보아하니, 위에 하나만 추가되어 있는 상황입니다. 따라서 그냥 추가하면 되겠죠?

하지만 React에서는 애석하게도 이를 애매하다!라고 판단하고 모든 자식을 변경합니다. 그럴만도 한 것이, 누가 기존 노드인지를 파악하지를 못하는 거죠.

이것이 다음 가정이 중요한 이유입니다.

2-3-2. 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

일반적으로 ReactDOM Treelevel by level로 탐색하는 알고리즘을 사용합니다. 따라서 같은 레벨의 노드들을 순회하며 비교하죠. 하지만 해당 노드의 위치가 아까처럼 바뀌어버린다면, 이를 인식하기 어려워집니다. 순회를 하는 데에 있어, 위치의 변화까지는 감지하지 못하기 때문입니다. 결과적으로 이는 통째로 모든 자식을 변경하므로 성능이 저하되는 큰 원인입니다.

따라서 이때 사용하는 것이 key prop이죠.

//Before
<div className="comment">
	<div key="2">hi!</div> // a
   	<div key="1">hello!</div> // b
</div>

//After
<div className="comment">
	<div key="3">nice To meet You!</div> // 다
   	<div key="2">hi!</div> // 가
   	<div key="1">hello!</div> // 나
</div>

다만, 인덱스를 키로 설정할 경우 오류가 발생할 수 있으므로 반드시 변하지 않고, 예상 가능하며, 유일한 값을 권장합니다.

이 글에서 다음 그림을 갖고 왔습니다. 해당 그림은, 리액트에서 재조정이 일어나는 과정이에요.
reconciliation


2-4. 여담

이렇게 효율적으로 재조정이 일어납니다.
결과적으로 여러 노드들을 비교하면서, 기존에 DOM 조작으로 발생할 수 있는 여러 리렌더링 대신 딱! 한 번만 렌더링을 시켜주는 것이죠.

어떻게 보면 메모리에 남기고, 이를 다시 변화시키는 과정이 복잡하고 비용이 많이 들어보일 수 있지만, 여러 리렌더링을 방지할 수 있는 패턴이라면, 꽤나 매력적입니다.

그렇다면 이런 생각이 들 수 있겠습니다.

virtual DOM이 훨씬 빠른 거 아냐?!

이에 대해서는, 충분히 빠르지만 실제 최적화한 돔보다 빠르지는 않다고 일축하였습니다. 그 이유는, 그 안에서 해야하는 일들이 상당히 많기 때문입니다. (상태관리도 해야하고, 돔에 대한 복사도 해야하고, 변화도 감지해야하죠.)

오히려, 오버헤드가 많이 발생한다는 한계도 지적되고 있습니다.

Most obviously, . You can't apply changes to the real DOM without first comparing the new virtual DOM with the previous snapshot.

그러면 반대로 또 이런 생각을 해볼 수 있죠.

그렇다면 왜 굳이 virtual DOM을 쓰는데? 빠르지도 않다며!

이에 대한 변호로는, 생산성을 들 수 있겠습니다.
만약 DOM에서 이를 최적화하려면, DOM fragment을 사용하며 일일이 따져야 합니다. 이에 대한 고민과, 또 만약 잘못 사용했을 때의 비용 역시 꽤나 골치 아프죠.

이러한 한계점에 있어서, 리액트의 virtual DOM은 고민 자체를 해결해주기 때문에, 꽤나 생산성에 있어서 파워풀하다고 할 수 있겠습니다.

p.s. 수정 - 현재는 "2-3-2의 경우, 형제간 이동"까지는 표현할 수 있다고 하네요.

현재 구현체에서는 한 종속 트리가 그 형제 사이에서 이동했다는 사실을 표현할 수는 있지만, 아예 다른 곳으로 이동했다는 사실은 표현할 수 없습니다. 알고리즘은 전체 종속 트리를 재렌더링할 것입니다.


3. 마치며 🌈

하... 정말 오랜 기간 동안 여러 글을 비교하면서 그래도 얻은 게 많았다.
내가 리액트를 정말 잘 알지 못하고 있었다는 것도 반성하게 됐다.

모든 것은 사실 미완성됐듯이, 나 역시 모든 지식들이 다 미완으로 남아 있다.
다만, 불완전하기에, 완전을 채우려 다가가는 이 과정은 꽤나 즐겁기만 하다. 이상!


4. 참고자료

https://ko.reactjs.org/docs/faq-internals.html#gatsby-focus-wrapper
https://ko.reactjs.org/docs/reconciliation.html

https://velopert.com/3236
https://medium.com/@gethylgeorge/how-virtual-dom-and-diffing-works-in-react-6fc805f9f84e

https://jeong-pro.tistory.com/210
https://blog.naver.com/dndlab/222007423326
https://svelte.dev/blog/virtual-dom-is-pure-overhead#Where_does_the_overhead_come_from

profile
People are scared of falling to the bottom but born from there. What they've lost is nth. 😉

0개의 댓글