728x90

React 공식 홈페이지에서는 Virtual DOM을 'UI로 표현될 객체를 가상 메모리에 저장하여 라이브러리에 의해 실제 DOM으로 동기화 하는 개념'으로 표현하고 있습니다.

여기서 UI로 표현될 객체는 DOM을 의미하며, 라이브러리는 VirtualDOM을 렌더링해주는 라이브러리를 의미합니다.

TLDR

리액트는 두 가지 전략을 사용해 diff 알고리즘을 기존의 O(n^3)에서 O(n)으로 성능 개선을 하였습니다.

리액트에서 사용하는 Virtual DOM을 바닐라 자바스크립트로 충분히 구현 가능하다는 것을 알 수 있습니다(본 게시글의 Virtual DOM 코드는 리액트의 Virtual DOM diff 알고리즘과 많이 다릅니다).

리액트를 사용할 때 데이터의 변화에 따른 화면 변화의 흐름을 이해한다면 Virtual DOM을 보다 더 효율적으로 사용할 수 있을 것입니다.

DOM이란?

DOM은 HTML을 객체로 표현한 것이며, JavaScript와 연결될 인터페이스입니다. HTML로 작성된 코드는 HTML 파서에 의해 DOM이라는 객체가 모인 트리로 변환되는데, 이를 DOM 트리라 합니다.

Virtual DOM의 이점

Virtual DOM은 DOM이 변경될 때 전체 DOM을 Reflow하는 것이 아닌, 가상의 DOM을 이용해 한 번만 Reflow를 수행할 수 있도록 하여 DOM의 부하를 줄여주는 이점이 있습니다.

React의 diff 알고리즘

diff 알고리즘의 주요 작업은 어떤 한 상태에서 다른 상태로 변경하는 가장 합리적인 방법을 찾아내는 것입니다. A 상태에서 B 상태로 변경하는 최소한의 변경이 필요하지만, 일부 응용 프로그램은 각각 영향을 미치는 기타 요인에 따라 작업에 무게를 두어야 할 수도 있습니다.

React에서 diff 알고리즘이 왜 필요한가?

React 컴포넌트를 처음 렌더링할 때 모든 엘리먼트의 트리가 만들어집니다. state 또는 props의 업데이트에 의해 render() 함수가 다시 호출된다면 React Element를 업데이트합니다. 여기에서 원래 상태와 변경된 상태에 대한 리소스의 가장 높은 효율을 식별하는 것인데, 이 문제에 대한 일반적인 해결책으로 O(n^3)의 복잡성을 갖는 알고리즘이 있습니다. 여기에서 n은 트리의 엘리먼트의 수 입니다.

React는 이 문제를 어떻게 바라봤을까?

React에서 위의 접근 방식을 사용한다면 1000개의 엘리먼트를 업데이트하기 위해 10억번의 비교가 필요할 것입니다. 이것은 비효율적인 방법으로, React는 다음 두 가지 가정을 기반으로 O(n) 알고리즘을 구현했습니다.

  1. Two elements of differnt types will produce different trees.
    • 서로 다른 타입을 가진 두 엘리먼트는 서로 다른 트리를 만들어냅니다.
  2. The developer can hit at which child elements may be stable across different renders with a key prop.
    • 개발자가 key prop를 사용해 자식 엘리먼트의 변경 여부를 표시할 수 있습니다.

Different root element

Virtual DOM 트리의 Root 엘리먼트의 타입이 달라진다면 DOM 트리를 버린 다음, 새로운 트리를 구축합니다.

  • Point A
    • <p> <MyComp /> </p>
  • Point B
    • <span> <MyComp/> </span>

위의 경우에 Point A를 마운트 해제(componentWillUnmount)한 뒤, Point B를 마운트(componentWillMount -> componentDidMount) 합니다. 엘리먼트가 제거된다면 해당 엘리먼트와 하위 엘리먼트의 state 값 역시 사라집니다.

Same root element

루트 엘리먼트가 동일하다면 모든 props를 비교하여 동일한 값은 유지하고, 새롭게 추가되거나 변경된 props만 적용합니다.

스타일의 갱신 역시 변경된 사항만 갱신합니다.

  • Point A

    <div className="comp1" style={{color: 'red', backgroundColor: 'blue'}}>
    Test
    </div>
  • Point B

    <div className="comp2" style={{color: 'red', backgroundColor: 'black'}}>
    Test
    </div>

All the Elements are Of The Same Type

컴포넌트가 생신되면 인스턴스는 동일하게 유지되며, 렌더링 간 state 값이 유지됩니다. React는 새로운 엘리먼트와 일치하도록 컴포넌트의 props를 업데이트한 뒤, componentWillReceivePropscomponentWillUpdate를 호출합니다.

위 과정을 거친 뒤, render() 메소드가 호출되어 이전 결과와 비교하기 위해 diff 알고리즘을 재귀 호출합니다.

Recursion on Child elements

