[Daily morning study] 가상 DOM (Virtual DOM) 작동 원리
#daily morning study
DOM이란?
DOM(Document Object Model)은 HTML 문서를 브라우저가 파싱해서 만들어내는 트리 구조의 객체 모델이다. 각 HTML 요소가 노드(Node)로 표현되며, JavaScript는 이 DOM API를 통해 요소를 조회하고 수정할 수 있다.
// 직접 DOM 접근
const el = document.getElementById('title');
el.textContent = '새 제목';
el.style.color = 'red';
문제는 DOM 조작이 생각보다 훨씬 비싼 연산이라는 점이다. DOM 노드를 변경하면 브라우저는 Layout(Reflow) → Paint → Composite 단계를 다시 거쳐야 한다. 특히 레이아웃에 영향을 주는 변경(width, height, top 등)은 전체 트리를 재계산해야 하므로 부하가 크다.
DOM 변경 → Layout(Reflow) → Paint → Composite → 화면 갱신
복잡한 SPA에서 이벤트마다 수십 개의 DOM 노드를 업데이트하면 렌더링이 버벅거린다. 가상 DOM은 이 문제를 해결하기 위해 등장했다.
가상 DOM(Virtual DOM)이란?
가상 DOM은 실제 DOM 구조를 흉내 낸 순수 JavaScript 객체다. 메모리 위에서만 존재하기 때문에 생성/수정/삭제가 빠르다.
// 실제 DOM 노드 대신 평범한 JS 객체
const vNode = {
type: 'div',
props: { className: 'container', id: 'main' },
children: [
{ type: 'h1', props: {}, children: ['제목'] },
{ type: 'p', props: { style: { color: 'blue' } }, children: ['내용'] }
]
};
React는 React.createElement()(또는 JSX)로 이런 객체를 만들어낸다.
// JSX
const el = <div className="container"><h1>제목</h1></div>;
// 컴파일 결과
const el = React.createElement(
'div',
{ className: 'container' },
React.createElement('h1', null, '제목')
);
핵심 개념: Diffing과 Reconciliation
가상 DOM의 핵심은 두 단계로 나뉜다.
1. Diffing (차이 계산)
상태가 바뀌면 새 가상 DOM 트리를 생성하고, 이전 트리와 비교해서 어디가 달라졌는지 찾아낸다.
이전 vDOM 새 vDOM
<div> <div>
<h1>제목A</h1> → <h1>제목B</h1> ← 변경됨
<p>내용</p> <p>내용</p> ← 동일
</div> </div>
변경 사항: h1의 텍스트 노드만 다름 → 실제 DOM에서도 h1의 텍스트만 교체
이상적으로 두 트리를 비교하는 알고리즘의 시간복잡도는 O(n³)이지만, React는 두 가지 휴리스틱으로 O(n)까지 낮췄다.
휴리스틱 1: 타입이 다르면 전체 교체
이전: <div>...</div>
새: <span>...</span>
→ div 서브트리 전체를 unmount하고 span 서브트리를 새로 mount
휴리스틱 2: 같은 레벨의 리스트는 key로 식별
// key 없이
<ul>
<li>사과</li> // index 0
<li>바나나</li> // index 1
</ul>
// 앞에 항목 추가 시 React는 index 기준으로 모두 다르다고 판단 → 전체 리렌더
<ul>
<li>망고</li> // index 0 (변경?)
<li>사과</li> // index 1 (변경?)
<li>바나나</li> // index 2 (신규?)
</ul>
// key 사용 시
<ul>
<li key="mango">망고</li>
<li key="apple">사과</li>
<li key="banana">바나나</li>
</ul>
// React는 key="apple", key="banana"는 그대로이고 key="mango"만 새로 추가됐다고 판단
2. Reconciliation (실제 DOM 반영)
Diffing으로 찾아낸 변경 목록을 실제 DOM에 최소한으로 적용하는 단계다.
diff 결과:
- h1 텍스트: "제목A" → "제목B"
- li 하나 추가
실제 DOM 조작:
document.querySelector('h1').textContent = '제목B';
ul.appendChild(newLiNode);
이렇게 변경된 노드만 골라서 업데이트하기 때문에 매번 전체 DOM을 다시 그리는 것보다 훨씬 효율적이다.
React의 Fiber 아키텍처
React 15까지의 Reconciliation은 동기적(synchronous)으로 동작했다. 트리가 크면 한 번의 업데이트가 수십 ms를 차지해서 그 사이 사용자 입력이 블록됐다.
React 16에서 Fiber 아키텍처를 도입해 이 문제를 해결했다.
Fiber의 핵심 아이디어
작업을 작은 단위(Fiber 노드)로 쪼개고, 우선순위를 매겨 스케줄링한다.
기존 Stack Reconciler:
업데이트 시작 → (블로킹) → 업데이트 완료
Fiber Reconciler:
업데이트 시작
→ 작업 단위 실행 → 남은 시간 있으면 계속, 없으면 중단
→ 브라우저에게 제어권 반환 (사용자 입력 처리 가능)
→ 다시 작업 재개 → 완료
우선순위 레인(Lane)
React 18부터 작업 우선순위를 “레인”으로 세분화했다.
| 레인 | 예시 | 우선순위 |
|---|---|---|
| SyncLane | 직접 DOM 이벤트 | 최고 |
| InputContinuousLane | 스크롤, 드래그 | 높음 |
| DefaultLane | 일반 setState | 보통 |
| TransitionLane | startTransition | 낮음 |
| IdleLane | 백그라운드 작업 | 최저 |
import { startTransition } from 'react';
// 검색 입력은 즉시 업데이트 (높은 우선순위)
setInputValue(e.target.value);
// 검색 결과는 나중에 업데이트해도 됨 (낮은 우선순위)
startTransition(() => {
setSearchResults(filterItems(e.target.value));
});
렌더링 단계 정리
React의 업데이트는 두 단계로 나뉜다.
Render Phase (순수 계산, 중단 가능)
├── 새 vDOM 트리 생성
├── Diffing 수행
└── 변경 목록(Effect List) 작성
Commit Phase (DOM 변경, 중단 불가)
├── Before Mutation: 스냅샷 저장 (getSnapshotBeforeUpdate)
├── Mutation: 실제 DOM 적용 (삽입/수정/삭제)
└── Layout: ref 업데이트, useLayoutEffect 실행
useEffect는 Commit Phase 이후 비동기로 실행되고, useLayoutEffect는 DOM 변경 직후 동기로 실행된다.
가상 DOM의 장단점
장점
- 성능 최적화: 변경된 부분만 실제 DOM에 반영해 불필요한 리렌더링을 줄인다.
- 선언적 UI: 개발자는 “어떻게 바꿀지”가 아니라 “어떤 상태일 때 어떻게 보일지”를 기술한다.
- 크로스 플랫폼: vDOM은 추상화 계층이라 React Native처럼 DOM이 없는 환경에서도 같은 패턴을 쓸 수 있다.
단점
- 초기 오버헤드: vDOM 생성·관리에 추가 메모리와 연산이 필요하다.
- 단순한 경우 비효율: 상태가 거의 안 바뀌는 정적 페이지에서는 오히려 vDOM이 불필요한 레이어다.
- 직접 조작 어려움: 애니메이션처럼 프레임 단위 DOM 조작이 필요하면
useRef나useImperativeHandle로 직접 접근해야 한다.
가상 DOM 없이도 빠를 수 있다
Svelte는 가상 DOM을 사용하지 않는다. 컴파일 타임에 어떤 변수가 어떤 DOM 노드와 연결됐는지 파악하고, 직접 최소한의 DOM 조작 코드를 생성한다.
// Svelte 컴파일 결과 (개념적)
// count가 바뀌면 → 해당 텍스트 노드만 직접 업데이트
function update(dirty) {
if (dirty & 1) { // count 변경 플래그
set_data(t, ctx[0]); // t: 텍스트 노드, ctx[0]: count 값
}
}
가상 DOM은 범용적인 성능 최적화 솔루션이지, 유일한 방법은 아니다. 트레이드오프를 이해하고 선택하는 게 중요하다.