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문서를 조작할 수 있도록 트리 구조화한 객체 모델이다.
DOM의 조작 속도가 느려지는 이유
- 자료구조 중에서 트리는 “데이터 저장”의 의미보다는 저장된 데이터를 더 효과적으로 탐색하기 위해 사용되므로, 빠른 자료 탐색 성능이 장점인 자료구조라고 볼 수 있다.
트리 구조로 된 DOM은 JavaScript와 같은 스크립팅 언어가 접근하고 탐색하는 속도가 빠르기 때문에 변경 및 업데이트 속도 또한 빠르다고 할 수 있다.
- 브라우저는 렌더링 과정에서 DOM트리와 CSSOM트리를 토대로 Render 트리를 생성하고 각 요소가 배치될 공간을 계산한 뒤 이를 화면에 그려낸다.
- 만약 DOM이 변경된다면 업데이트된 요소와 그에 해당하는 자식 요소들에 의해 DOM트리를 재구축하게 된다.
- 그 과정에서 이에 대한 레이아웃 재연산을 수행하는 리플로우, 그리고 이를 화면에 그려내는 Repaint 과정을 거치게 된다.
이때 변화가 필요 없는 부분도 변경되면서 잦은 리플로우 발생으로 인해 성능을 떨어뜨리는 문제를 야기하게 된다.
따라서 JavaScript를 통한 DOM 조작이 많아질수록 이에 대한 리플로우가 발생하므로 DOM 업데이트 비용이 커질 수 있다.
- 위 이미지와 같이 6개의 콘텐츠가 있는 화면에서 단 1개의 콘텐츠만 변경되어야 된다고 가정한다.
- 나머지 5개의 콘텐츠는 신경쓰지 않고 해당 콘텐츠만 변경되어야 하지만, 실제 DOM을 조작하는 경우 해당 콘텐츠만 변경될 뿐만 아니라 나머지 콘텐츠도 다시 그리게 될 수 있다.
- 이 에시는 브라우저의 성능에 크게 문제를 끼치지 않겠지만, 대부분의 모던 웹에서는 많은 양의 DOM 조작이 이루어지기 때문에 리플로우로 인한 비효율적인 업ㄷ이트가 빈번하게 발생할 가능성이 있다.
- 극단적인 예로 프레임 드랍과 같은 치명적인 UX문제가 발생할 수 있다.
- 프레임 드랍은 웹 애플리케이션에서 프레임 레이트가 떨어져 화면이 버벅대거나 부드럽게 표시되지 않는 것을 의미한다.
- 이는 사용자 경험을 저하시키고, 애플리케이션의 성능을 떨어뜨릴 수 있으며, 특히 DOM 조작이 빈번하게 일어날 때 더욱 발생할 가능성이 높다.
- 바뀐 부분만 비교해서 그 부분만 렌더링할 수 없을까?라는 아이디어를 기반으로 Virtual DOM이 등장하게 되었다.
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 트리를 비교할 때, 트리의 레벨 순서대로 순회하는 방식으로 탐색한다.
- 같은 레벨끼리 비교한다는 뜻이며 이는 너비 우선 탐색의 일종이라고 볼 수 있다.
- 리액트는 이런 식으로 동일 선상에 있는 노드를 파악한 뒤 다음 자식 세대의 노드를 순차적으로 파악해 나간다.
다른 타입의 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>