들어가기
리액트에서 가상 DOM을 활용한 효율적으로 DOM 관리하는 방법이 사용되고 있다. 평소에 가상 DOM에 대해서 관심있었고 어떻게 구현되는지 궁금했었다. 그러던중 한중일님의 글을 보고 직접 간단하게 구현해보고 정리해보았다. 여기서 만들려는 UI 라이브러리는 순수 브라우저에서 동작하는 라이브러리를 제작하는 목표를 가정하였다.
작성자: http://ospace.tistory.com/ (ospace114@empal.com)
DOM
DOM은 Document Object Model로 웹 문서를 구조화된 모델로 표현하였다. 웹 문서는 다양한 구성요소가 있고 이런 구성 요소를 Node라고 하며 웹 요소를 조작할 수 있는 인터페이스를 제공한다.
Node은 DOM에서 모델로 표현되는 구성 요소로 Document, Element, Text, Comment 등이 있다.
- Document: 문서로서 웹문서 전체에 표현하는 객체
- Text: 실제 데이터가 저장되는 텍스트 객체
- Element: 모든 엘리먼트 객체들(예, div, input, span, table)
- Attribute: 속성들
- Comment: 주석
이런 구성 요소가 DOM에서 구조화된 형태로 표현된다. 다르게 표현하면 DOM은 트리형태의 구조 전체를 의미하고 Node은 그 구조에서 사용되는 구성요소를 가리킨다. 최상위 노드로는 Document가 있고 많으로 Element가 트리형태로 연결된다. 그리고 Element 밑으로 Text나 Attribute가 구성된다.
가상 DOM
가상 DOM은 실제 DOM을 의미하는 객체가 아니라 메타 객체라고 보면 된다. 즉 DOM을 생성하기 위한 정보를 가진 객체이다. 가상 DOM을 사용해서 실제 DOM을 관리하게 된다. 예를 들어, 새로운 요소가 가상 DOM에 추가되었다면 실제 DOM을 생성하고 가상 DOM을 변경되거나 삭제된다면 실제 DOM도 변경하거나 삭제한다.
그런데 이런 복잡한 작업을 왜하는지 이해되지 않을 것이다. 웹 구성 요소는 다양하다. 이런 다양한 요소를 직접 조작한다면 관리하게 매우 힘들게 된다. UI 개발을 보았다면 이런 경험을 해보았을 것이다. 특히 UI가 비즈니스 로직을 제어하는 경우는 더 복잡해진다. 이런 문제를 해결하기 위해서 가상 DOM을 조작하면 알아서 실제 DOM을 관리해주고 리소스 관리도 알아서 해주면 더 단순하고 쉽게 개발할 수 있다.
가상 DOM 구현은 단순한 객체를 사용하면 된다. 물론 풍부한 기능을 포함한 설계를 할 수도 있지만, 여기서는 접근하기 쉽게하기위해 최대한 단순하게 구성하였다.
- type: DOM 유형으로 소문자 사용
- props: 속성들
- children: 자식 요소들
앞에서 Node 종류 중에 Element 유형을 사용한다. Document는 문서 전체로 이미 기본으로 사용되고 있고, Attribute와 Text는 Element에 포함되어 있기 때문에 별도로 처리하지 않았다. 한가지 유념할 부분은 Text는 Node은 별도 요소로서 실제 DOM에서 구분된 객체로 생성된다. 이 부분을 고려하기 위해 텍스트는 반드시 Element에 포함되도록 제한하였다. 물론 텍스트만 독립적으로 구성하고 싶다면 별도로 타입을 구분해서 설계하면 된다.
가상 DOM은 Object 객체로 아래 처럼 생성해서 정보를 관리하면 된다.
{ type: 'span', props: {class: 'my_label'}, "Message" }위의 가상 DOM은 타입이 span이고 속성으로 class가 my_label로 되어 있고 텍스트는 “Message”이다. 가상 DOM을 쉽게 생성하기 위한 헬퍼함수를 정의해보았다.
function h(type, props, ...children) {
return {type, props: props || {}, children: children.flat() };
}h()를 사용해서 앞의 가상 DOM을 생성하는 코드는 아래와 같게 된다.
h('span', {class: 'my_label'}, 'Message');좀 더 간단하게 표현되었다. h 함수명은 hyperscript의 약자로 하이퍼텍스트인 HTML을 생성하는 자바스크립트를 의미한다.
가상 DOM은 만들었으니 실제 DOM으로 반영해보자.
DOM 렌더링
DOM 렌더링은 가상 DOM을 실제 DOM에 반영하는 작업이다. 가상 DOM과 DOM을 동기화함으로써 가상 DOM의 변경사항이 DOM으로 즉시 반영된다. 주의사항은 DOM은 변경이 가상 DOM에 영향을 미치지 않는다. 가상 DOM과 실제 DOM의 루트 노드를 기준으로 순회하면서 서로 비교를 통해서 변경사항을 감지하여 반영해줘야 한다. 이 때 사용하는 알고리즘이 Diff 알고리즘이라고 한다. 알고리즘에서는 아래처럼 고려해야할 사항이 있다.
- 기존 DOM 삭제
- 가상 DOM은 없지만 실제 DOM이 있는 경우로 실제 DOM을 삭제
- 새로운 DOM 생성
- 가상 DOM은 있지만 실제 DOM은 없는 경우로 가상 DOM을 사용해서 실제 DOM을 생성
- 기존 DOM 교체
- 기존 DOM과 가상 DOM는 있지만 타입이 다른 객체로서 전혀 다른 DOM으로서 기존 DOM은 삭제되고 새로 생성된 DOM으로 교체
- 기존 DOM 변경
- 가상 DOM과 실제 DOM 타입이 동일한 상태
- 가상 DOM의 속성을 실제 DOM에 적용하는 경우로 속성 값이 다를 경우 갱신
이를 가상 DOM과 실제 DOM 객체 여부를 가지고 표형태로 정리하면 아래와 같다.
| 구분 | 가상 DOM 있음 | 가상 DOM 없음 |
|---|---|---|
| 실제 DOM 있음 | 타입이 다른 경우: 실제 DOM 교체 타입이 같은 경우: 속성 갱신 |
실제 DOM 삭제 |
| 실제 DOM 없음 | 실제 DOM 생성 | NA |
가상 DOM이 없는 경우는 단순하게 실제 DOM 여부에 따라서 삭제 처리하면 된다.
가상 DOM이 있을 경우가 조금 구분해서 처리해야 한다. 실제 DOM이 없다면 단순히 생성하면 되지만 실제 DOM이 있다면 동일한 형태의 DOM인지 판단하고 다르다면 새로운 실제 DOM을 생성해서 교체하고 같다면 단순히 속성만 변경하면 된다.
기존 DOM 삭제
기존 DOM 삭제를 먼저 다루었다. 이는 경우의 수가 단순하고 추가적인 처리가 없이 끝날 수 있어서 처음에 다루었다. 가상 DOM이 없어져서 더 이상 필요없는 DOM을 삭제한다.
function renderDom(parent, vdom, dom) {
if(!vdom) {
return dom && dom.remove();
}
}실제 DOM 삭제하는 방법은 단순하다. DOM 객체의 remove()사용해서 삭제한다.
새로운 DOM 생성
먼저 아래와 같이 가상 DOM과 실제 DOM을 입력 받아서 처리하는 함수를 정의했다.
function renderDom(parent, vdom, dom) {
// ...
if(!dom) {
parent.appendChild((dom = createDom(vdom)));
}
}이미 앞에서 vdom이 없는 경우는 처리했기 때문에 나머지는 vdom이 있다고 가정하고 처리하면 된다. 그래서 dom여부에 따라서 dom이 없으면 그냥 새로 만드면 된다. parent가 필요한 이유가 새로 생성되는 실제 돔을 추가하기 위해서이다. 실제 DOM에서 parent을 접근하면 될거이라고 생각할 수 있지만, 이 경우는 실제 DOM이 없기 때문에 부모 객체를 알 수가 없다. 생성된 DOM 객체는 dom에 저장하여 추후 속성을 갱신하거나 자식 노드 생성하는 추가적인 작업을 진행한다.
createDom()은 실제 DOM 객체를 생성하는 함수이다.
function createDom(vdom) {
return isObject(vdom)
? document.createElement(vdom.type)
: document.createTextNode(vdom);
}creaetDom()의 vdom이 객체인지 텍스트인지 확인한다. 객체이면 createElement()를 텍스트이면 createTextNode()를 사용해서 DOM 객체를 생성한다. 또한 별도로 DOM 객체에 속성을 적용하지 않았다. 여기서는 DOM 객체 생성에만 집중하였다.
기존 DOM 교체
가상 DOM과 실제 DOM이 존재하는 경우이다. 실제 DOM의 타입이 가상 DOM과 다른 상황으로 DOM 자체가 변경되었다. 그럼 기존 객체는 삭제되고 새로운 타입의 DOM으로 변경되어야 한다. 이런 경우는 가상 DOM으로 기존 실제 DOM을 삭제하고 실제 DOM을 생성해서 저장하면 된다. 이런 작업을 처리할 수 있는 replaceWith()을 사용해서 기존 실제 DOM 위치에 생성된 DOM 객체를 교체하였다.
function renderDom(parent, vdom, dom) {
//...
if(!dom) {
// ...
} else {
if (isObject(vdom) ? vdom.type : "#text" !== dom.nodeName.toLowerCase()) {
const oldDom = dom;
oldDom.replaceWith((dom = createDom(vdom)));
}
}
}생성된 DOM 객체는 dom에 저장하여 추후 속성을 갱신하거나 자식 노드 생성하는 추가적인 작업을 진행한다.
기존 DOM 변경
기존 DOM 변경은 DOM 자체는 동일한 타입이지만 속성이 다른 경우이다. 또한 이전에 새로 생성된 실제 DOM 객체가 아직 속성이 적용되지 않았기 때문에 신규 속성을 적용하는 역활도 있다. 이미 이전에 모든 경우에 대해서 처리되었기 때문에 속성 변경만 남았다. 그래서 별도 조건없이 updateAttributes()를 호출한다.
function renderDom(parent, vdom, dom) {
//...
updateAttributes(vdom, dom);
}updateAttributes()는 가상 DOM에 속성과 실제 DOM 속성을 비교해서 다르면 속성 값을 반영하는 함수이다. 속성 반영하는 작업은 기존 속성을 갱신하거나 사라진 속성는 삭제해줘야 한다. 별도로 속성을 생성하지 않는 이유는 갱신하는 과정에서 처리할 수 있기 때문이다.
function updateAttributes(vdom, dom) {
if (isObject(vdom)) {
// Element 노드인 경우
const props = vdom.props;
// 속성의 값이 다르면 값을 반영
Object.keys(props).forEach((k) => {
const newVal = props[k];
const oldVal = dom.getAttribute(k);
if (oldVal === newVal) return;
if (k.startsWith("on") || k === "value") {
dom[k.toLowerCase()] = newVal;
} else {
dom.setAttribute(k, newVal);
}
});
// dom의 속성이 vdom에 없다면 삭제
dom.getAttributeNames().forEach((k) => {
if (k in props) return;
dom.removeAttribute(k);
});
} else {
// Text 노드인 경우 속성이 nodeValue만 있음
if (vdom != dom.nodeValue) {
dom.nodeValue = vdom;
}
}
}코드 자체는 복잡하지 않기 때문에 설명을 주석으로 간단하게 추가했다.
가상 DOM 순회
마지막으로 가상 DOM을 순회하면서 빠짐없이 모든 객체에 대해 확인하는 과정이 남아 있다. 순회할 때는 텍스트 노드가 아닌 children을 가진 객체 노드만 가능하다. 가상 DOM에서 children인 자식 객체들과 실제 DOM에서 childNodes에 있는 자식 객체에 대한 순회로직이다. render()에서 코드 순회하는 로직을 작성하였고 순서대로 함수를 재귀 호출하였고, DOM 생성은 renderDom()을 호출하였다.
function render(parent, ...vdoms) {
vdoms = vdoms.flat();
const n = Math.max(vdoms.length, parent.childNodes.length);
for (let i = 0; i < n; ++i) {
const vdom = vdoms[i];
renderDom(parent, vdom, parent.childNodes[i]);
if (isObject(vdom)) render(parent.childNodes[i], vdom.children);
}
}renderDom()의 전체 코드
function renderDom(parent, vdom, dom = null) {
if (!vdom) {
return dom && dom.remove();
}
if (!dom) {
parent.appendChild((dom = createDom(vdom)));
} else {
if ((isObject(vdom) ? vdom.type : "#text") !== dom.nodeName.toLowerCase()) {
const oldDom = dom;
oldDom.replaceWith((dom = createDom(vdom)));
}
}
updateAttributes(vdom, dom);
}DOM 렌더링 예제
실제로 적용하는 예제를 보자. 여기서 다룰 예제는 간단한 메시지 목록 형태의 테이블이다. HTML 코드는 다음과 같다.
<table id="msgList" class="table table-striped">
<colgroup>
<col width="10%" />
<col width="90%" />
</colgroup>
<thead>
<tr>
<th>#</th>
<th>Message</th>
</tr>
</thead>
<tbody></tbody>
</table>별거 없는 단순히 메시지를 테이블 형태로 나열하는 페이지이다. 앞에서 작성했던 가상 DOM API를 적용해보자. 먼저 가상 DOM을 정의해보자.
function TableData(data) {
return data.map((it, i) =>
h(
"tr",
null,
h("td", null, i),
h("td", null, it),
h(
"td",
null,
h(
"button",
{
class: "btn btn-danger",
onClick() {
data.splice(i, 1);
renderDemo();
}
},
"DEL"
)
)
)
);
}TableData()는 데이터 목록에서 각 항목을 가져와서 h()을 사용해서 가상 DOM을 생성해서 리턴한다. 이를 사용해서 실제 렌더링하는 코드를 보자.
let data = ["foo", "bar"];
function renderDemo() {
const root = document.querySelector("#msgList tbody");
render(root, TableData(data), null);
}
render();renderDom() 호출시 테이블의 tbody에 해당하는 객체를 root로 두고 하위 트리에 가상 DOM에 의해 생성된 DOM 객체가 추가된다. 마지막 인자가 null인 경우 현재 생성된 DOM이 없기 때문이다. 가상 DOM에 의해서 모두 신규 생성된다. 아래는 실행했을 때의 화면이다.

