Web Frontend Developer

가상 DOM(Virtual DOM) 과 재조정(Reconciliation) 톺아보기

DevOwen 2024. 1. 25. 00:55

이번 글은 리액트의 가상 DOM(Virtual DOM)과 재조정(Reconciliation) 과정을 구체적으로 살펴보는 글이다. 최근에 기술 면접을 보면서 관련된 질문을 받았는데, 스스로 만족스러운 답변을 하지 못했다고 판단해서 이번 기회에 좀 더 자세하게 알아보려고 한다.

 

리액트의 가상 DOM

리액트는 선언적인 API를 사용하기 때문에, 매번 어떤 변화가 일어나는지를 알기는 어렵다. 다만 우리는 리액트가 가상 DOM을 통해 브라우저의 모든 렌더 트리를 다시 다 그리지 않고, 이 중에 차이가 있는 부분만 다시 그리는 것을 알고 있고 이 과정이 비교(Diffing) 알고리즘을 통해 이루어진다는 것 까지 한 번쯤은 들어 보았다. 여기서 실제 DOM과 가상 DOM을 비교하는 과정을 재조정(reconciliation)이라고 한다.

출처 : https://velog.io/@whow1101/Virtual-Dom%EC%9D%B4%EB%9E%80-Reconciliation

가상 DOM 트리를 만들면, 이는 브라우저 렌더링에서 리플로우와 리페인트를 유발하지 않는다. 그 이유는 브라우저에서 아무것도 그려지지 않았기 때문이다.

 

이번 포스팅은 왜? 이러한 과정이 나타났는지에 대해서 조금 더 주목해서 살펴보려고 한다.

 

리액트 컴포넌트를 살펴보면 최상단에 다음과 같은 선언이 되어있다는 것을 알 수가 있다.

ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
        <App/>
    </React.StrictMode>,
)

리액트의 render() 함수는 리액트 요소(element) 트리를 만든다. 그리고 이 트리는 state나 props가 갱신된다면, 새로운 리액트 요소 트리를 반환한다. 어느 하나의 트리를 다른 트리로 변환하는 최소한의 연산을 구하는 알고리즘이 있는데, 내용이 너무 어려워질 것 같아 참고 문헌만 링크로 공유한다. 이 논문에 따르면 n개의 요소가 있는 트리에 대해 O(n^3)의 복잡도를 가진다. 

 

이 연산은 너무 비싸다. 그래서 리액트는 이 시간복잡도를 휴리스틱 알고리즘을 통해 O(n) 복잡도로 낮추었다. 여기에는 두 가지 가정이 들어간다.

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

 

비교 알고리즘

두 루트 요소의 타입이 다르면 리액트는 이전 트리를 완전히 버리고 새로운 트리를 구축한다.

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

예를 들어, 이렇게 비교를 하게 되면 이전 <Counter />는 사라지고, 새로 다시 마운트가 일어난다.

 

같은 타입의 두 리액트 DOM 요소를 비교할 때, 두 요소의 속성을 확인해서 동일한 속성은 유지하고 변경된 속성만 갱신한다. 예를 들어,

<div className="before" title="stuff" />

<div className="after" title="stuff" />

이런 비교를 하게 되면, 리액트는 현재 DOM 노드의 className만 수정한다.

 

같은 타입의 컴포넌트 요소는 갱신이 되면, 인스턴스는 동일하게 유지가 되어 렌더링 간 state가 유지된다. 리액트는 새로운 요소의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 갱신한다.

 

이러한 재조정 작업을 리액트 안에서 하는 녀석을 리컨사일러(Reconciler)라고 한다. 리컨사일러가 비교 알고리즘을 사용하여 현재( Current) 트리와 작업중(Work in Progress) 트리 간의 차이를 발견한다. 그리고 이를 렌더러(renderer)로 보낸다.

 

리액트 16에서는 섬유(fiber)라고 불리는 새로운 자료구조를 통해 리컨사일러를 만들었다. 이렇게 섬유 구조를 사용한 이유는, 리컨사일러를 비동기적으로 만들고 작업을 우선순위대로 수행하게 만들기 위함이었다.

 

재조정 프로세스

조금 더 구체적으로 재조정 프로세스가 어떻게 진행이 되는지 살펴보자

https://namansaxena-official.medium.com/react-virtual-dom-reconciliation-and-fiber-reconciler-cd33ceb0478e

