가상돔 Virtual DOM을 왜 사용할까요

단단·2025년 1월 19일
3

글또

목록 보기
8/9

안녕하세요. 단단입니다.
오늘은 '리액트 코어 구현' 스터디에서 Virtual DOM(가상돔)을 Vanilla JS로 구현하고 배운 점을 정리해보겠습니다.

가상돔을 사용하는 이유

가상돔을 사용하는 이유를 설명하기 위해서 우선 브라우저 렌더링 과정을 알아야 합니다.

브라우저 렌더링

유저가 브라우저에 값을 입력하면 브라우저는 서버에 관련 요청을 보내고, 서버에서 받은 응답 값을 화면에 표시합니다.
이 과정을 브라우저 렌더링이라고 합니다.

브라우저 렌더링은 브라우저 렌더링 엔진이 HTML 파싱(DOM Tree, CSSOM Tree 생성) -> DOM Tree와 CSSOM Tree로 렌더트리 구성 -> 리플로우 -> 리페인트 -> 합성(화면에서 페인트된 부분을 합치는 과정) 단계를 거칩니다.

이때 DOM에 변경이 있는 렌더링 단계(리플로우 등)에서 브라우저 연산이 일어나는데, 복잡하고, 비용이 많이 듭니다.

Diffing 알고리즘과 재조정

실제 DOM이 아닌 순수객체로 이뤄진 가상 DOM을 활용해 바뀐 부분을 바뀌기 직전의 가상 DOM과 비교(Diffing 알고리즘)해 한 번에 모아서(Batch) 실제 DOM에 반영(DOM 업데이트)하는 방식이 성능 개선에 도움됩니다.

이때 직전의 가상 DOM과 변경된 가상 DOM을 비교해서 최소한의 변경만 실제 DOM에 반영하는 게 재조정(Reconciliation) 단계입니다. type, key, props 중 하나만 변경이 되더라도 변경이 필요한 컴포넌트로 체크한 후 실제돔을 업데이트 할 때 반영합니다.

결론

실제돔을 렌더링하는 데 브라우저 연산이 많이 발생하면 브라우저 성능을 잡아먹고, 브라우저 엔진에 부담을 줍니다.

또, 개발자는 사용자의 인터랙션에 의해 바뀐 부분만 렌더링되면 전체가 렌더링될 때보다 예측가능한 개발을 할 수 있고, 유지보수 측면에서도 유용할 수 있습니다.

주의할 점은 가상돔을 사용하는 게 실제돔을 관리하는 브라우저보다 빠르다고 말할 순 없습니다. 가상돔을 사용하는 이유는 브라우저와 개발자의 부담을 덜고, 더 효율적인 렌더링 프로세스를 위해서 입니다.

가상돔 Vanilla JS로 구현하기

저는 '황준일' 개발자의 블로그를 참고해 가상돔을 Vanilla JS로 가상돔을 구현해봤습니다. 전체 구현 코드는 깃허브에서 확인할 수 있습니다.

아래 코드는 가상돔을 순수객체로 구현해 실제돔 요소로 변환하는 것을 구현한 것입니다. JS 컴파일러인 Babel을 사용해 리액트를 사용하지 않고, JSX를 사용할 수 있게 세팅했습니다.

function h(type, props, ...children) {
  if (children.length === 0) {
    return { type, props, children: [] };
  }

  const flatChildren = children.flat().filter((child) => child != null);

  return {
    type,
    props: props || {},
    children: flatChildren,
  };
}

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

  if (!node) {
    return document.createTextNode('');
  }

  const element = document.createElement(node.type);

  if (node.props) {
    Object.entries(node.props).forEach(([name, value]) => {
      if (name === 'className') {
        element.setAttribute('class', value);
      } else if (name.startsWith('on')) {
        element.addEventListener(name.toLowerCase().slice(2), value);
      } else if (typeof value === 'boolean' && value) {
        element.setAttribute(name, '');
      } else {
        element.setAttribute(name, value);
      }
    });
  }

  node.children.forEach((child) => {
    element.appendChild(createElement(child));
  });

  return element;
}

const virtualNode = <div id="app">Hello World</div>;
const realNode = createElement(virtualNode);
document.body.appendChild(realNode);

const app = createElement(
  <div id="app">
    <ul>
      <li>
        <input type="checkbox" className="toggle" />
        todo list item 1<button className="remove">삭제</button>
      </li>
      <li className="completed">
        <input type="checkbox" className="toggle" checked />
        todo list item 2<button className="remove">삭제</button>
      </li>
    </ul>
    <form>
      <input type="text" />
      <button type="submit">추가</button>
    </form>
  </div>,
);