데모
https://codepen.io/ospace/pen/ogLBgKz
마무리
단순하게 가상 DOM을 활용해서 화면을 생성하는 방법을 알아보았다. 한가지 단점은 h()를 사용해서 가상 DOM을 생성하기에 익숙한 HTML이 아닌 형태로 코드 해석이 쉽지 않다. 그렇기 때문에 리액터에서는 JSX(JavaScript XML)을 사용해서 직관적인 HTML 형태로 가상 DOM을 생성할 수 있다. 이 부분도 node.js의 바벨(Bable) 컴파일러라는 트랜스파일러를 통해 JSX를 표준 자바스크립트로 변환할 수 있다. 만약 node.js를 사용하는 환경이라면 앞의 가상 DOM 생성하는 코드를 JSX를 사용해서 처리할 수 있다. 그러나, 여기서는 그 부분까지 다루기는 너무 깊게 들어간듯 해서 가상 DOM에 집중하였다.
이글에서 가상 DOM은 단순한 POJO을 사용해서 표현하였다. 이는 HTML 표현이 코드화되어 가독성이 좋지 않아 JSX가 아닌 HTML로 표현할 수 있는 방법을 생각해보았다.
- 직접 HTML로 작성하고 root 노드를 지정하면 자식 노드를 가상 DOM으로 변환해서 사용하는 방법
- 추후에 컴포넌트로 활용하기 쉽지 않고 다양한 기능을 표현하기 어려움
- HTML에 지원하는 표현방식만 가능, 특정 기능은 문자열로 표기하고 추가적인 파싱 작업 필요
- HTML를 문자열로 작성해서 파싱해서 가상 DOM 생성
- HTML 파서로 인해 배보다 배꼽이 커짐
- 제한된 범위에서 HTML 표현법을 사용해서 파서 작성이 어려움
- 템플릿 문자열과 같이 결합도 가능
- HTML를 문자열로 작성해 브라우저의 XML 파서를 통해서 가상 DOM 생성
- 앞에 별도 HTML 파서는 불필요로 구현이 쉬워짐
- HTML 표현에 맞춰서 작성이 필요해 다양한 표현이 어려움
- 다양한 기능을 위해 문자열 표현 확장으로 추가 파서 및 동적인 HTML 표현이 어려움
그외에 vue.js와 같은 표현법을 고려할 수도 있지만 여기서는 고려하지는 않았다. 결국 직접 가상 DOM을 생성하는게 직관적이지 않지만 구현이 단순해진다. 그래서 다른 형태로의 확장은 고려하지 않았다.
추후에 useState()나 useReducer()에 다룰려고 한다. 부족한 글이지만 여러분에게 도움이 되었으면 하네요. 모두 즐프하세요. ospace.
참조
[1] 노드(Node), https://developer.mozilla.org/en-US/docs/Web/API/Node
[2] 황준일, Vanilla Javascript로 가상돔(VirtualDOM) 만들기, https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Virtual-DOM/#_3-virtualdom-→-realdom
[3] 곽봉칠, 가상돔을 직접 만들어보며, https://velog.io/@meowoof/가상돔-만들기
'3.구현 > HTML5&Javascript' 카테고리의 다른 글
| 직접 캐러셀 스크롤(Carousel Scroll) 구현 (0) | 2026.01.12 |
|---|---|
| 간단한 캐러셀 스크롤(Carousel Scroll) 만들기 (0) | 2025.11.04 |
| [CSS] Flex 사용하기 (2) | 2024.04.02 |
| [javascript] 이미지 동적 로딩 (0) | 2024.03.21 |
| [javascript] VR Panoramic 360 video player 사용 (0) | 2024.03.20 |
