[React] Virtual DOM

LMH·2023년 1월 20일
0
post-thumbnail

이번 포스팅에서는 리엑트의 Virtual DOM에 대해서 정리하도록 하겠습니다.

리액트의 렌더링 기술

리액트는 브러우저에 렌더링을 하는 과정에서 Virtual DOM을 사용합니다. Virtual DOM은 Real DOM의 사본과 같은 개념으로 리액트에서는 Real DOM 객체에 접근해서 조작하지 않고 Virtual DOM과 Real DOM을 비교해서 변경된 부분만 렌더링하게 됩니다.

Real DOM(DOM)

DOM은 Document Object Model의 약자로 자바스크립트와 같은 스크립팅 언어가 HTML 태그에 접근하고 조작할 수있도록 태그들을 트리 구조로 객체화 한것을 의미합니다. DOM은 애플리케이션의 UI 상태가 변경될 때마다 해당 변경 사항을 나타내기 위해 업데이트가 됩니다.

DOM이 업데이트 된다는 것은 브라우저 렌더링 엔진에 의해 리플로우(Reflow)와 리페인트(Repaint)가 발생한다는 의미이며, 그 과정에서 레이아웃 및 페인트에 해당되는 값을의 재연산이 이루어지기 때문에 잦은 렌더링은 브라우저의 처리 속도가 느려지게 합니다. 만약 조작하는 DOM이 많다면 최악의 경우 프레임 드랍이 발생하여 최악의 사용자 경험을 제공할 수 있습니다.

브라우저 자원의 사용을 최소화하면서 효과적으로 화면을 업데이트하기 위해서 기존 DOM과 비교할 대상이 필요하게 되었고 그것이 Virtual DOM 입니다.

Virtual DOM

Virtual DOM은 가상의 UI 요소를 메모리에 유지시키고, ReactDOM과 같은 라이브러리를 통해 Real DOM과 동기화 시킵니다. Virtual DOM을 조작하는 것은 실제 DOM처럼 브라우저 화면에 그리는 것이 아니기 때문에 속도가 훨씬 빠릅니다.

리액트는 새로운 요소가 UI에 추가되면 트리 구조의 Virtual DOM을 생성합니다. 그리고 변경이 발생하면 다시 새로운 Virtual DOM이 생성됩니다. 그리고 변경 전과 변경 후의 Virtual DOM 차이를 비교한 후 Raal DOM에 반영할 수 있는 최상의 방법을 계산합니다. 즉, 최소한의 작업으로 화면에 렌더링 합니다.

Virtual DOM 형태

Virtual DOM은 추상화된 자바스크립트 객체의 형태를 가지고 있습니다. 실제 DOM과 마찬가지로 가상 DOM 또한 HTML 문서 객체를 기반으로 합니다. 또한 추상화만 되었을 뿐 평범한 자바스크립트 객체이므로 자유롭게 조작이 가능합니다.

React Diffing Algorithm

리액트가 기존 Virtual DOM과 변경된 새로운 Virtual DOM을 비교할 때 사용하는 알고리즘은 하나의 트리를 다른 트리로 변형을 시킬 때, O(n³)의 복잡도를 가지고 있었습니다. 이 알고리즘은 DOM의 노드가 많아질 수록 너무 많은 연산을 요구했습니다.

더 효율적인 탐색을 위해 리액트는 다음과 같은 두 가지의 가정을 가지고 시간 복잡도 O(n)의 새로운 휴리스틱 알고리즘(Heuristic Algorithm)으로 구현 되었습니다.
1. 각기 서로 다른 두요소는 다른 트리를 구축할 것이다.
2. 개발자가 제공하는 key 프로퍼티를 가지고, 여러 번 렌더링을 거쳐도 변경되지 말아야 하는 자식요소가 무엇인지 알아 낼 수 있을 것이다.

실제 이 두 가정은 거의 모든 실제 사용 사례에 적합하였고 이 과정에서 비교 알고리즘(Diffing Algorithm)을 사용합니다.

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

리액트는 기존의 가상 DOM 트리와 새롭게 변경된 가상 DOM 트리를 비교할 때, 트리의 레벨 순서대로 순회하는 방식으로 탐색합니다. 즉 같은 레벨(위치)끼리 비교한다는 뜻입니다. 이는 너비 우선 탐색(BFS)의 일종이라고 볼 수 있습니다.

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

DOM 트리는 각 HTML 태그마다 각각의 규칙이 있어 그 아래 들어가는 자식 태그가 한정적입니다. 예를들어 ul, li 태그의 경우 자식 태그의 부모 태그의 관계가 정해져 있기 때문에, 리액트에서는 부모 태그가 달라진다면 이전 트리를 버리고 새로운 트리를 구축합니다.

[예시]

<div>
	<Counter />
</div>

//부모 태그가 div에서 span으로 바뀐 경우
<span>
	<Counter />
</span>