React는 리스트를 비교할 때 순차적으로 비교하며 차이점이 있다면 변경합니다. 순회 비교를 하므로 Element 리스트의 앞에 추가하는 것보다 뒤에 추가하는 것이 성능상으로 좋습니다.

  • Point A

    <ul>
      <li>a</li>
      <li>b</li>
    </ul>
  • Point B

    <ul>
      <li>a</li>
      <li>b</li>
      <li>c</li>
    </ul>

위의 예시는 a lib li를 각각 비교한 후, 값이 동일하다는 것을 파악한 뒤 c li를 추가하므로 성능상의 문제가 없습니다.

  • Point A

    <ul>
      <li>a</li>
      <li>b</li>
    </ul>
  • Point B

    <ul>
      <li>c</li>
      <li>a</li>
      <li>b</li>
    </ul>

위와 같이 맨 앞에 삽입되는 경우, React는 a li(old)와 c li(new)를 비교하면서 하위 트리를 유지할 수 없다고 판단한 후, 모든 자식을 변경합니다. 이러한 절차로 성능상의 문제가 발생할 수 있습니다.

Keys

  • Point A

    <ul>
      <li key='1'>a</li>
      <li key='2'>b</li>
    </ul>
  • Point B

    <ul>
      <li key='3'>c</li>
      <li key='1'>a</li>
      <li key='2'>b</li>
    </ul>

목록의 중간 또는 맨 앞에 추가되는 경우 성능상의 문제가 발생하는 것을 방지하기 위해 key props를 지원합니다. React는 key props를 이용해 비교할 두 트리를 결정합니다. key 값이 동일한 두 트리를 비교한 후, 차이점이 발생하면 트리를 업데이트합니다.

이러한 특징을 가지고 있어 React에서 반복문을 사용해 Element를 생성할 때 일반적으로 key props를 요구합니다. 주의해야 할 점이 있는데, key props에 index 속성을 사용한다면 Element 항목이 재배열되는 경우 key 값이 변경될 수 있어 목록이 추가 / 제거될 가능성이 있다면 key에 index 사용을 권장하지 않습니다.

React의 createElement

const Component = React.createClass({
  render: function() {
    if (this.props.first) {
      return (
        <div className="first">
          <span>A Span</span>
        </div>
      );
    } else {
      return (
        <div className="second">
          <p>A Paragraph</p>
        </div>
      );
    }
  }
});

React의 render 결과는 실제 DOM 노드가 아닙니다. 자바스크립트 객체로 반환되며, render를 통해 생성된 자바스크립트 객체를 virtual DOM이라 부르는 것입니다.

React는 위에서 알아본 절차를 거쳐 첫 render 이후 render를 통해 업데이트할 때 가장 효율적인 방법을 찾을 것입니다. 위의 예시를 통해 알아본다면 <Component first={true} /> 에서 <Component first={false} />로 변경하고, 컴포넌트를 unmount시킬 때 DOM은 다음 과정을 수행할 것입니다.

  1. 노드 생성
    • <div className="first"><span>A Span</span></div> 노드를 생성합니다.
  2. attribute 및 노드 교체
    • className="first"className="second"로 교체
    • <span>A Span</span><p>A Paragraph</p>으로 교체
  3. 노드 삭제
    • <div className="second"><p>A Paragraph</p></div> 삭제

Virtual DOM 만들기

<ul class="list">
  <li>item 1</li>
  <li>item 2</li>
</ul>

위 DOM 트리를 메모리에 저장해야 할 때, 이것을 단순히 JS 객체로 구현할 수 있습니다. 이는 React가 DOM을 저장하는 방법과 같습니다.

{
  type: 'ul',
  props: {'class': 'list'},
  children: [
    {type: 'li', props: {}, children: ['item 1']},
    {type: 'li', props: {}, children: ['item 2']},
  ],
}

위 객체를 통해 알 수 있는 것은 type, props, children 키만 이용해서 DOM 노드를 객체로 표현할 수 있다는 것입니다. 하지만, 큰 규모의 트리를 위와 같이 직접 작성하는 것은 어렵기 때문에 편하게 작업하기 위해 헬퍼 함수를 작성할 필요가 있습니다.

createVirtualDOM 함수 작성

function createVirtualDOM(type, props, ...children) {
  return { type, props, children };
}

이제 위 함수를 이용해 돔 트리를 아래와 같이 작성할 수 있습니다.

createVirtualDOM(
  'ul',
  {class: 'list'},
  createElement('li', {}, 'item 1'),
  createElement('li', {}, 'item 2'),
)

위에서 작성한 createVirtualDOM 형태는 React JSX에서 Babel이 번역한 코드와 유사합니다.

React.createElement(
  ‘ul’,
  { className: ‘list’ },
  React.createElement(‘li’, {}, ‘item 1’),
  React.createElement(‘li’, {}, ‘item 2’),
);

JSX 없이 사용하는 React 문서에서 나와있듯이 JSX 문법은 React.createElement를 호출하기 위한 문법입니다. 때문에 아래 코드를 jsx 파일의 상단에 추가한다면 아래 우리가 직접 만든 함수를 사용해 JSX 구문을 파싱해 VirtualDOM을 반환하도록 할 수 있습니다.

