
지금까지 진행한 프로젝트는 모두 React를 사용했다고 해도 과언이 아니라고 생각합니다... 그만큼 React를 자주 사용했지만, 정작 React가 무슨 역할을 하는지, 그 안에서는 어떻게 돌아가는지에 대해서 설명해보라고 하면 동공 지진이 났었어요.

그래서 이번 기회에 리액트의 구조에 대해서 깊게 공부해보기로 마음을 먹었어요.
그 처음은 바로 VDOM!!
리액트 개념을 배울때 빠지지 않는 친구예요.
저는 이전까지 VDOM에 대해 딱 이정도로만 알고있었어요.
상태가 변경되면 새로운 VDOM을 만들고, 이전 VDOM과 비교해 변경된 부분만 실제 DOM에 반영한다!
이런 단순한 개념 자체는 알고 있었지만, 실제로 VDOM이 어떻게 구성되어있고, 변경된 부분은 어떻게 비교하는지에 대해 알지 못했어요.
- 새로운 노드가 추가되거나 기존 노드가 사라진 것은 어떻게 판단할까?
- 속성과 이벤트는 어떻게 갱신할까?
- 비교에 필요한 이전 VDOM은 어디에 저장할까?
그래서 이번에는 Vanilla JavaScript로 VDOM을 직접 구현하면서, 그동안 추상적으로만 알고 있던 VDOM의 동작 원리를 더 깊이 이해해보기로 했어요.
구현에 들어가기 전에 VDOM이 무엇이고, 왜 사용하는지부터 짚고 넘어가 봤어요.

실제 DOM을 변경하면 브라우저는 변경된 내용을 화면에 반영하기 위해 layout, paint, composite 등의 렌더링 과정을 수행할 수 있어요.
따라서 복잡한 UI에서 DOM을 직접 여러 번 수정하면, 어떤 부분을 언제 변경해야 하는지 관리하기 어려워지고 불필요한 렌더링 작업이 발생할 수도 있어요.
그래서 VDOM은 이렇게 동작해요
- 화면의 UI 구조를 JavaScript 객체 형태로 표현
- 상태가 변경되면 새로운 VDOM을 만들기
- 이전 VDOM과 비교하여 변경된 부분을 찾기 (diffing)
- 이후 실제 DOM에는 필요한 변경 사항만 반영 (commit)
하지만 항상 VDOM이 빠른건 아니에요.
위에서 얘기한 diffing 과정도 비용이기 때문에 작은 프로젝트에서는 innerHTML 이나 직접 DOM을 조작하는 방식이 더 단순하고 빠를 수도 있다고 해요.
VDOM의 진짜 장점은 성능보다도 복잡한 UI를 상태 기반으로 관리하기 쉬워진다는 점에 있어요.
VDOM을 사용하면 개발자가 실제 DOM을 어떻게 수정할지 일일이 작성하는 대신, 현재 상태에서 UI가 어떤 모습이어야 하는지를 선언적으로 표현할 수 있어요.
// DOM을 직접 수정하는 방식
const likeButton = document.querySelector(".like-button");
const likeCount = document.querySelector(".like-count");
likeButton.dataset.liked = String(isLiked);
likeCount.textContent = currentLikeCount;
// React에서는 상태에 따라 UI의 결과를 선언하고
// DOM의 어떤 속성과 텍스트를 수정할지는 React가 판단해요.
<LikeButton
isLiked={isLiked}
likeCount={currentLikeCount}
/>
이번에는 이 과정을 vanilla js 프로젝트에서 직접 구현해보면서 VDOM의 동작 방식을 깊게 공부해봤어요.
처음부터 페이지 전체에 VDOM을 적용하면 어떤 부분에서 문제가 발생했는지 확인하기 어려울 것 같아서 상태 변화가 단순하고 변경 범위가 작은 좋아요 버튼부터 적용해보기로 했어요.