기존의 트리를 버리고 새로운 트리를 구축하기 때문에 이전의 DOM 노드들은 모두 지워집니다. 위의 코드처럼 부모 노드였던 div가 span으로 바뀌어버리면 자식 노드인 Counter 컴포넌트는 완전히 사라집니다.

즉, 함수형 컴포넌트의 경우 span 태그 속에서 Counter 함수가 새롭게 실행된다는 의미입니다. 새로운 컴포넌트가 실행되면서 기존의 컴포넌트는 완전히 unmount 되어버리기 때문에 Counter 컴포넌트가 가지고 있던 state 또한 사라지게 됩니다.

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

반대로 타입이 바뀌지 않는다면 리액트에서는 최대한 렌더링을 하지 않는 방향으로 최소한의 변경 사항만 업데이트 됩니다. 업데이트 할 내용이 생기면 virtual DOM 내부의 프로퍼티만 수정한 뒤, 모든 노드에 걸친 업데이트가 끝나면 그때 단 한번 실제 DOM으로의 렌더링을 시도합니다.

[예시]

//className이 before인 컴포넌트
<div style={{color: 'red', fontWeight: 'bold"}} title="stuff" />

//className이 after인 컴포넌트
<div style={{color: 'green', fontWeight: 'bold"}} title="stuff" />  

두 엘리먼트를 비교했을 때 React는 정확히 color 스타일만 변경되었기 때문에 리액트는 color 스타일만 수정하고 fontWeight 및 다른 요소는 수정하지 않습니다. 이렇게 하나의 DOM 노드를 처리한 뒤 React는 뒤이어서 해당 노드들 밑의 자식들을 순차적으로 동시에 순회하면서 차이가 발견될 때마다 변경합니다. 이를 재귀적으로 처리한다고 표현합니다.

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

<ul>
  <li>A</li>
  <li>B</li>
</ul>

//자식 엘리먼트를 처음에 추가합니다.
<ul>
  <li>A</li>
  <li>B</li>
  <li>C</li>
</ul>

리액트는 기존 ul과 새로운 ul을 비교할 때 자식 노드를 순차적으로 위에서부터 아래로 비교하면서 바뀐 점을 찾습니다. 그렇기 때문에 예상대로 React는 첫 번째 자식 노드들과 두 번째 자식 노드들이 일치하는 걸 확인한 뒤 세 번째 자식 노드를 추가합니다.

Virtual DOM 내의 노드의 변화를 감지할 때, 위에서 아래로 순차적으로 비교합니다. 그렇기 때문에 만약, A를 컨텐츠로 가지고 있는 li 태그 앞에 C라는 컨텐츠를 가지고 있는 li 태그를 추가하게 되면, 리액트에서는 첫번째 li 노드의 컨텐츠가 A가 아니기 때문에 완전히 리스트 전체가 바뀌었다고 인식하고 새롭게 렌더링하게 됩니다.

그렇기 때문에 잘못된 방식으로 엘리먼트를 추가하는 것은 렌더링 성능을 떨어뜨릴 수 있습니다. React는 이 문제를 해결하기 위해 key라는 속성을 지원합니다. key값이 없는 노드는 비효율적으로 동작할 수 있기 때문에 key를 설정해주지 않으면 리액트에서 경고문을 띄우는 것을 확인할 수 있습니다.

KEY

만약 자식 노드들이 이 key를 갖고 있다면, 리액트는 key값을 이용해 기존 트리의 자식과 새로운 트리의 자식이 일치하는지 아닌지 확인할 수 있습니다.

<ul>
  <li key="1">A</li>
  <li key="2">B</li>
</ul>

//key가 2014인 자식 엘리먼트를 처음에 추가합니다.
<ul>
  <li key="3">C</li>
  <li key="1">A/li>
  <li key="2">B</li>
</ul>

React는 key 속성을 통해 key 값이 ‘3’라는 자식 엘리먼트가 새롭게 생겼고, ‘1’, ‘2’ 키를 가진 엘리먼트는 그저 위치만 이동했다는 것을 인식 합니다. 리액트는 기존의 동작 방식대로 다른 자식 엘리먼트는 변경하지 않고 추가된 엘리먼트만 변경합니다. 단, 이렇게 작동하기 위해서 key 값은 형제 엘리먼트 사이에서 유니크한 값을 가져야합니다.

만약 유니크한 값이 없다면 최후의 수단으로 배열의 인덱스를 key로 사용할 수 있습니다. 다만 배열이 다르게 정렬될 경우가 생긴다면 배열의 인덱스를 key로 선택했을 경우는 비효율적으로 동작할 것입니다. 왜냐하면 배열이 다르게 정렬되어도 인덱스는 그대로 유지되기 때문입니다.

인덱스는 그대로지만 그 요소가 바뀌어버린다면 리액트는 배열의 전체가 바뀌었다고 받아들일 것이고, 기존의 DOM 트리를 버리고 새로운 DOM 트리를 구축해버리기 때문에 비효율적으로 동작합니다.

Reference

https://ko.reactjs.org/
https://urclass.codestates.com/

profile
새로운 것을 기록하고 복습하는 공간입니다.

0개의 댓글