이 글의 목적은 Vanilla JavaScript(TypeScript)로 가상 DOM의 기본 구조를 직접 구현해 보며, diffing 과 patching 이 어떤 방식으로 동작하는지 학습하는 것이다.
가상 DOM은 React 같은 프레임워크를 사용할 때 자주 접하게 되는 개념이지만, 실제로 어떤 기준으로 노드를 비교하고 어떤 방식으로 실제 DOM을 수정하는지는 추상적으로 느껴질 때가 많다.
이번 글에서는 간단한 가상 DOM 구조를 직접 구현하면서, 변경된 부분만 실제 DOM에 반영하는 흐름을 단계별로 정리해 보았다.
또한 이번 구현에서는 AI를 단순히 정답 코드를 생성하는 도구로 사용하지 않고, 교육 방식으로 질문과 힌트를 주고받으면서 구현을 진행했다.
가상 DOM은 실제 브라우저 DOM을 직접 조작하기 전에, 메모리 상에서 UI 구조를 객체 형태로 표현한 것이다.
이전 상태의 가상 DOM과 새로운 상태의 가상 DOM을 비교한 뒤, 변경된 부분만 실제 DOM에 반영하는 방식으로 동작한다.
작동 원리
1.가상 노드 생성: 화면 구조를 실제 DOM이 아닌 JavaScript 객체(VNode) 형태로 표현한다.
2.변경점 비교: 이전 VNode와 새로운 VNode를 비교하여 어떤 부분이 달라졌는지 확인한다.
3.실제 DOM 반영: 비교 결과를 바탕으로 추가, 삭제, 교체가 필요한 부분만 실제 DOM에 반영한다.
이번 구현에서는 복잡한 최적화보다, 무엇이 바뀌었는지 판단하고, 그 결과를 실제 DOM에 반영하는 기본 흐름을 이해하는 데 초점을 맞췄다.
가상 DOM 구현에서 먼저 필요한 것은 이전 노드와 새로운 노드가 서로 다른지 판단하는 기준이다. 처음에는 type, props, children을 모두 한 번에 비교해야 한다고 생각했지만, 실제로는 현재 노드를 통째로 교체해야 하는지 먼저 판단하는 것이 더 중요했다.
이번 구현에서는 isChanged 함수를 통해 현재 노드를 통째로 교체해야 하는지 우선 판단하도록 구성했다.
function isChanged(node1: VNodeChild, node2: VNodeChild) {
if (typeof node1 !== typeof node2) return true;
if (typeof node1 === 'string' || typeof node1 === 'number') {
return node1 !== node2;
}
if (
typeof node1 === 'object' && node1 !== null &&
typeof node2 === 'object' && node2 !== null
) {
return node1.type !== node2.type;
}
return false;
}
이 함수에서는 다음과 같은 경우를 변경으로 판단한다. 모든 차이를 세밀하게 비교하기보다는 현재 노드를 교체해야 하는지 판단하는 최소 기준에 가깝다.
type이 다른 경우노드가 변경되었는지 판단했다면, 그 다음에는 그 결과를 실제 DOM에 반영해야 한다. 이 역할을 담당하는 함수가 updateDOM이다.
function updateDOM(
$parent: Node,
newNode?: VNodeChild,
oldNode?: VNodeChild,
index = 0
) {
const $child = $parent.childNodes[index];
if (!oldNode && newNode) {
$parent.appendChild(createDOM(newNode));
return;
}
if (oldNode && !newNode) {
if ($child) $parent.removeChild($child);
return;
}
if (newNode && oldNode && isChanged(newNode, oldNode)) {
if ($child) $parent.replaceChild(createDOM(newNode), $child);
return;
}
if (
typeof newNode === 'object' && newNode !== null &&
typeof oldNode === 'object' && oldNode !== null
) {
updateProps($child as HTMLElement, newNode.props, oldNode.props);
const maxLength = Math.max(
newNode.children.length,
oldNode.children.length
);
for (let i = 0; i < maxLength; i++) {
updateDOM($child, newNode.children[i], oldNode.children[i], i);
}
}
}
이 함수는 크게 네 가지 경우를 처리한다.
추가: 이전 노드는 없고 새 노드만 있는 경우삭제: 새 노드는 없고 이전 노드만 있는 경우교체: 두 노드가 다르다고 판단된 경우재귀 비교: 껍데기(type)는 같고 내부만 비교해야 하는 경우이 과정을 통해 전체 DOM을 새로 그리는 대신, 필요한 DOM 조작만 선택적으로 수행할 수 있다.
가상 DOM을 실제 브라우저에 반영하려면, VNode를 실제 DOM 노드로 변환하는 과정도 필요하다. 이 과정은 createDOM 함수에서 처리했다.
function createDOM(node: VNodeChild): Node {
if (typeof node === 'string' || typeof node === 'number') {
return document.createTextNode(String(node));
}
const $el = document.createElement(node.type);
updateProps($el, node.props);
node.children.forEach((child) => {
$el.appendChild(createDOM(child));
});
return $el;
}
보통 AI에게 구현을 요청하면 빠르게 결과 코드를 얻을 수 있다. 하지만 이번에는 그보다 직접 이해하며 구현하는 과정에 더 초점을 두고 싶었다.
실제로 대화 중 내가 교육 모드를 상기시키자, Gemini는 바로 구현해 버리는 대신 챌린지를 통해 단계별로 학습하며 직접 구현할 수 있도록 가이드하겠다고 방향을 전환했다.
이후 첫 번째 단계로는 isChanged를, 다음 단계로는 updateDOM을 중심으로 질문을 던지며 구현을 이끌어갔다.
isChanged 구현할 때 받은 질문
이 질문 덕분에 isChanged는 모든 차이를 계산하는 함수가 아니라, 현재 노드를 통째로 교체해야 하는지만 판단하는 함수라는 점을 정리할 수 있었다.
updateDOM 파트에서 Gemini의 질문
<div>일 때, updateDOM은 어떤 명령을 내려야 할까요?”<div>이고 newVNode는 undefined일 때는요?”isChanged(newVNode, oldVNode)가 true라면요?”이 질문들을 통해 updateDOM이 결국 추가, 삭제, 교체, 재귀 비교라는 네 가지 흐름으로 정리된다는 점을 이해할 수 있었다.
구현이 제대로 동작하는지 확인하기 위해, 상태 변경 시 실제로 어떤 부분이 다시 렌더링되는지도 함께 확인했다.
기존처럼 전체를 다시 그리는 방식이 아니라, 변경된 부분만 실제 DOM에 반영되는지 확인하는 것이 목적이었다. 결과적으로 상태를 바꿨을 때 전체가 아니라 필요한 노드만 갱신되는 것을 볼 수 있었다.


첫 번째 움짤은 가상 DOM을 적용하기 전 상태로, 상태가 변경될 때 전체가 다시 렌더링되는 모습을 보여준다.
두 번째 움짤은 가상 DOM을 적용한 뒤의 결과로, 변경된 부분만 선택적으로 갱신되는 것을 확인할 수 있다.
isChanged와 updateDOM처럼 비교와 반영의 책임을 나누는 방식이 더 중요하다는 점을 배웠다.이번 구현은 완성도 높은 엔진을 만드는 데 목적이 있었던 것은 아니다. 다만 작은 구조라도 직접 만들어보면서, 가상 DOM이 내부적으로 어떤 원리로 동작하는지 이전보다 훨씬 명확하게 이해할 수 있었다는 점에서 의미가 있었다.