가상돔을 직접 만들어보며

곽봉칠·2025년 4월 6일

React

목록 보기
2/2
post-thumbnail

글을 작성하게 된 계기

가상돔을 만드는건 항해 프론트엔드 플러스 과정의 2주차 과제이다..
만드는 과정이 흥미로웠고, 구현 과정에서 배웠던 여러 인사이트들을 공유하는 것이 의미 있을 것 같아 상세히 정리해보았다.


가상돔 개념 다시 짚고가기

버츄얼돔은 쉽게 말해서 실제 DOM의 가벼운 복사본이다.
실제 DOM은 브라우저가 화면에 그리는 요소들을 객체로 표현한 것인데, 이걸 직접 조작하면 생각보다 엄청 비용이 크다.

비용이 크다는데.. 머가 큰거지 ?

실제 DOM을 변경하면 아래와 같은 비용이 발생한다.

  1. 레이아웃 계산: DOM이 변경되면 브라우저는 요소의 위치와 크기를 다시 계산한다
  2. 리페인트: 계산된 레이아웃에 따라 화면을 다시 그린다
  3. 리플로우: 하나의 요소가 변경되면 연관된 다른 요소들도 영향을 받을 수 있어 연쇄적인 재계산이 발생한다
  4. 스타일 재계산: CSS 스타일도 다시 계산해야 한다

이런 과정이 자주 발생하면 성능이 크게 저하된다.
특히 복잡한 웹앱에서는..


버츄얼돔은 대략 이런 형태의 자바스크립트 객체로 표현된다

{
  "type": "div",
  "props": { "id": "app" },
  "children": [
    {
      "type": "ul",
      "props": null,
      "children": [
        {
          "type": "li",
          "props": null,
          "children": ["첫 번째 항목"]
        }
      ]
    }
  ]
}

이 형태의 이유는 HTML의 계층적 구조를 자바스크립트 객체로 표현하기에 가장 자연스럽고, 프로퍼티와 메서드를 활용해 DOM 조작을 쉽게 만들기 위함이다.

트리 구조를 객체로 나타내면 비교 알고리즘을 효율적으로 구현할 수 있고, JSON과 유사한 형태로 직렬화/역직렬화도 용이하다.

직렬화(Serialization)
컴퓨터 메모리 상에 존재하는 객체(Object) -> 문자열(string) 로 변환하는 것

역직렬화(Deserialization) or 파싱(Parsing)
문자열(string) -> 자바스크립트 객체(Object)로 반환하는 것


구현 과정과 내 고민들

1. JSX 변환과 평탄화 과정에서의 고민

첫 번째로 JSX를 가상 DOM 객체로 변환하는 부분을 만들었다.
여기서 제일 신경 쓴 건 중첩된 배열 구조를 평탄화하는 거였다.

function createVNode(type, props, ...children) {
  return {
    type,
    props,
    children: children.flat()
  };
}

처음엔 평탄화가 왜 필요한지 이해가 안 됐다.
근데 이런 JSX 코드가 있으면

<div>
  Hello
  {condition && ["world", "!"]}
</div>

이게 ["Hello", ["world", "!"]] 같은 중첩 배열이 되는데,
이 중첩 배열을 그대로 두면 여러 문제가 발생한다.


문제 발생하는 요인들

  1. DOM 생성 시 복잡성 증가: 배열 안에 또 배열이 있으면, DOM 요소를 만들 때 "이게 텍스트인지, 배열인지, DOM 요소인지" 매번 확인해야 한다. 마치 상자 안에 또 상자가 있어서 열 때마다 "이게 뭐지?" 확인해야 하는 것과 같다.

  2. 재귀 처리 필요: 중첩 배열을 처리하려면 재귀 함수가 필요하다.

    function processChildren(children) {
      children.forEach(child => {
        if (Array.isArray(child)) {
          // 또 배열이면 다시 재귀 호출
          processChildren(child);
        } else {
          // 실제 처리 로직
          createDOMElement(child);
        }
      });
    }

    이런 돔을 만들 때 처리할 경우 이런 재귀적 처리를 해야하는데,
    이해하기도 어렵고 디버깅하기도 어렵다.

  3. 비교 알고리즘 복잡화: 나중에 가상 DOM을 비교할 때도, 단순히 같은 위치의 요소끼리 비교하는 것이 아니라, 중첩 구조까지 고려해야 한다. 이건 마치 두 개의 복잡한 상자 세트를 비교하는 것처럼 어렵다.

