Virtual DOM 자료구조 설계
브라우저의 DOM은 느리다. 정확히 말하면 DOM 자체가 느린 게 아니라, DOM을 변경할 때 발생하는 reflow와 repaint가 비용이 크다. 버튼 하나를 클릭했을 뿐인데 화면 전체를 다시 그려야 한다면, 그건 명백한 낭비다.
React가 등장하기 전에도 이 문제를 해결하려는 시도는 있었다. jQuery 시절에는 개발자가 직접 "어떤 DOM 노드를 어떻게 변경할지"를 일일이 지정했다. $('#title').text('새 제목') 같은 코드가 전형적인 예시다. 변경 범위가 명확하니 성능은 괜찮았지만, 애플리케이션이 커지면 "어디를 바꿔야 하는지" 추적하는 것 자체가 악몽이 됐다.
Virtual DOM은 이 딜레마에 대한 하나의 해법이다. 개발자는 "현재 상태에서 UI가 어떻게 보여야 하는지"만 선언하고, 실제로 어떤 DOM을 바꿔야 하는지는 Virtual DOM이 알아서 계산한다. 선언적 프로그래밍의 편리함과 최소한의 DOM 조작을 동시에 달성하는 것이 핵심 아이디어다.
Virtual DOM이란
Virtual DOM은 실제 DOM 트리를 자바스크립트 객체로 표현한 것이다. HTML의 모든 노드를 일대일로 대응하는 가벼운 객체 트리를 메모리에 만들어두고, 상태가 변경될 때마다 새로운 트리를 생성한 뒤, 이전 트리와 비교(diff)해서 실제로 바뀐 부분만 브라우저 DOM에 반영(patch)한다.
핵심 흐름을 정리하면 이렇다:
- 상태가 변경된다
- 새로운 Virtual DOM 트리를 생성한다
- 이전 트리와 새 트리를 비교한다 (diffing)
- 차이가 있는 부분만 실제 DOM에 반영한다 (patching)
여기서 "자바스크립트 객체로 표현한다"는 게 핵심이다. 자바스크립트 객체의 생성과 비교는 실제 DOM 조작에 비해 훨씬 빠르다. DOM 노드 하나를 생성하면 브라우저는 수십 개의 내부 속성을 초기화해야 하지만, 자바스크립트 객체는 필요한 속성만 가진 가벼운 구조체에 불과하다.
VNode 자료구조 설계
Virtual DOM의 각 노드를 보통 VNode(Virtual Node)라고 부른다. 가장 기본적인 VNode 구조는 세 가지 정보만 있으면 된다.
interface VNode {
type: string;
props: { [key: string]: any };
children: (VNode | string)[];
}
- type: 어떤 종류의 요소인지.
"div","span","button"같은 HTML 태그명이 들어간다. - props: 해당 요소의 속성들.
className,id, 이벤트 핸들러 등. - children: 자식 노드들의 배열. VNode일 수도 있고, 텍스트 노드를 표현하는 문자열일 수도 있다.
예를 들어, 이런 HTML을:
<div class="container">
<h1>제목</h1>
<p>본문입니다</p>
</div>
VNode로 표현하면 이렇게 된다:
{
type: "div",
props: { className: "container" },
children: [
{
type: "h1",
props: {},
children: ["제목"]
},
{
type: "p",
props: {},
children: ["본문입니다"]
}
]
}
실제 DOM 노드 하나에는 수백 개의 속성이 붙어 있지만, VNode는 렌더링에 필요한 최소한의 정보만 담고 있어서 메모리도 적게 쓰고 비교도 빠르다.
ReactElement 타입 — 더 정교한 설계
위의 VNode는 HTML 요소만 표현할 수 있다. 하지만 실제 UI 라이브러리에서는 함수 컴포넌트도 표현할 수 있어야 한다. type이 문자열이 아니라 함수일 수도 있다는 뜻이다.
type ElementType = string | Function;
type ReactNode =
| ReactElement
| string
| number
| boolean
| null
| undefined
| ReactNode[];
interface Props {
[key: string]: any;
children?: ReactNode;
}
interface ReactElement<
P extends Props = Props,
T extends ElementType = ElementType
> {
type: T;
props: P;
key: string | null;
}
이 설계에서 주목할 점이 몇 가지 있다.
ElementType: string | Function
type 필드가 문자열이면 HTML 네이티브 요소, 함수이면 컴포넌트를 의미한다. 렌더러가 VNode를 처리할 때 이 타입을 보고 분기한다:
if (typeof vnode.type === "string") {
// <div>, <span> 등 → document.createElement(vnode.type)
} else if (typeof vnode.type === "function") {
// 함수 컴포넌트 → vnode.type(vnode.props) 호출해서 반환된 VNode를 처리
}
이 단순한 분기 하나가 컴포넌트 시스템의 핵심이다. 함수 컴포넌트는 결국 "props를 받아서 VNode를 반환하는 함수"에 불과하고, 렌더러는 그 반환값을 재귀적으로 처리할 뿐이다.
ReactNode 유니온 타입
ReactNode는 렌더링 가능한 모든 것을 표현하는 유니온 타입이다. ReactElement뿐 아니라 문자열, 숫자, boolean, null, undefined, 그리고 이들의 배열까지 포함한다.
왜 이렇게 넓은 타입이 필요한가? JSX에서 이런 코드가 가능하기 때문이다:
function App() {
return (
<div>
{"텍스트"} {/* string */}
{42} {/* number */}
{true && <Comp />} {/* boolean 또는 ReactElement */}
{null} {/* null — 아무것도 렌더링 안 함 */}
{[1, 2, 3].map(n => <li key={n}>{n}</li>)} {/* ReactNode[] */}
</div>
);
}
렌더러는 각 자식을 순회하면서 타입에 따라 다르게 처리한다:
string이나number→document.createTextNode()로 텍스트 노드 생성boolean,null,undefined→ 무시 (아무것도 렌더링하지 않음)ReactElement→ 재귀적으로 DOM 생성- 배열 → 펼쳐서 각 요소를 개별 처리
key 속성
key는 리스트 렌더링에서 각 요소를 식별하기 위한 힌트다. diff 알고리즘이 이전 트리와 새 트리의 자식 노드를 비교할 때, key가 없으면 순서대로 일대일 비교할 수밖에 없다. key가 있으면 "이전 트리의 key=3 노드"와 "새 트리의 key=3 노드"를 직접 매칭할 수 있어서, 노드의 순서가 바뀌거나 중간에 삽입/삭제가 일어나도 불필요한 DOM 재생성을 피할 수 있다.
// key가 없으면: 리스트 순서가 바뀔 때 모든 항목을 재생성
// key가 있으면: 실제로 변경된 항목만 이동
{items.map(item => <li key={item.id}>{item.name}</li>)}
key의 상세한 동작 원리는 diff 알고리즘과 reconciliation에서 더 깊이 다룬다.
제네릭 타입 파라미터
ReactElement<P, T>에서 P는 props의 타입, T는 element type의 타입이다. 이 제네릭 덕분에 타입 시스템이 각 컴포넌트의 props를 정확히 추론할 수 있다:
interface ButtonProps extends Props {
onClick: () => void;
label: string;
}
// ReactElement<ButtonProps, Function> → props.onClick과 props.label의 타입이 보장됨
기본값(Props, ElementType)이 지정되어 있으므로, 구체적인 타입이 필요 없는 곳에서는 ReactElement만 써도 된다.
createElement 함수
VNode 객체를 직접 만드는 건 번거롭다. createElement는 VNode를 편리하게 생성하기 위한 팩토리 함수다.
function createElement(
type: string,
props: { [key: string]: any } | null,
...children: any[]
): VNode {
return {
type,
props: props || {},
children,
};
}
이 함수가 중요한 이유는 JSX의 변환 대상이기 때문이다. JSX 코드는 빌드 타임에 createElement 호출로 변환된다:
// 개발자가 작성하는 코드
<div className="box">
<span>hello</span>
</div>
// 빌드 도구가 변환한 코드
createElement("div", { className: "box" },
createElement("span", null, "hello")
)
Vite 같은 빌드 도구에서 jsxFactory 옵션을 설정하면 JSX가 어떤 함수로 변환될지 지정할 수 있다. React를 사용하면 React.createElement로, 커스텀 구현이면 직접 만든 createElement로 변환된다.
rest parameter로 children 수집
...children 문법을 사용하면 세 번째 인자부터 모든 인자가 배열로 수집된다. JSX에서 자식 요소가 여러 개일 때 자연스럽게 처리된다:
// JSX: <div><span>a</span><span>b</span></div>
// 변환: createElement("div", null, createElement("span", null, "a"), createElement("span", null, "b"))
// 결과: { type: "div", props: {}, children: [VNode, VNode] }
props가 null일 수 있으므로 props || {}로 기본값을 보장한다. 이는 JSX에서 속성이 하나도 없는 요소(<div></div>)에서 props로 null이 전달되기 때문이다.
render 함수 — VNode를 실제 DOM으로
VNode 트리를 만들었으면 이제 실제 DOM으로 변환해야 한다. render 함수가 이 역할을 한다.
function render(vnode: VNode | string, container: HTMLElement) {
// 텍스트 노드 처리
if (typeof vnode === "string" || typeof vnode === "number") {
container.appendChild(document.createTextNode(String(vnode)));
return;
}
// 요소 노드 생성
const dom = document.createElement(vnode.type);
// 속성 적용
Object.entries(vnode.props).forEach(([key, value]) => {
dom[key] = value;
});
// 자식 재귀 처리
if (Array.isArray(vnode.children)) {
vnode.children.forEach((child) => {
render(child, dom);
});
}
container.appendChild(dom);
}
render 함수의 동작을 단계별로 보면:
- 텍스트 노드 판별: vnode가 문자열이면
createTextNode로 텍스트 노드를 만든다. 재귀의 기저 조건이다. - DOM 요소 생성:
document.createElement(vnode.type)으로 실제 DOM 노드를 만든다. - 속성 적용: props의 각 키-값 쌍을 DOM 속성으로 설정한다.
- 자식 재귀 처리: children 배열을 순회하면서 각 자식에 대해 render를 재귀 호출한다.
- DOM 트리에 삽입: 완성된 노드를 컨테이너에 추가한다.
이 과정은 VNode 트리의 깊이 우선 탐색(DFS)이다. 루트부터 시작해서 각 노드의 자식을 먼저 모두 처리한 뒤 부모에 붙이는 방식이다.
이 구현의 한계
위 render 함수는 초기 렌더링만 처리한다. 상태가 변경되었을 때 전체 DOM을 버리고 새로 만드는 것이므로, 매번 전체를 다시 그리게 된다. 이 문제를 해결하려면 diff 알고리즘이 필요하다 — 이전 VNode 트리와 새 VNode 트리를 비교해서 실제로 변경된 부분만 DOM에 반영하는 것이다.
또한 dom[key] = value 방식의 속성 적용은 단순하지만 불완전하다. className → class, htmlFor → for 같은 속성명 매핑이 빠져 있고, 이벤트 핸들러(onClick 등)도 별도 처리가 필요하다. 이런 세부 사항은 각각 별도의 주제로 다룬다.
왜 이런 구조여야 하는가
Virtual DOM의 자료구조 설계에서 가장 중요한 원칙은 실제 DOM과의 1:1 대응이다. VNode 하나가 DOM 노드 하나에 대응하고, VNode 트리의 구조가 DOM 트리의 구조와 정확히 일치해야 한다. 이 대응이 깨지면 diff와 patch가 올바르게 동작할 수 없다.
동시에 최소한의 정보만 저장해야 한다. 실제 DOM 노드에는 이벤트 리스너, 스타일 계산 결과, 레이아웃 정보 등 수백 개의 속성이 있지만, VNode에는 렌더링에 필요한 type, props, children, key만 있으면 된다. 이 가벼움이 곧 성능이다. 상태가 변경될 때마다 새로운 VNode 트리를 통째로 만들어도 부담이 없는 이유가 여기에 있다.
정리하면, Virtual DOM은 "무엇이 바뀌었는지"를 효율적으로 계산하기 위한 중간 추상 레이어다. 개발자는 선언적으로 UI를 기술하고, Virtual DOM이 명령적인 DOM 조작으로 변환하는 다리 역할을 한다. 그리고 그 다리의 가장 기초가 되는 것이 바로 이 VNode 자료구조다.