리액트의 동작 방식
React는 UI의 상태를 추적하고 변화가 일어난 요소들의 빠른 업데이트를 위해 Virtual DOM이라는 가상의 DOM 객체를 활용한다. Virtual DOM은 실제 DOM의 사본같은 개념인데, 리액트는 이 가상의 DOM 객체에 접근하여 변화 전과 후를 비교하고 바뀐 부분을 실제 DOM에 적용한다.
🤔 왜 Virtual DOM을 사용하지?
Real DOM (DOM)
* Real DOM은 브라우저에서 생성되는 Document Object Model을 가리킴. Virtual DOM과 구분하기 위해 Real DOM이라고 칭함.
DOM은 브라우저가 HTML 문서를 조작할 수 있도록 트리 구조화한 객체 모델을 말한다.
객체화 된 DOM은 JavaScript와 같은 스크립팅 언어가 DOM API를 통해 문서의 요소들을 조작할 수 있다.
🙄 엥 그러면 그냥 Real DOM을 사용하면 되는거 아닌가?
DOM은 계층적 형태의 트리 구조로 구성되어 있다. 자료구조 중에서도 특히 트리는 저장된 데이터를 더 효과적으로 탐색하기 위해 사용되는 빠른 자료 탐색 성능이 장점인 자료구조이다. 즉, DOM은 트리 구조이기 때문에 JavaScript같은 스크립팅 언어가 접근하고 탐색하는 속도가 빠르고 변경과 업데이트되는 속도도 빠르다.
하지만 DOM이 변경되고 업데이트가 된다는 것은 결론적으로 브라우저의 렌더링 엔진 또한 리플로우(Reflow)한다는 것을 뜻한다.
✅ 브라우저의 렌더링 과정 요약
HTML을 파싱한 DOM 트리 + CSS를 파싱한 CSSOM 트리 = Render Tree 생성
각 요소가 배치될 공간을 계산(Layout) 하고 이것을 화면에 그려낸다(Paint).
만약 DOM이 변경될 경우에는 업데이트 된 요소와 그에 해당하는 자식 요소들에 의해 DOM 트리를 재구축하게 된다.
그러면서 레이아웃을 재연산하고(리플로우) 이를 화면에 다시 그려내게 된다(리페인트).
이때 변화가 필요 없는 부분까지도 변경되면서 잦은 리플로우가 발생하고 성능이 떨어지는 문제를 야기하게 된다.
=> 즉, JavaScript를 통한 DOM 조작이 많아질수록 이에 대한 리플로우가 발생함으로 DOM 업데이트 비용이 커지고 비효율적인 업데이트가 빈번해질 가능성이 있다. 극단적으로는 프레임 드랍(Frame Drop : 프레임 레이트가 떨어져 화면이 버벅거리거나 부드럽게 표시되지 않는것) 같은 UX 문제가 발생할 수 있다.
따라서 바뀐 부분만 비교해서 그 부분만 렌더링 할 수 없을까 라는 아이디어를 기반으로 React의 Virtual DOM이 등장하게 되었다.
Virtual DOM
DOM 객체에 대응하는 React의 가상의 DOM 객체. 실제 DOM과 동기화되어 상태가 변경될때마다 새로 생성되고 이전 상태와 비교한다. 그리고 변경이 필요한 부분만 실제 DOM에 반영하여 업데이트하므로 전체 UI를 다시 그리지 않는다.
자바스크립트 객체로 이루어져있기 때문에 실제 DOM 객체와 동일한 속성을 가지고 있음에도 훨씬 가벼운 복사본이라고 할 수 있다. 단, 말 그대로 가상이기 때문에 비교를 위해서 사용되고, 실제 DOM 객체처럼 화면에 표시되는 내용을 직접 변경하는 것은 아니다 .
형태
추상화된 자바스크립트 객체의 형태를 갖고 있다. 실제 DOM과 마찬가지로 HTML문서 객체를 기반으로 하며, 실제 DOM을 건드리지 않고 필요한 만큼 자유롭게 조작할 수 있다. 리액트에서 컴포넌트 상태, 속성이 변경될때마다 새로 생성되고 리액트는 이전의 가상 DOM과 새로운 가상 DOM을 비교하여 변경된 부분만 실제 DOM에 반영한다.
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"
}
]
]
}
]
}
동작 과정
- 이벤트 발생
- Diffing 알고리즘을 사용해 가상 DOM에 저장된 이전 상태와 현재 상태 비교
- 상태 변경시 Diffing 알고리즘이 감지할 수 있도록 해야함(setState 등 활용)
- 이전의 가상 DOM과 새로운 가상 DOM을 비교해 변경이 필요한 부분만 실제 DOM에 반영하여 업데이트
=> Reconciliation(재조정) - 여러개의 상태변화가 있을 경우 일일이 수행하는것이 아니라 일괄적으로 한번에 업데이트 함(Batch Update)
=> 성능 최적화 + 불필요한 리렌더링 최소화
React Diffing Algorithm
기존의 가상 DOM과 변경된 가상 DOM을 비교할때, 리액트 입장에서는 변경된 새로운 가상 DOM 트리에 부합하도록 기존의 UI를 효율적으로 갱신하는 방법을 알아낼 필요가 있었다. 즉 하나의 트리를 다른 트리로 변형시키는 가장 작은 조작 방식을 알아내야만 했는데, 이 알고리즘은 O(n^3)의 복잡도를 가지고 있었다.
만약 리액트에 이 알고리즘을 적용한다면 1000개의 엘리먼트를 그리기 위해 10억번의 비교 연산을 수행해야 한다. 이것은 너무 비싼 연산이기 때문에 리액트는 두가지 가정을 통해 시간복잡도 O(n)의 새로운 휴리스틱 알고리즘(Heuristic Algorithm)을 구현해냈다.
1. 각기 서로 다른 두 요소는 다른 트리를 구축할 것이다.
2. 개발자가 제공하는 key 프로퍼티를 가지고 여러번 렌더링을 거쳐도 변경되지 말아야 하는 자식 요소가 무엇인지 알아낼 수 있을 것이다.
React가 DOM 트리를 탐색하는 방법
리액트는 기존의 가상 DOM 트리와 새롭게 변경된 가상 DOM 트리를 비교할때 트리의 레벨 순서대로 순회하는 방식으로 탐색한다. 즉 같은 레벨(위치)끼리 비교한다. 이는 너비 우선 탐색(BFS)의 일종이라고 볼 수 있다.
이렇게 동일 선상에 있는 노드를 파악한 뒤 다음 자식 세대의 노드를 순차적으로 파악해나간다.
다른 타입의 DOM 엘리먼트인 경우
DOM 트리의 특징은 HTML 태그마다 각각의 규칙이 있어 부모태그의 자식태그가 한정적이고, 마찬가지로 자식 태그의 부모 태그 또한 정해져있다. 일례로, ul 태그의 자식으로는 li 태그만 사용할 수 있다던가, p 태그 안에 p태그를 쓰지 못하는 것들이 있다. 따라서 리액트는 부모태그가 달라질 경우 이전 트리를 버리고 새로운 트리를 구축한다.
<div>
<Counter />
</div>
//부모 태그 변경
<span>
<Counter />
</span>
위의 코드처럼 부모 태그가 바뀌어버리는 경우, 리액트가 기존의(이전) 트리를 버리고 새로운 트리를 구축하기 때문에 이전의 DOM 노드들은 모두 파괴된다. 즉 div 내부의 <Counter />는 파괴되고 span 속 새로운 <Counter />가 다시 실행되는 것이다. 새로운 컴포넌트가 실행되면 기존의 컴포넌트는 완전히 해제(Unmount) 되어버리기 때문에 <Counter />가 갖고있던 기존의 state는 파괴된다.
같은 타입의 DOM 엘리먼트인 경우
타입이 바뀌지 않는 경우, 실제 DOM이 아닌 가상 DOM을 사용해 조작하고 업데이트 할 내용이 생기면 virtual DOM 내부의 프로퍼티만 수정한 뒤 모든 노드에 걸친 업데이트가 끝나면 그때 딱 한번 실제 DOM으로의 렌더링을 시도한다.
<div className="before" title="stuff" />
<div className="after" title="stuff" />
// className만 바뀌었음으로 className만 수정한다
//className이 before인 컴포넌트
<div style={{color: 'red', fontWeight: 'bold"}} title="stuff" />
//className이 after인 컴포넌트
<div style={{color: 'green', fontWeight: 'bold"}} title="stuff" />
// color만 변경되었음으로 color만 변경하고 그 외의 요소는 수정하지 않는다
이렇게 하나의 DOM 노드를 처리한뒤 리액트는 뒤이어서 해당 노드들 밑의 자식들을 순차적으로 동시에 순회하며 차이가 발견될때마다 변경한다. 이를 재귀적으로 처리한다고 표현한다.
자식 엘리먼트의 재귀적 처리와 key
<ul>
<li>first</li>
<li>second</li>
</ul>
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
예를 들어 자식의 끝에 엘리먼트를 추가하는 경우, 리액트는 기존의 ul과 새 ul을 비교할때 자식노드를 순차적으로 위에서부터 아래로 비교하면서 바뀐점을 찾는다. 첫번째 노드와 두번째 노드가 일치하기때문에 세번째 자식 노드가 추가된다.
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>
//자식 엘리먼트를 처음에 추가합니다.
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>
리액트는 위에서 아래로 순차적으로 비교하기때문에 리스트의 처음에 엘리먼트를 삽입하게 되면 이전에 비해 성능이 나빠진다. 처음의 자식 노드를 비교할때 <li>Duke</li>와 <li>Connecticut</li>로 자식 노드가 서로 다르다고 인지하게 된 리액트는 리스트 전체가 바뀌었다고 받아들이고 모든 내용을 새롭게 렌더링해버린다. 즉, <li>Duke</li>와 <li>Villanova</li>의 내용은 그대로이기 때문에 유지시켜도 된다는 것을 인지하지 못하는 것이다.
그래서 리액트는 이런 문제를 해결하기 위해 key 속성을 지원한다.
만약 자식 노드들이 key를 갖고 있다면 리액트는 key를 이용해 기존 트리의 자식과 새로운 트리의 자식이 일치하는지 아닌지 확인할 수 있다.
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
//key가 2014인 자식 엘리먼트를 처음에 추가합니다.
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
위의 코드처럼 key 속성을 부여해주었을 경우, 리액트는 key=2014라는 자식 엘리먼트가 새로 생겼고 key=2015, key=2016이라는 엘리먼트는 위치만 이동했다는것을 알게 된다. 따라서 기존의 동작 방식대로 다른 자식 엘리먼트는 변경하지 않고 추가된 엘리먼트만 변경한다.
key 속성으로는 보통 id 처럼 데이터베이스 상의 유니크한 값을 부여해준다. 전역적으로 유일한 값일 필요는 없고 형제 엘리먼트 사이에서만 유일하면 된다.
만약 유니크한 값이 없다면 최후의 수단으로 배열의 index를 key로 사용할 수 있는데, 이는 비효율적으로 동작한다.
배열이 다르게 정렬될 경우에도 인덱스는 그대로 유지되기때문에 인덱스는 그대로지만 요소가 바뀌어버릴 경우 리액트는 배열 전체가 바뀌었다고 받아들이고 기존의 DOM 트리는 버리고 새 DOM 트리를 구축하기때문이다.
참고
https://ko.reactjs.org/docs/reconciliation.html
'study > TIL' 카테고리의 다른 글
CS 기초 (0) | 2023.03.27 |
---|---|
React Hooks(feat. useMemo, useCallback, Custom Hooks) (0) | 2023.03.22 |
번들링과 웹팩(feat. 리액트) (0) | 2023.03.21 |
23.03.15 - Tree, Graph (0) | 2023.03.15 |
23.03.14 - 자료구조, Stack, Queue (0) | 2023.03.14 |