브라우저의 메인 쓰레드는 작업 중 트리를 생성하고, 사용자 이벤트와 리페인트 등을 처리하는데 사용된다.

 

  1. 상태가 변화하면, 리액트는 메인 쓰레드가 유휴 상태(idle)가 될때까지 기다렸다가, 작업 중 트리를 만들기 시작한다.
  2. 작업 중 트리는 섬유를 가지고 만들어지고 이 트리 구조는 현재 컴포넌트의 구조와 일치한다.
  3. 작업 중 트리를 만드는 이 단계는 비동기적으로 일어나고 메인 쓰레드가 다른 작업을 마칠 때 까지 멈춰질 수 있다. 이 경우 메인 쓰레드는 우선순위 리스트의 우선순위에 따라 작업을 진행한다. 메인 쓰레드가 다시 유휴 상태가 되면, 작업 중 트리를 다시 이어서 만들기 시작한다.
  4. 그 다음 단계는 작업 중 트리가 완성된 후 이루어진다. 이 단계는 동기적으로 이루어지며 중단되지 않는다. 이 단계에서 리액트는 이러한 변화를 DOM에 반영한다. 이 과정은 현재 트리와 작업 중 트리의 포인터를 교체(swap)하여 이루어진다. 

 

자식에 대한 재귀적 처리

DOM 노드의 자식들을 재귀적 처리할 때, 리액트는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 새로 생성한다.

 

예를 들어, 자식의 끝에 요소를 추가하면, 두 트리 사이의 변경은 잘 동작한다. 아래 예제 코드를 보면

<ul>
  <li>first</li>
  <li>second</li>
</ul>

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

<li>third</li> 만 트리에 추가를 하는 식이다.

 

만약에 차이가 리스트의 맨 앞 요소에 있어서 추가를 해야 하는 경우는 성능이 좋지 않다.

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

리액트는 이런 경우 모든 자식 <li>Connecticut</li>, <li>Duke</li>, <li>Villanova</li> 를 전부 다 변경한다. 사실 요소 하나만 추가가 된 것임에도 말이다.

 

이러한 문제를 해결하기 위해 리액트에 key 속성이 등장했다. 리액트에서는 자식 요소들이 key 속성을 가지고 있다면 기존 트리와 이후 트리의 자식 요소들이 일치 하는지를 확인한다. 예를 들어 다음과 같이 key를 추가하면,

<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

리액트는 key가 '2014'인 요소가 새로 추가되었다는 것을 알 수 있어서, '2015', '2016' key를 가진 요소는 새로 추가해줄 필요가 없이 그냥 이동을 해주면 된다.

 

key 값은 오직 해당 자식 요소들사이에서만 구분이 되면 된다. 전역에서 유일하게 작성이 될 필요는 없다. key는 어떤 요소의 자식 요소 간의 비교를 통해 변경된 부분을 빠르게 찾기 위해 존재하기 때문이다.

 

배열의 인덱스를 key로 쓰면 왜 안 되는가?

그렇다면 여기서 의문이 하나 들 수가 있는데, 배열에 있는 인덱스도 해당 배열에서 고유의 값을 서로 가지는데 왜 이건 key로 쓰면 안된다고 하는지가 의아할 수 있다.

 

예를 들어,

<div>
  <li key="1">1</li>
  <li key="2">2</li>
  <li key="3">3</li>
</div>

<div>
  <li key="1">1</li>
  <li key="2">3</li>
</div>

[1,2,3]을 map을 돌려서 나온 위와 같은 요소들에서 두 번째 값인 2가 빠진 [1,3]으로 바뀌게 된다면 어떻게 될까? 리액트는 key값을 가지고 요소의 변화를 감지하는데, key="3"이 빠졌으므로 마지막 요소가 없어졌다고 생각하고 3을 지워줄 것이다. 실제로는 2가 빠졌음에도 말이다.

 

이와 같이 배열의 인덱스를 key로 사용하면 재배열, 정렬 등의 상황에서 항목의 순서가 바뀌면서 key 값이 바뀌고, 이로 인하여 문제가 발생할 수 있으므로 다른 방법으로 자식 요소들을 구분해 주는 것이 좋다.

 

참고자료

https://ko.legacy.reactjs.org/docs/reconciliation.html

 

재조정 (Reconciliation) – React

A JavaScript library for building user interfaces

ko.legacy.reactjs.org

https://goidle.github.io/react/in-depth-react-hooks_1/

 

React 톺아보기 - 03. Hooks_1 | Deep Dive Magic Code

모든 설명은 v16.12.0 버전 함수형 컴포넌트와 브라우저 환경을 기준으로 합니다. 버전에 따라 코드는 변경될 수 있으며 클래스 컴포넌트는 설명에서 제외됨을 알려 드립니다. 각 포스트의 주제는

goidle.github.io

https://namansaxena-official.medium.com/react-virtual-dom-reconciliation-and-fiber-reconciler-cd33ceb0478e

 

React Virtual DOM, Reconciliation and Fiber Reconciler

In this article, we are going to discuss React Virtual DOM and how Reconciliation works under the hood.

namansaxena-official.medium.com