[Daily morning study] 가상 DOM (Virtual DOM) 작동 원리

#daily morning study

Image


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보통
TransitionLanestartTransition낮음
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은 범용적인 성능 최적화 솔루션이지, 유일한 방법은 아니다. 트레이드오프를 이해하고 선택하는 게 중요하다.