평탄화를 하면 단순히 ["Hello", "world", "!"] 같은 1차원 배열이 되어, 처리 로직이 훨씬 단순해진다. 그냥 순서대로 처리하면 되니까!


2. 정규화 과정에서 생긴 의문들

평탄화된 가상 DOM을 표준화하는 과정에서도 이것저것 고민이 많았다.

function normalizeVNode(vnode) {
  // null, undefined, boolean 처리
  if (vnode === null || vnode === undefined || typeof vnode === "boolean") {
    return "";
  }
  
  // 숫자는 문자열로
  if (typeof vnode === "number") {
    return String(vnode);
  }
  
  // 함수형 컴포넌트 처리
  if (typeof vnode.type === "function") {
    const result = vnode.type(vnode.props || {});
    return normalizeVNode(result);
  }
  
  // 자식 요소도 정규화
  if (vnode.children) {
    vnode.children = vnode.children.map(normalizeVNode).filter(Boolean);
  }
  
  return vnode;
}

null이나 boolean 같은 값을 왜 빈 문자열로 바꿔야 하는지 처음엔 의문이었다. 알고보니 DOM API가 텍스트 노드를 만들 때 문자열만 받을 수 있어서였다.


리액트에서도 falsy 값 렌더링을 시도하면 특정 방식으로 처리된다

  • null, undefined, false는 아무것도 렌더링하지 않는다
  • 예를 들어 return <div>{null}</div>를 실행하면 빈 div만 렌더링된다
  • 이런 값들을 직접 출력하려고 하면 TypeError: Cannot convert undefined or null to object 같은 에러가 발생할 수 있다

이런 부분들을 내 구현에서도 반영했다.


3. DOM 생성 과정에서 발견한 재밌는 점들

가상 DOM을 실제 DOM으로 변환하는 과정은 제일 흥미로웠다.

function createElement(vNode) {
  // falsy 값 처리
  if (vNode === null || vNode === undefined || typeof vNode === "boolean") {
    return document.createTextNode("");
  }
  
  // 문자열/숫자 처리
  if (typeof vNode === "string" || typeof vNode === "number") {
    return document.createTextNode(String(vNode));
  }
  
  // 배열 처리
  if (Array.isArray(vNode)) {
    const fragment = document.createDocumentFragment();
    vNode.forEach(child => {
      const childEl = createElement(child);
      fragment.appendChild(childEl);
    });
    return fragment;
  }
  
  // 일반 요소 처리
  const element = document.createElement(vNode.type);
  
  if (vNode.props) {
    updateAttributes(element, vNode.props);
  }
  
  if (vNode.children) {
    vNode.children.forEach(child => {
      const childEl = createElement(child);
      element.appendChild(childEl);
    });
  }
  
  return element;
}

여기서 제일 신기했던 건 DocumentFragment 개념이었다.
처음에는 이게 뭔지 몰랐는데, 여러 노드를 한 번에 추가할 때 DOM 재계산을 최소화해주는 최적화 도구였다.


1000개 요소 추가할 때

// Fragment 없이
for(let i = 0; i < 1000; i++) {
  document.body.appendChild(document.createElement('div')); // 1000번 DOM 계산...
}

// Fragment 쓰면
const fragment = document.createDocumentFragment();
for(let i = 0; i < 1000; i++) {
  fragment.appendChild(document.createElement('div'));
}
document.body.appendChild(fragment); // 한 번만!

4. 이벤트 위임 구현하면서 가장 많이 고민한 부분

이벤트 처리는 제일 고민 많이 했다. 모든 요소마다 이벤트 리스너 달면 성능 문제 생길 것 같아서 이벤트 위임 방식으로 구현했다.

const handlers = new WeakMap();
const allEvents = new Set();

function addEvent(element, eventType, handler) {
  if (!handlers.has(element)) {
    handlers.set(element, new Map());
  }
  handlers.get(element).set(eventType, handler);
  allEvents.add(eventType);
}

