본문으로 건너뛰기
yutils

virtual DOM 은 어떻게 동작할까?

VDOM = snapshot, reconciliation diff (O(n) — O(n³) 아님), React Fiber 의 interruptible scheduler, key prop 이 중요한 이유, Vue 의 tracked-dep patch, Svelte / Solid 의 no-VDOM signal 접근.

약 9분 읽기

React 의 "JSX 를 쓰면 DOM 이 알아서 갱신" 의 진짜 — virtual DOM + reconciliation. 그러나 modern (Svelte / Solid) 는 VDOM 없이도 같은 마법. 이 가이드는 VDOM 의 동작, diff 알고리즘이 O(n³) 가 아닌 O(n) 인 휴리스틱, key prop 의 중요성, no-VDOM 의 다른 길을 정리한다.

VDOM — 무엇인가

기본 아이디어: 직접 DOM 조작 X, JS object 로 "원하는 모양" 표현.

JSX:
  <div className="card">
    <h1>{title}</h1>
    <p>{body}</p>
  </div>

→ 컴파일 후:
  React.createElement("div", {className: "card"}, [
    React.createElement("h1", null, title),
    React.createElement("p", null, body)
  ])

→ runtime 의 JS object (VDOM tree):
  {type: "div", props: {className: "card"}, children: [
    {type: "h1", props: {}, children: [title]},
    {type: "p", props: {}, children: [body]}
  ]}

state 변경 → 새 VDOM tree 생성 (이전과 다른 object)
→ React 가 새 vs 이전 비교 (reconciliation) → 실제 DOM 의 차이만 업데이트.

왜 VDOM 인가 — 비교

직접 DOM 조작 (jQuery 시대):
  $("#list").empty();
  for (let item of items) {
    $("#list").append("<li>" + item.name + "</li>");
  }

문제:
- 100 item 의 list → 100 DOM mutation
- 단 1 item 변경에도 모두 재생성 (또는 수동으로 diff)
- jQuery 가 그래도 가볍지만 — application 복잡도 ↑

VDOM 의 답:
  function List({items}) {
    return <ul>{items.map(i => <li>{i.name}</li>)}</ul>;
  }

- React 가 자동 diff
- 100 item 중 1 개 변경 → 1 DOM mutation
- application 코드는 "전체 render" — declarative

Reconciliation Diff — O(n) 휴리스틱

tree diff 의 일반 알고리즘 = O(n³) — 두 큰 tree 의 정확한 minimum
edit distance. 1000-node tree = 10^9 연산 → 매우 느림.

React 의 trick — 두 휴리스틱으로 O(n) 으로 단순화:

1. 다른 type 의 element 는 다른 subtree
   <div>...</div>  →  <span>...</span>
   → div 전체 제거 + span 새로 생성 (안에 변경 비교 X)

2. key prop 으로 list 의 reorder 알림
   key 없으면: <li>A</li><li>B</li><li>C</li> → <li>B</li><li>C</li>
              → React 가 A→B, B→C, C→remove 로 판단 (3 mutation)
   key 있으면: <li key="a">A</li><li key="b">B</li><li key="c">C</li>
              → A 제거, B C 그대로 (1 mutation)

→ "정확한" 알고리즘 포기 + 일반적 사용 패턴에 맞춤 = 빠른 dev experience.

Key Prop 의 중요성

흔한 안티 패턴:
  {items.map((item, i) => <Card key={i} data={item} />)}
                                  ↑
                                index 를 key 로

문제: list 의 reorder 시 — key=0 이 무조건 첫 item.
      items 가 [A, B, C] → [B, C] 로 변경 (A 삭제) 면:
        key=0: 이전 A → 이제 B (re-render 후 B 데이터로)
        key=1: 이전 B → 이제 C
        key=2: 이전 C → unmount
      → A B C 의 모든 Card 가 props 변경 → re-render
      (실제로 unmount 한 건 C 의 위치 뿐)

올바른 패턴:
  {items.map(item => <Card key={item.id} data={item} />)}
                            ↑
                          unique stable id

→ React 가 정확히 인식: A 사라짐, B C 그대로 → B C 의 Card 는 동일 instance 유지.

특히 form input 같은 stateful component 에서 큰 차이.

React Fiber — interruptible scheduler (2017+)

React 16 의 새 reconciliation engine.

