React 34장 - Virtual DOM
포스트
취소

React 34장 - Virtual DOM

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이 변경되고 업데이트가 된다는 것은 결국 브라우저의 렌더링 엔진 또한 리플로우한다는 것을 의미한다. 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이란

image

  • 가상 DOM은 실제 DOM과 동기화되어 상태가 변경될 때마다 가상 DOM을 새로 생성하여 이전 상태와 비교한다.
  • 변경이 필요한 부분만 실제 DOM에 반영하여 업데이트하므로 전체 UI를 다시 그리지 않아도 된다.
  • React에는 DOM객체에 대응하는 가상의 DOM 객체가 있다.
  • 상대적으로 무거운 DOM에 비해 React의 가상 DOM 객체는 자바스크립트 객체로 이루어져 있기 때문에 실제 DOM 객체와 동일한 속성을 가지고 있음에도 훨씬 가벼운 사본이라고 표현할 수 있다.
  • 가상 DOM 객체는 말그대로 가상이기 때문에 비교를 위해 사용되며, 실제 DOM 객체처럼 화면에 표시되는 내용을 직접 변경하는 것은 아니다.

예시

image

  • 가상 DOM은 이삿날에 가구를 배치하기 전에 미리 가구 배치에 대해 생각해놓고, 실제로 배치하는 것과 같다.
  • 이를 통해 화면 업데이트 시간과 비용을 절약할 수 있다.

Virtual DOM의 형태

  • 가상 DOM은 추상화된 자바스크립트 객체의 형태를 가지고 있다. image

  • 위 예시를 다음과 같이 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의 동작 과정

image

  • 다음과 같은 동영상 사이트에서 특정 썸네일 클릭 시, 모달 플레이어가 생성되는 경우를 상상해본다.

image

  • 리액트는 상태를 변경하는 작업이 일어났을 때, 가상 DOM에 저장된 이전 상태와 변경된 현재 상태를 비교한다.
  • 이 과정에서 React는 Diffing 알고리즘을 사용하여 변경된 부분을 감지한다.
  • React에서 상태를 변경하는 경우에는 Diffing 알고리즘에서 이를 감지할 수 있도록 직접 할당이 아닌 setState와 같은 메서드를 활용해 상태를 변경한다.

image

  • 가상 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>
    
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.