근데 왜 root에만 이벤트 리스너를 달아도 될까? 성능 문제는 없을까? 이런 의문이 계속 들었다. 테스트해보니까 장점이 많았다

  1. 메모리 사용량 줄어든다 (요소 100개 있어도 리스너는 이벤트 타입당 1개)
  2. 새로 추가되는 요소도 자동으로 이벤트 처리된다
  3. WeakMap 쓰면 요소 삭제될 때 핸들러도 자동으로 정리된다

특히 WeakMap의 효과는 실제로 테스트해봤다

// 테스트 결과
=== A 렌더링 후 상태 ===
현재 이벤트: ["click", "submit"]
현재 핸들러: WeakMap { buttonA => Map { 'click' => handler }, formA => Map { 'submit' => handler } }

=== B 렌더링 후 상태 ===
현재 이벤트: ["submit", "change"]
현재 핸들러: WeakMap { formB => Map { 'submit' => handler }, inputB => Map { 'change' => handler } }
// A 컴포넌트의 핸들러가 자동으로 사라졌다!

WeakMap이 키를 약하게 참조해서 요소 삭제되면 자동으로 메모리 정리된다는 점이 엄청난 장점이었다.

WeakMap의 "약한 참조"란 무엇일까?

일반 Map은 키에 대한 강한 참조를 유지해서 키 객체가 다른 곳에서 참조되지 않더라도 Map이 그 키를 참조하는 한 가비지 컬렉션 대상이 되지 않는다.
반면, WeakMap은 키에 대한 약한 참조만 가지므로 키 객체가 다른 곳에서 참조되지 않으면 가비지 컬렉터가 해당 키-값 쌍을 수집할 수 있다. 즉, DOM 요소가 삭제되면 자동으로 해당 이벤트 핸들러도 메모리에서 정리된다는 의미다.

약한 참조, 깊은 참조에 대한 부분은 다음 과제에서 진행할 예정이다.
성능에 영향이 있다고 하는데, 학습하게 된다면 다음 주차 링크를 걸어 둘 예정이다.

5. Diffing 알고리즘 구현하면서 느낀 점

가상 DOM의 핵심인 Diffing 알고리즘은 정말 재밌었다.
두 트리 비교해서 변경된 부분만 업데이트하는 건데, 생각보다 복잡했다.

function updateElement($parent, newNode, oldNode, index = 0) {
  // 노드 삭제
  if (!newNode && oldNode) {
    return $parent.removeChild($parent.childNodes[index]);
  }
  
  // 노드 추가
  if (newNode && !oldNode) {
    return $parent.appendChild(createElement(newNode));
  }
  
  // 텍스트 노드 변경
  if (typeof newNode === "string" || typeof oldNode === "string") {
    if (newNode !== oldNode) {
      const newEl = document.createTextNode(newNode);
      $parent.replaceChild(newEl, $parent.childNodes[index]);
    }
    return;
  }
  
  // 요소 타입 변경
  if (newNode.type !== oldNode.type) {
    const newEl = createElement(newNode);
    $parent.replaceChild(newEl, $parent.childNodes[index]);
    return;
  }
  
  // 속성 업데이트
  updateAttributes(
    $parent.childNodes[index],
    newNode.props || {},
    oldNode.props || {}
  );
  
  // 자식 요소 비교
  for (let i = 0; i < Math.max(newNode.children.length, oldNode.children.length); i++) {
    updateElement(
      $parent.childNodes[index],
      newNode.children[i],
      oldNode.children[i],
      i
    );
  }
}

이 과정에서 가장 어려웠던 건 리스트 렌더링이었다.
복잡한 트리 구조를 효율적으로 비교하는 알고리즘은 처음 구현해봤는데, 재귀적으로 두 트리를 탐색하면서 차이점을 찾아내는 과정이 흥미로웠다.

하지만 아래와 같은 리스트 순서가 바뀔때에는 key를 활용해서 비교를 해야하는데, 그 부분은 내 구현에는 없어서 너무 아쉬운 것 같다.

// 이런 리스트 순서가 바뀌면
<ul>
  <li key="a">항목 A</li>
  <li key="b">항목 B</li>
  <li key="c">항목 C</li>
</ul>

// 이렇게 바뀔 때
<ul>
  <li key="c">항목 C</li>
  <li key="a">항목 A</li>
  <li key="b">항목 B</li>
</ul>

React의 Diffing과 배치(Batching) 최적화