document.body.appendChild(app);

중요한 것은 가상돔을 사용하는 게 아니라, 변경된 부분만 일괄 렌더링하는 게 성능에 중요하다는 생각이 들었습니다. 그래서 Diffing 알고리즘을 실제돔에 적용하는 방식으로 코드를 구현했습니다.

아래 코드에서 updateElement()함수는 모든 태그를 비교해 변경된 부분만 업데이트하는 로직입니다. 이전 요소와 새 요소의 타입 등을 비교하고, 속성(props) 변경을 확인하고, 자식 요소를 비교하면서 변경된 부분을 확인합니다.
구현하면서 배운 점은 '분할정복'방식으로 문제 정의를 할 때 필요한 조건을 놓치지 않고 좀 더 꼼꼼하게 구현할 수 있다는 것입니다.

type RecordType = Record<string, any>;

interface VirtualNode {
  type: string;
  props: RecordType;
  children: (VirtualNode | string)[];
}

function updateElement(parent: Node, oldNode: Node | null, newNode: Node | null) {
  if (!newNode && oldNode && oldNode instanceof HTMLElement) {
    oldNode.remove();
    return;
  }

  if (newNode && !oldNode) {
    parent.appendChild(newNode);
    return;
  }

  if (!oldNode || !newNode) return;

  if (newNode instanceof Text && oldNode instanceof Text) {
    if (newNode.nodeValue !== oldNode.nodeValue) {
      oldNode.nodeValue = newNode.nodeValue;
    }
    return;
  }

  if (oldNode instanceof Element && newNode instanceof Element) {
    if (newNode.nodeName !== oldNode.nodeName) {
      oldNode.replaceWith(newNode);
      return;
    }

    updateAttributes(oldNode, newNode);

    const newChildren = Array.from(newNode.childNodes);
    const oldChildren = Array.from(oldNode.childNodes);
    const maxLength = Math.max(newChildren.length, oldChildren.length);

    for (let i = 0; i < maxLength; i++) {
      updateElement(oldNode, oldChildren[i] || null, newChildren[i] || null);
    }
  }
}

function updateAttributes(oldNode: Element, newNode: Element) {
  const oldProps = Array.from(oldNode.attributes);
  const newProps = Array.from(newNode.attributes);

  for (const { name, value } of newProps) {
    if (oldNode.getAttribute(name) !== value) {
      oldNode.setAttribute(name, value);
    }
  }

  for (const { name } of oldProps) {
    if (!newNode.hasAttribute(name)) {
      oldNode.removeAttribute(name);
    }
  }
}

const render = (state: RecordType[]) => {
  const element = document.createElement('div');
  element.innerHTML = `
    <div id="app">
      <ul>
        ${state
          .map(
            ({ completed, content }) => `
              <li class="${completed ? 'completed' : ''}">
                <input type="checkbox" class="toggle" ${completed ? 'checked' : ''} />
                ${content}
                <button class="remove">삭제</button>
              </li>
            `,
          )
          .join('')}
      </ul>
      <form>
        <input type="text" />
        <button type="submit">추가</button>
      </form>
    </div>
  `.trim();

  return element.firstElementChild;
};

딥다이브 - 리액트 파이버

Diffing 알고리즘을 검색하다보니 리액트 팀에서 Diffing 알고리즘의 한계를 보완하기 위해 리액트 파이버를 도입했다는 것을 알게 됐습니다.

리액트 파이버는 리액트에서 관리하는 JS 객체인데, 단일 작업 단위로 동작하며, 가상돔과 실제돔을 비교할 때 변경 정보를 가지고 있다고 합니다. 리액트 파이버의 목표는 리액트 웹 애플리케이션에서 발생하는 애니메이션, 레이아웃, 사용자 인터랙션에 올바른 결과물을 만드는 반응성 문제를 해결하는 것입니다.

여기서 과거 리액트의 스택 기반 알고리즘과 달리 재조정 과정을 비동기로 처리할 수 있습니다. 그래서 렌더링 우선순위 문제를 해결할 수 있다고 합니다. 또, 재조정 과정을 중단하거나 재개할 수 있다고 합니다.

글 관련 피드백은 언제나 환영합니다!
읽어주셔서 감사합니다.

참고
황준일 개발자 아티클 'Vanilla Javascript로 가상돔(VirtualDOM) 만들기'
이동현 개발자 아티클 '리액트 렌더링 과정에 대해 어디까지 알고 있나요?'
모던 리액트 딥다이브

profile
반드시 해내는 프론트엔드 개발자

0개의 댓글

관련 채용 정보