/** @jsx createVirtualDOM */

JSFiddle

실제 DOM 생성

객체로 저장한 Virtual DOM을 활용해 실제 DOM을 만들어야 합니다. 가상 돔 노드를 가져와 실제 돔 노드를 생성하는 createElement 함수를 작성하도록 하겠습니다.

function createElement(node) {
  if (typeof node === 'string') {
    return document.createTextNode(node);
  }

  return document.createElement(node.type);
}

텍스트 노드로 전달하는 경우, 위와 같이 처리할 수 있습니다. 하지만 아래 객체 형태의 엘리먼트를 처리하기 위해서는 children 배열에 대한 처리가 필요하며, 이것 또한 텍스트 도느 또는 엘리먼트이기 때문에 createElement 함수로 생성할 수 있습니다.

엘리먼트의 children에 대해 createElement 함수를 호출하고 해당 엘리먼트에 appendChild()하면 다음과 같습니다.

function createElement(node) {
  if (typeof node === 'string') {
    return document.createTextNode(node);
  }

  const $el = document.createElement(node.type);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));

  return $el;
}

변경사항 처리하기

Virtual DOM을 실제 DOM으로 변경하는 작업을 진행했습니다. 이제 Virtual tree의 변화를 감지하는 알고리즘을 작성해야 합니다. 해당 알고리즘은 old, new 두 개의 트리를 비교해 실제 DOM 변경에 필요한 만큼 수행됩니다.

  • 노드가 새로 추가된 경우, appendCHild를 통해 노드 추가
  • old에 있던 내용이 new에서 제거되는 경우, removeChild를 통해 해당 노드 제거
  • 특정 위치의 노드가 달라지는 경우, 해당 노드를 replaceChild를 통해 변경해야 함
  • children이 중첩해서 있는 경우, 자식 노드까지 비교해야 함

위 4가지만 반영해서 updateElement라는 함수를 작성할 것입니다. 해당 함수는 parent, newNode, oldNode 세 개의 파라미터를 받습니다.

노드가 새로 추가된 경우

function updateElement($parent, newNode, oldNode) {
  if (!oldNode) {
    $parent.appendChild(createElement(newNode));
  }
}

노드를 새로 추가하는 경우는 단순히 appendChildcreateElement 함수를 이용해 작성할 수 있습니다.

노드를 삭제해야 하는 경우

New virtual tree에 노드가 없는 경우, 실제 DOM에서 제거해야 합니다. 우리는 부모 엘리먼트를 알고 있으므로 $parent.removeChild를 호출한 뒤, 해당 함수에 실제 DOM 엘리먼트의 reference를 전달해야 합니다.

하지만, 우리는 해당 reference를 갖고 있지 않습니다. 만약, 부모 노드에서 우리 노드의 위치를 알고 있다면 $parent.childNodes[index]로 reference를 얻을 수 있습니다. 여기에서 index는 부모 엘리먼트에 포함된 자식 엘리먼트 중 선택한 엘리먼트의 위치입니다.

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(createElement(newNode));
  } else if (!newNode) {
    $parent.removeChild($parent.childNodes[index]);
  }
}

노드가 변경되는 경우

두 노드를 비교하고 노드가 실제로 변경되었는지 알려주는 함수를 작성해야 합니다. 노드는 텍스트 노드 또는 엘리먼트 노드가 될 수 있습니다.

function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
         typeof node1 === 'string' && node1 !== node2 ||
         node1.type !== node2.type;
}

부모 노드에서 현재 노드의 index 값을 활용해 새로 생성된 노드로 쉽게 대체할 수 있습니다.

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(createElement(newNode));
  } else if (!newNode) {
    $parent.removeChild($parent.childNodes[index]);
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(createElement(newNode), $parent.childNodes[index]);
  }
}

children 비교

마지막으로 두 노드의 모든 자식을 재귀로 비교해야 합니다. 텍스트 노드는 자식을 가질 수 없으므로 엘리먼트 노드만 비교되어야 합니다.

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(createElement(newNode));
  } else if (!newNode) {
    $parent.removeChild($parent.childNodes[index]);
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(createElement(newNode), $parent.childNodes[index]);
  } else if (newNode.type) {
    const newLength = newNode.children.length;
    const oldLength = oldNode.children.length;

    for (let i = 0; i < newLength || i < oldLength; i++) {
      updateElement(
        $parent.childNodes[index],
        newNode.children[i],
        oldNode.children[i],
        i
      );
    }
  }
}

JSFiddle

결론

위와 같이 작성해서 Virtual DOM 구현을 마무리지었습니다.

여기에서 작성한 updateElement는 React의 diff 알고리즘과 많이 다르며, Virtual DOM의 작동 원리와 React에서 Virtual DOM은 어떻게 동작하는지에 대한 개념을 이해할 수 있으면 좋겠습니다.


[참조]

728x90
728x90