리액트는 Diffing 알고리즘을 더 효율적으로 만들기 위해 '배치(Batching)' 처리를 도입했다. 내 구현체에서는 상태가 변경될 때마다 즉시 Diffing 알고리즘을 실행하고 DOM을 업데이트하지만, 리액트는 더 스마트하게 처리한다.

배치란 무엇인가?

배치는 여러 상태 업데이트를 그룹화하여 단일 리렌더링으로 처리하는 최적화 기법이다.

function handleClick() {
  setCount(count + 1); // 첫 번째 상태 변경
  setFlag(true);       // 두 번째 상태 변경
  setName('홍길동');   // 세 번째 상태 변경
}

배치 처리 없이는 위 코드에서 3번의 Diffing과 DOM 업데이트가 발생하지만, 리액트는 이 모든 상태 변경을 모아서 한 번에 처리한다.

React 18부터는 '자동 배치(Automatic Batching)'가 도입되어 비동기 이벤트에서도 배치 처리가 적용된다:

// React 18에서는 이벤트 핸들러 외부에서도 배치 처리됨
fetch('/api').then(() => {
  setCount(count + 1);
  setFlag(true);
  // 하나의 리렌더링만 발생
});

상태 변경을 큐에 모아두었다가 한 번에 처리하는 방식으로 구현하는 건 어떨까?
리액트의 설계가 정말 정교하다는 걸 다시 한번 느낀다.

다른 사람들은 어떻게 구현했을까?

다른 사람들의 구현을 찾아보니 몇 가지 흥미로운 접근법들이 있었다.

  1. Preact: 리액트의 가벼운 대체제로, 가상돔 구현이 정말 효율적이다. 특히 diffing 알고리즘이 매우 최적화되어 있어서 참고할 만하다. (https://github.com/preactjs/preact)

  2. Vue.js의 가상돔 구현: Vue는 template을 컴파일해서 render 함수를 만드는 방식을 사용한다. 특히 Vue 3에서는 Proxy 기반의 반응형 시스템과 함께 가상돔을 최적화했다. (https://github.com/vuejs/core)

  3. vanilla-virtual-dom: 아주 심플한 가상돔 구현체로, 교육 목적으로 좋다. (https://github.com/Matt-Esch/virtual-dom)

  4. snabbdom: Vue 2에서 영감을 받은 가상돔 라이브러리로, 모듈화된 패치 함수와 훅 시스템이 특징이다. (https://github.com/snabbdom/snabbdom)

결론적으로, 버츄얼돔 직접 구현해보는 경험은 프론트엔드 개발자로서 정말 값진 경험이었다. 프레임워크의 내부 동작을 이해하는 게 얼마나 중요한지 새삼 깨달았다.

관련한 대단하신 분들의 인사이트

마치며: 가상 DOM 구현을 통해 배운 것들

이번 가상 DOM 구현 과제는 처음에는 막막했지만, 지금 돌아보면 정말 값진 경험이었다. 평소에 React나 Vue를 사용하면서 "가상 DOM이 있어서 빠르다"라고만 알고 있었지, 그 내부가 어떻게 동작하는지는 제대로 이해하지 못했었다.

직접 구현하면서 가장 크게 깨달은 점은 프레임워크가 얼마나 많은 최적화 작업을 자동으로 해주는지였다. 평탄화, 정규화, Diffing 알고리즘, 이벤트 위임, 배치 처리 같은 기능들이 사용자 모르게 숨겨져 있었다는 것이 놀라웠다.

특히 Diffing 알고리즘 구현이 가장 어려웠지만, 가장 재미있는 부분이기도 했다. 어떻게 하면 두 트리의 차이점을 효율적으로 찾아낼 수 있을지 고민하는 과정이 특히 재밌었다.

키(key)를 활용한 리스트 최적화나 React의 파이버(Fiber) 아키텍처 같은 고급 기능은 구현하지 못했다. 또한 리액트의 배치 처리 기능도 내 구현체에는 없어서, 상태 변경이 여러 번 일어날 때 성능이 최적화되지 않는다...



BP !

profile
고수는 못먹지만 개발고수는 되고싶다

2개의 댓글

comment-user-thumbnail
2025년 4월 7일

MCP 사용법 글 써주세용.

답글 달기
comment-user-thumbnail
2025년 4월 18일

깊게 고민한 흔적이 보이네요 저도 모르고 썻던 DocumentFragment 개념이 잘 정리 되었습니다

답글 달기