좋아요 버튼에서는 사용자의 클릭에 따라 다음 값이 달라져요.
- 좋아요 여부에 따라
data-liked속성이 변경- 좋아요 개수에 따라 버튼 내부의 숫자 텍스트가 변경
속성, 텍스트, 이벤트 변경을 모두 확인할 수 있어서 VDOM의 기본 동작을 테스트하기에 적합하다고 생각했어요.
기존에는 좋아요 버튼을 HTML에 직접 작성하고 있었어요.
<button
id="like-button"
class="counter like-button"
type="button"
data-liked="false"
>
<span class="counter__label">좋아요</span>
<span id="like-count"></span>
</button>
VDOM을 적용하기 위해 기존 버튼을 제거하고, VNode가 렌더링될 루트 요소만 남겼어요.
<div id="like-button-root"></div>
이제 좋아요 버튼의 구조를 JavaScript 객체로 표현하고, 그 객체를 실제 DOM으로 변환해야 해요.
h() 함수 먼저 VNode를 생성하는 h() 함수를 만들었어요.
export function h(type, props, ...children) {
return {
type,
props,
children: children.flat(), //자식이 배열 형태로 전달되더라도 한 단계 평탄화해 같은 구조로 다루기 위해
};
}
h() 함수가 반환하는 VNode는 세 가지 정보를 가져요.
type: 생성할 DOM 요소의 태그props: 요소에 적용할 속성과 이벤트children: 요소가 포함할 자식h() 함수로 LikeButton 컴포넌트 만들기 이제 좋아요버튼의 상태를 바탕으로 VNode를 반환하는 LikeButton()을 만들었어요.
import { h } from "../lib/vdom.js";
export function LikeButton(
isLiked,
likeCount,
onClick
) {
return h(
"button",
{
id: "like-button",
class: "counter like-button",
type: "button",
"data-liked": String(isLiked),
onClick,
},
h(
"span",
{
class: "counter__label",
},
"좋아요"
),
h(
"span",
{
id: "like-count",
},
likeCount
)
);
}
이렇게 만들어진 LikeButton()을 호출하면 실제 DOM이 아니라 다음과 같은 JavaScript 객체가 만들어져요.
{
type: "button",
props: {
id: "like-button",
class: "counter like-button",
type: "button",
"data-liked": "false",
onClick: handleLike
},
children: [
{
type: "span",
props: {
class: "counter__label"
},
children: ["좋아요"]
},
{
type: "span",
props: {
id: "like-count"
},
children: [0]
}
]
}
이 객체가 이번 구현에서 사용하는 VNode예요.
간단한 버튼 하나도 VNode로 나타내면 가독성이 안좋기때문에 React에서는 같은 구조를 JSX로 작성할 수 있게 했어요.
<button
id="like-button"
className="counter like-button"
type="button"
data-liked={isLiked}
onClick={onClick}
>
<span className="counter__label">
좋아요
</span>
<span id="like-count">
{likeCount}
</span>
</button>
#### JS 공식 문법이 아닌데도 브라우저는 어떻게 jsx를 읽을 수 있는 걸까요?
브라우저는 JSX를 직접 실행하지 못하기 때문에 빌드 과정에서 JavaScript 코드로 변환해요
변환된 코드를 실행하면 UI 구조를 표현하는 React element 객체가 만들어지는거죠~.~
LikeButton()이 반환한 값은 JavaScript 객체이기 때문에 그대로 화면에 표시할 수 없어요.
화면에 표시할 수 있도록 VNode를 실제 DOM으로 변환하는 createElement() 함수를 만들었어요.
export function createElement(vNode) {
// 문자열과 숫자는 텍스트노드로 처리
if (typeof vNode === "string" ||typeof vNode === "number") {
return document.createTextNode(String(vNode));
}
// VNode 객체는 type을 이용해 실제 요소로 만들기
const element = document.createElement(vNode.type);
// Props를 실제 DOM 속성으로 적용
Object.entries(vNode.props ?? {}).forEach(([name, value]) => {
element.setAttribute(name, value);
}
);
// 자식 VNode에도 createElement()를 재귀적으로 호출
vNode.children.forEach((child) => {
element.appendChild(createElement(child));
});
return element;
}
마지막으로 생성한 DOM을 루트 요소에 추가하는 render() 함수를 만들었어요.
// vdom.js
export function render(vNode, container) {
container.appendChild(
createElement(vNode)
);
}
이제 LikeButton()으로 만든 VNode를 실제 화면에 표시할 수 있어요.
// postDetail.js
const likeButtonRoot =
document.getElementById(
"like-button-root"
);
render(
LikeButton(
isLiked,
currentLikeCount,
handleLike
),
likeButtonRoot
);
좋아요 버튼은 화면에 정상적으로 표시됐지만 버튼을 클릭해도 아무 일도 일어나지 않았어요.
왜냐하면 모든 Props를 setAttribute()로 처리하고 있었기 때문이에요.
element.setAttribute(name, value);
이벤트가 동작하려면 이 함수를 실제 DOM의 이벤트 프로퍼티에 연결해야 해요.
속성 이름이 on으로 시작하고 값이 함수라면 이벤트 핸들러로 판단하도록 코드를 변경했어요.
// vdom.js - createElement()
Object.entries(vNode.props ?? {}).forEach(([name, value]) => {
if(name.startsWith('on') && typeof value === "function"){
element[name.toLowerCase()] = value;
return;
}
element.setAttribute(name, value);
});
VNode의 onClick은 소문자로 변환되어 실제 DOM의 onclick 프로퍼티에 연결돼요.
element.onclick = value;
이제 클릭 이벤트는 정상적으로 동작해요.
하지만 상태가 바뀐 뒤 화면에 새로운 값을 반영하려면 좋아요 버튼 전체를 다시 만들어야 했어요. 이렇게 되면 상태가 바뀔때마다 매번 좋아요 버튼을 새로 만들어서 비효율적이에요.
그래서 다음으로는 Diff 과정을 구현하고, 적용해봤어요
상태가 변경될 때마다 좋아요 버튼 전체를 교체하는 대신, 이전 VNode와 새로운 VNode를 비교해 변경된 부분만 실제 DOM에 반영해보기로 했어요.
diff 구현을 위해 노드 비교를 담당하는 updateElement() 함수를 만들었어요.
먼저 두 VNode를 비교하면서 발생할 수 있는 상황을 나눠봤어요.
이전 VNode에는 있지만 새로운 VNode에는 없다면 실제 DOM에서도 제거해요.
if (!newNode && oldNode) {
return parent.removeChild(
parent.childNodes[index]
);
}
새로운 VNode에만 노드가 존재한다면 실제 DOM으로 변환해 추가해요.
if (newNode && !oldNode) {
return parent.appendChild(
createElement(newNode)
);
}
두 노드가 문자열이라면 값을 비교하고, 값이 달라진 경우에만 텍스트 노드를 교체해요.
if (
typeof newNode === "string" &&
typeof oldNode === "string"
) {
if (newNode === oldNode) {
return;
}
return parent.replaceChild(
createElement(newNode),
parent.childNodes[index]
);
}
한쪽이 텍스트이고 다른 쪽이 VNode 객체라면 기존 DOM을 버리고 새로 만들어요
if (
typeof newNode !== typeof oldNode
) {
return parent.replaceChild(
createElement(newNode),
parent.childNodes[index]
);
}
두 노드가 같은 종류라면 기존 DOM을 유지하고 속성을 비교했어요.
updateAttributes(
parent.childNodes[index],
newNode.props ?? {},
oldNode.props ?? {}
);
function updateAttributes(
target,
newProps,
oldProps
) {
for (
const [attribute, value]
of Object.entries(newProps)
) {
if (
oldProps[attribute] === value
) {
continue;
}
target.setAttribute(
attribute,
value
);
}
}
현재 노드의 비교가 끝나면 자식 노드에도 같은 비교 과정을 재귀적으로 적용했어요.
const maxLength = Math.max(
newNode.children.length,
oldNode.children.length
);
for (let i = 0; i < maxLength; i++) {
updateElement(
parent.childNodes[index],
newNode.children[i],
oldNode.children[i]
);
}
기본적인 Diff를 구현한 뒤 좋아요 버튼에 적용해봤어요.
// postDetail.js
function updateLikeButtonState() {
const oldState = {
isLiked: !isLiked,
currentLikeCount: isLiked
? currentLikeCount - 1
: currentLikeCount + 1,
};
const oldNode = LikeButton(
oldState.isLiked,
oldState.currentLikeCount,
handleLike
);
const newNode = LikeButton(
isLiked,
currentLikeCount,
handleLike
);
updateElement(
likeButtonRoot,
newNode,
oldNode
);
}
그런데 Diff를 적용하자 새로운 문제들이 나타나기 시작했어요.
render()로 버튼 전체를 생성했을 때는 숫자가 정상적인 위치에 표시됐어요.
하지만 updateElement()를 적용하자 버튼을 클릭하면 좋아요 숫자가 엉뚱한 위치에 나타났어요.
자식 노드의 Diff 과정을 확인해보니 현재 자식의 인덱스를 재귀 호출에 전달하지 않고 있었어요.
updateElement(
parent.childNodes[index],
newNode.children[i],
oldNode.children[i]
);
updateElement()의 index 기본값은 0이었어요. 자식의 인덱스를 전달하지 않으니 모든 자식 비교가 첫 번째 DOM 노드를 대상으로 수행되고 있었어요.
현재 자식의 인덱스인 i를 전달하도록 수정했어요.
updateElement(
parent.childNodes[index],
newNode.children[i],
oldNode.children[i],
i
);
또한 텍스트 노드 비교에서는 문자열만 처리하고 있었어요.
typeof newNode === "string"
하지만 좋아요 개수는 숫자이므로 숫자도 텍스트 노드와 같은 방식으로 처리해야 했어요.
const isTextNode = (node) =>
typeof node === "string" ||
typeof node === "number";
두 문제를 수정하니 좋아요 버튼을 클릭해도 숫자가 정상적인 위치에 표시됐어요.
그런데 좋아요 개수가 0이면 숫자가 화면에 나타나지 않았어요.
원인은 노드의 존재 여부를 판단하는 조건이었어요.
if (!newNode && oldNode) {
// 기존 노드를 제거해요.
}
JavaScript에서 숫자 0은 falsy 값이에요. 값이 존재하는 텍스트 노드인데도 새로운 노드가 없다고 판단하고 있었어요.
노드가 없다는 의미를 0이 아니라 null 또는 undefined로 한정해야 했어요.
if (newNode == null && oldNode != null) {
return parent.removeChild(parent.childNodes[index]);
}
if (newNode != null &&oldNode == null) {
return parent.appendChild(createElement(newNode));
}
조건을 수정한 뒤에는 좋아요 개수가 0일 때도 정상적으로 표시됐어요.
좋아요 버튼은 정상적으로 동작하기 시작했지만, 현재 상태의 반대 값을 이용해 이전 VNode를 만드는 방식이 마음에 걸렸어요.
const oldState = {
isLiked: !isLiked,
currentLikeCount: isLiked
? currentLikeCount - 1
: currentLikeCount + 1,
};
이 값은 실제로 이전 렌더링에 사용했던 상태가 아니에요. 현재 상태를 기준으로 이전 값을 추측한 것에 불과해요.
초기 상태가 예상과 다르거나 서버에서 다른 값이 응답되면 실제 DOM과 비교에 사용하는 oldNode가 일치하지 않을 수 있어요.
Diff가 올바르게 동작하려면 이전 VNode도 실제 렌더링 결과와 일치해야 해요.
그래서 현재 화면에 반영된 VNode를 별도로 저장하기로 했어요.
let currentLikeNode;
상태가 변경되면 새로운 VNode를 생성하고, 저장해둔 currentLikeNode와 비교했어요.
function updateLikeButtonState() {
const newNode = LikeButton(
isLiked,
currentLikeCount,
handleLike
);
updateElement(likeButtonRoot,newNode,currentLikeNode);
currentLikeNode = newNode;
}
이 구조를 만들면서 React에서 상태가 변경되면 새로운 렌더링 결과를 만들고, 이전 결과와 비교한다는 흐름을 조금 더 구체적으로 이해할 수 있었어요.
좋아요 버튼을 통해 VNode 생성, 실제 DOM 변환, 이벤트 연결, 상태 변경과 Diff까지 확인했어요!

하지만 좋아요 버튼은 자식의 개수와 구조가 고정된 UI예요. 노드가 여러 개 추가되거나 삭제되고, 순서가 바뀌는 더 복잡한 상황까지 확인하기에는 부족하다는 생각이 들었어요.
그래서 다음 글에는 게시글 목록에 Diff를 적용해보고, 코드를 수정해나가는 과정에 대해 작성해보려 해요.
다음 글을 기대해주세요!! 😘
오 상호작용 신기합니다 😦