React
Virtual DOM
- 리액트는 UI의 상태를 추적하고 변화가 일어난 요소들을 빠르게 업데이트할 수 있도록 Virtual DOM이라는 가상의 DOM객체를 활용한다.
Virtual DOM이 나오게 된 배경
- Virtual DOM은 Real DOM의 가벼운 사본과 같다.
- Real DOM은 Browser에서 생성되는 Document Object Model을 가리키는 용어이며, Virtual DOM과 구분하기 위해 이 용어를 사용핝ㄷ자.
Real DOM
- DOM은 Document Object Model의 약자로 문서 객체 모델을 의미한다.
- 문서 객체란 브라우저가 JavaScript와 같은 스크립팅 언어가
<html>,<head>,<body>와 같은 태그들을 접근하고 조작할 수 있도록 문서를 트리 구조로 객체화한 것을 의미한다. - DOM은 브라우저가 HTML문서를 조작할 수 있도록 트리 구조화한 객체 모델이다.
![image]()
DOM의 조작 속도가 느려지는 이유
- 자료구조 중에서 트리는 “데이터 저장”의 의미보다는 저장된 데이터를 더 효과적으로 탐색하기 위해 사용되므로, 빠른 자료 탐색 성능이 장점인 자료구조라고 볼 수 있다.
트리 구조로 된 DOM은 JavaScript와 같은 스크립팅 언어가 접근하고 탐색하는 속도가 빠르기 때문에 변경 및 업데이트 속도 또한 빠르다고 할 수 있다.
![image]()
- 브라우저는 렌더링 과정에서 DOM트리와 CSSOM트리를 토대로 Render 트리를 생성하고 각 요소가 배치될 공간을 계산한 뒤 이를 화면에 그려낸다.
- 만약 DOM이 변경된다면 업데이트된 요소와 그에 해당하는 자식 요소들에 의해 DOM트리를 재구축하게 된다.
- 그 과정에서 이에 대한 레이아웃 재연산을 수행하는 리플로우, 그리고 이를 화면에 그려내는 Repaint 과정을 거치게 된다.
이때 변화가 필요 없는 부분도 변경되면서 잦은 리플로우 발생으로 인해 성능을 떨어뜨리는 문제를 야기하게 된다.
![image]()
따라서 JavaScript를 통한 DOM 조작이 많아질수록 이에 대한 리플로우가 발생하므로 DOM 업데이트 비용이 커질 수 있다.
![image]()
- 위 이미지와 같이 6개의 콘텐츠가 있는 화면에서 단 1개의 콘텐츠만 변경되어야 된다고 가정한다.
- 나머지 5개의 콘텐츠는 신경쓰지 않고 해당 콘텐츠만 변경되어야 하지만, 실제 DOM을 조작하는 경우 해당 콘텐츠만 변경될 뿐만 아니라 나머지 콘텐츠도 다시 그리게 될 수 있다.
- 이 에시는 브라우저의 성능에 크게 문제를 끼치지 않겠지만, 대부분의 모던 웹에서는 많은 양의 DOM 조작이 이루어지기 때문에 리플로우로 인한 비효율적인 업ㄷ이트가 빈번하게 발생할 가능성이 있다.
- 극단적인 예로 프레임 드랍과 같은 치명적인 UX문제가 발생할 수 있다.
- 프레임 드랍은 웹 애플리케이션에서 프레임 레이트가 떨어져 화면이 버벅대거나 부드럽게 표시되지 않는 것을 의미한다.
- 이는 사용자 경험을 저하시키고, 애플리케이션의 성능을 떨어뜨릴 수 있으며, 특히 DOM 조작이 빈번하게 일어날 때 더욱 발생할 가능성이 높다.
- 바뀐 부분만 비교해서 그 부분만 렌더링할 수 없을까?라는 아이디어를 기반으로 Virtual DOM이 등장하게 되었다.
![180722968-4c1639e2-c394-4299-8589-dbe862dde751]()
Virtual DOM이란
- 가상 DOM은 실제 DOM과 동기화되어 상태가 변경될 때마다 가상 DOM을 새로 생성하여 이전 상태와 비교한다.
- 변경이 필요한 부분만 실제 DOM에 반영하여 업데이트하므로 전체 UI를 다시 그리지 않아도 된다.
- React에는 DOM객체에 대응하는 가상의 DOM 객체가 있다.
- 상대적으로 무거운 DOM에 비해 React의 가상 DOM 객체는 자바스크립트 객체로 이루어져 있기 때문에 실제 DOM 객체와 동일한 속성을 가지고 있음에도 훨씬 가벼운 사본이라고 표현할 수 있다.
- 가상 DOM 객체는 말그대로 가상이기 때문에 비교를 위해 사용되며, 실제 DOM 객체처럼 화면에 표시되는 내용을 직접 변경하는 것은 아니다.
예시
- 가상 DOM은 이삿날에 가구를 배치하기 전에 미리 가구 배치에 대해 생각해놓고, 실제로 배치하는 것과 같다.
- 이를 통해 화면 업데이트 시간과 비용을 절약할 수 있다.
Virtual DOM의 형태
위 예시를 다음과 같이 JavaScript 객체로도 표현할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
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" } ] ]} ] }
- 이것을 가상 DOM이라고 생각해 봤을 때, 실제 DOM과 마찬가지로 가상 DOM 또한 HTML 문서 객체를 기반으로 한다.
- 또한 추상화만 되었을 뿐 평범한 자바스크립트 객체이므로 실제 DOM을 건드리지 않고도 필요한 만큼 자유롭게 조작할 수 있다.
- 가상 DOM은 리액트에서 컴포넌트의 상태나 속성이 변경될 때마다 새로 생성되며, 리액트는 이전 가상 DOM과 새로운 가상 DOM을 비교하여 변경된 부분만 실제 DOM에 반영한다.
Virtual DOM의 동작 과정
- 다음과 같은 동영상 사이트에서 특정 썸네일 클릭 시, 모달 플레이어가 생성되는 경우를 상상해본다.
- 리액트는 상태를 변경하는 작업이 일어났을 때, 가상 DOM에 저장된 이전 상태와 변경된 현재 상태를 비교한다.
- 이 과정에서 React는 Diffing 알고리즘을 사용하여 변경된 부분을 감지한다.
- React에서 상태를 변경하는 경우에는 Diffing 알고리즘에서 이를 감지할 수 있도록 직접 할당이 아닌
setState와 같은 메서드를 활용해 상태를 변경한다.
- 가상 DOM과 변경된 새로운 가상 DOM을 비교하여 변경이 필요한 부분만 실제 DOM에 반영하여 업데이트한다.
- 이것을 Reconciliation 즉 재조정이라고 한다.
- 이 과정에서 여러 개의 상태 변화가 있을 경우 이를 일일이 수행하지 않고 일괄적으로 한번에 업데이트한다.
- 이를 통해 성능을 최적화하고 불필요한 리렌더링을 최소화할 수 있다.
Virtual DOM은 빠르다?
- 가상 DOM은 일반적으로 실제 DOM을 직접 조작하는 것보다 빠르다는 것이 맞지만, 모든 경우에 그렇지는 않다.
- 때로는 직접 DOM을 조작하는 것이 더 빠를 수 있다.
- 실제 DOM 조작이 느려지는 대부분의 경우는 DOM 조작이 발생할 때마다 브라우저에서 다시 레이아웃을 계산하고, 페이지를 다시 그리기 때문이다.
- 이러한 렌더링 과정에서 발생하는 비용이 크기 때문에, 가상 DOM을 활용하여 실제 DOM 조작을 최소화하는 것이 성능 최적화의 핵심이라고 볼 수 있다.
React Diffing Algorithm
- 리액트가 기존 가상 DOM과 변경된 새로운 가상 DOM을 비교할 때, 리액트 입장에서는 변경된 새로운 가상 DOM 트리에 부합하도록 기존의 UI를 효율적으로 갱신하는 방법을 알아낼 필요가 있었다.
- 하나의 트리를 다른 트리로 변형을 시키는 가장 작은 조작 방식을 알아내야만 했는데, 알아낸 조작 방식 알고리즘은 O(n^3)의 복잡도를 가지고 있다.
- 알고리즘을 그대로 React에 적용한다면 1000개의 엘리먼트를 실제 화면에 표시하기 위해 10억번의 비교 연산을 해야만 한다.
- 이것은 너무 비싼 연산이기 때문에 React는 두 가지의 가정을 가지고 시간 복잡도 O(n)의 새로운 휴리스틱 알고리즘을 구현해낸다.
- 각기 다른 요소는 다른 트리를 구축할 것이다.
- 개발자가 제공하는
key프로퍼티를 가지고, 여러 번 렌더링을 거쳐도 변경되지 말아야 하는 자식 요소가 무엇인지 알아낼 수 있을 것이다.
React가 DOM 트리를 탐색하는 방법
- 리액트는 기존의 가상 DOM트리와 새롭게 변경된 가상 DOM 트리를 비교할 때, 트리의 레벨 순서대로 순회하는 방식으로 탐색한다.
- 같은 레벨끼리 비교한다는 뜻이며 이는 너비 우선 탐색의 일종이라고 볼 수 있다.
- 리액트는 이런 식으로 동일 선상에 있는 노드를 파악한 뒤 다음 자식 세대의 노드를 순차적으로 파악해 나간다.
![image]()
다른 타입의 DOM 엘리먼트인 경우
- DOM 트리는 각 HTML 태그마다 각각의 규칙이 있어 그 아래 들어가는 자식 태그가 한정적이라는 특징이 있다.
- 자식 태그의 부모 태그 또한 정해져 있다는 특징이 있기 때문에, 부모 태그가 달라진다면 리액트는 이전 트리를 버리고 새로운 트리를 구춘한다.
- 부모 태그가 바뀌어버리면, 리액트는 기존의 트리를 버리고 새로운 트리를 구축하기 때문에 이전의 DOM 노드들은 전부 파괴된다.
- 부모 노드였던
<div>가<span>으로 바뀌어버리면 자식 노드인<Counter />가 완전히 해제되며, 이전<div>태그의<Counter />는 파괴되고<span>태그 속 새로운<Counter />가 다시 실행된다. - 새로운 컴포넌트가 실행되면서 기존의 컴포넌트는 완전히 해제되어버리기 때문에
<Counter />가 갖고 있던 기존의 state 또한 파괴된다.1 2 3 4 5 6 7
<div> <Counter /> </div> // 부모 태그가 div에서 span으로 바뀐다. <span> <Counter /> </span>
같은 타입의 DOM 엘리먼트인 경우
- 반대로 타입이 바뀌지 않는다면 리액트는 최대한 렌더링을 하지 않는 방향으로 최소한의 변경 사항만 업데이트한다.
- 이것이 가능한 이유는 앞서 리액트가 실제 DOM이 아닌 가상 DOM을 사용해 조작하기 때문이다.
업데이트 할 내용이 생기면 Virtual DOM 내부의 프로퍼티만 수정한 뒤, 모든 노드에 걸친 업데이트가 끝나면 그때 단 한번 실제 DOM으로의 렌더링을 시도한다.
1 2 3
<div className="before" title="stuff" /> // 기존의 엘리먼트가 태그는 바뀌지 않은 채 className만 바뀌었다. <div className="after" title="stuff" />
- 리액트는 두 요소를 비교했을 때
className만 수정되고 있다는 것을 알게 된다. className = "before"와after는 각자 이런 스타일이 있다고 가정해본다.1
// className이 before인 컴포넌트 <div style= title="stuff" /> // className이 after인 컴포넌트 <div style= title="stuff" />
- 두 엘리먼트를 비교했을 때 리액트는 정확히
color스타일만 바뀌고 있음을 눈치챈다. - 따라서 리액트는
color스타일만 수정하고fontWeight및 다른 요소는 수정하지 않는다. - 이렇게 하나의 DOM 노드를 처리한 뒤 리액트는 뒤이어서 해당 노드들 밑의 자식들을 순차적으로 동시에 순회하면서 차이가 발견될 때마다 변경한다.
- 이를 재귀적으로 처리한다 고 표현한다.
자식 엘리먼트의 재귀적 처리
1
2
3
4
5
6
7
8
9
10
11
<ul>
<li>first</li>
<li>second</li>
</ul>
// 자식 엘리먼트의 끝에 새로운 자식 엘리먼트를 추가했다.
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
- 리액트는 기존
<ul>과 새로운<ul>을 비교할 때 자식 노드를 순차적으로 위에서부터 아래로 비교하면서 바뀐 점을 찾는다. - 그렇기 때문에 예상대로 리액트는 첫 번째 자식 노드들과 두 번째 자식 노드들이 일치하는 걸 확인한 뒤 세 번째 자식 노드를 추가한다.
이렇게 리액트는 위에서 아래로 순차적으로 비교하기 때문에, 이 동작 방식에 대해 고민하지 않고 리스트의 처음에 엘리먼트를 삽입하게되면 이전의 코드에 비해 훨씬 나쁜 성능을 내게 된다.
1 2 3 4 5 6 7 8 9 10 11
<ul> <li>Duke1</li> <li>Duke2</li> </ul> // 자식 엘리먼트를 처음에 추가한다. <ul> <li>Duke0</li> <li></li> <li>Duke1</li> <li>Duke2</li> </ul>
- 이렇게 구현하게 되면 리액트는 우리의 기대대로 최소한으로 동작하지 못하게 된다.
- 리액트는 원래의 동작하던 방식대로 처음의 노드들을 비교하게 된다.
- 처음의 자식 노드를 비교할 때,
<li>Duke1</li>와<li>Duke0</li>로 자식 노드가 서로 다르다고 인지하게 된 리액트는 리스트 전체가 바뀌었다고 발아들인다. - 즉,
<li>Duke1</li>와<li>Duke2</li>는 그대로기 때문에 두 자식 노드는 유지시켜도 된다는 것을 깨닫지 못하고 전부 버리고 새롭게 렌더링 해버린다. - 그래서 리액트는 이 문제를 해결하기 위해
key라는 속성을 지원한다. - 이는 효율적으로 가상 DOM을 조작할 수 있도록 한다.
- 만일 개발할 당시 key라는 속성을 사용하지 않으면 리액트에서
key값을 달라고 경고문을 띄우는 것도 이 때문인 것이다. key값이 없는 노드는 비효율적으로 동작할 수 있기 때문이다.
Key
- 만약 자식 노드들이
key를 갖고 있다면, 리액트는 그key를 이용해 기존 트리의 자식과 새로운 트리의 자식이 일치하는지 아닌지 확인할 수 있다. - 리액트는
key속성을 통해2014라는 자식 엘리먼트가 새롭게 생겼고,2015,2016키를 가진 엘리먼트는 그저 위치만 이동했다는 걸 알게 된다. - 따라서 리액트는 기존의 동작 방식대로 다른 자식 엘리먼트는 변경하지 않고 추가된 엘리먼트만 변경한다.
- 이
key속성에는 보통 데이터 베이스 상의 유니크한 값을 부여해주면 된다. - 키는 전역적으로 유일할 필요는 없고, 형제 엘리먼트 사이에서만 유일하면 된다.
- 만약 유니크한 값이 없다면 최후의 수단으로 배열의 인덱스를
key로 사용할 수 있다. - 배열이 다르게 정렬될 경우가 생긴다면 배열의 인덱스를
key로 선택했을 경우는 비효율적으로 동작할 것이다. 배열이 다르게 정렬되어도 인덱스는 그대로 유지되기 때문에 인덱스는 그대로지만 그 요소가 바뀌어버린다면 리액트는 배열의 전체가 바뀌었다고 받아들일 것이고, 기존의 DOM 트리를ㄹ 버리고 새로운 DOM 트리를 구축해버리기 때문에 비효율적으로 동작하는 것이다.
1 2 3 4 5 6 7 8 9 10 11
<ul> <li key="2015">Duke1</li> <li key="2016">Duke2</li> </ul> // key가 2014인 자식 엘리먼트를 처음에 추가한다. <ul> <li key="2014">Duke1</li> <li key="2015">Duke1</li> <li key="2016">Duke2</li> </ul>