이전 (Stack Reconciler):
  render 가 시작되면 끝까지 동기 실행
  → 큰 tree 의 render 가 main thread 점유 → user input 무시 (jank)

Fiber:
  render 가 "interruptible" unit 으로 쪼개짐
  → main thread 가 다른 일 (animation, input) 처리 시 잠시 yield
  → 우선순위 시스템 (high: user input / low: 데이터 update)
  → "concurrent rendering" (React 18) 의 기반

특징:
- 같은 render 가 여러 frame 에 걸침 가능
- urgent update (input) 가 lower-priority update (data) 를 가로채기 가능
- Suspense / startTransition / useDeferredValue 의 기반

→ "큰 page 도 60fps 유지" 의 modern React 약속.

Vue — Tracked Dependency Patch

Vue 의 reactivity 는 React 와 다른 접근:

  const state = reactive({count: 0});
  console.log(state.count);  // get → React 가 의존 추적

  function Counter() {
    return <div>{state.count}</div>;  // 의존 등록됨
  }

  state.count++;  // set → 의존하는 component 만 정확히 re-render
                  // (다른 component 다 그대로)

→ React 가 "tree 전체 diff" 인 반면 Vue 는 "변경된 곳만 정확히 patch".
   런타임 cost 다름 — Vue 가 fine-grained 갱신, 추적 cost 추가.

Vue 3 의 VDOM 도 React 보다 작음 + Proxy 기반 reactivity 가 더 효율적.

Svelte / Solid — No VDOM (Signal)

다른 path: VDOM 자체 제거.

Svelte:
  <script>
    let count = 0;
  </script>
  <button on:click={() => count++}>{count}</button>

→ compile time 에 "count 가 변경되면 그 button 의 textContent 갱신"
  같은 정확한 DOM 명령으로 변환.
→ runtime 에 VDOM diff X, 직접 DOM 변경.
→ bundle 매우 작음 (framework runtime 0 에 가까움).

Solid:
  function Counter() {
    const [count, setCount] = createSignal(0);
    return <button onClick={() => setCount(count() + 1)}>{count()}</button>;
  }

→ JSX 사용하지만 internally signal — Vue 와 비슷한 fine-grained reactivity.
→ React API 익숙한 사람이 받기 쉽고, 성능은 Svelte 급.

trade-off:
- React/Vue: framework runtime 큼, mental model 안정적
- Svelte/Solid: framework runtime 작음, 새 패러다임

성능 — VDOM 이 항상 더 느린가?

흔한 오해: "VDOM 비싸다 → 직접 DOM 이 빠르다"

실제:
- 작은 update: 직접 DOM ≈ VDOM (둘 다 1 mutation)
- 큰 update: VDOM 이 batched diff 후 한 번에 → 직접보다 빠를 수도
- 복잡한 reorder: 직접은 거의 불가능 (수동 diff 필요)

→ "VDOM 의 cost" 자체는 작음 (수십 μs). 진짜 비용은 component 의
   render 함수 안 작업.

VDOM 의 가치:
1. Developer experience (declarative)
2. 자동 batching + scheduling
3. cross-platform (React Native — VDOM 을 native widget 으로 mapping)
4. testing / DevTools (tree snapshot)

흔한 함정

  • index 를 key 로 — list reorder / 추가 / 삭제 시 잘못된 instance 재사용 → state 누수, perf 손해.
  • inline object / function 을 prop 으로 — 매 render 마다 새 reference → memoized child 가 무용. useCallback / useMemo.
  • 큰 component 의 render 가 자주 — Fiber 도 한 component 의 render 는 동기. 잘게 쪼개기.
  • useMemo / useCallback 남용 — memoization 자체도 cost. 측정 후 적용.
  • VDOM 의 cost 만 걱정 — 실제 bottleneck 은 보통 큰 state / 큰 list / 잘못된 component split.

마무리

VDOM 의 본질 — "원하는 모양 (JS object) 와 실제 DOM 의 diff". React 의 휴리스틱 (다른 type = 다른 subtree, key 로 reorder) 이 O(n³) → O(n) 로. Fiber 가 interruptible 로 60fps 유지.

실용 — React/Vue 의 VDOM 은 modern hardware 에서 충분 빠름. bundle / runtime 더 줄이려면 Svelte / Solid 의 no-VDOM signal 접근. 어느 쪽이든 key prop 정확한 사용 + 작은 component 가 성능의 80%.

가이드 목록으로