핵심 질문
Virtual DOM은 정말 필요한가?
React가 2013년에 Virtual DOM을 들고 나왔을 때, 프론트엔드 커뮤니티는 열광했습니다. "DOM 조작이 비싸니까 메모리에서 먼저 비교하자"는 아이디어는 직관적이었고, 실제로 잘 작동했습니다.
그런데 2018년, Svelte의 Rich Harris가 "Virtual DOM is pure overhead"라는 글을 씁니다. VDOM이 불필요한 중간 단계라는 주장이었습니다. 그 후로 프레임워크들의 렌더링 전략은 갈라지기 시작합니다.
문제: DOM 조작은 비싸다
// DOM API 호출은 JavaScript 연산보다 훨씬 느리다
element.textContent = 'hello'; // 레이아웃 재계산, 페인트, 합성...DOM은 브라우저의 렌더링 엔진이 관리하는 객체입니다. JavaScript에서 DOM을 수정하면 브라우저는 레이아웃을 다시 계산하고, 화면을 다시 그려야 합니다. 변경이 잦으면 성능이 눈에 띄게 떨어집니다.
모든 프레임워크가 풀고 싶은 문제는 같습니다, 변경을 최소화하고 싶다. 하지만 "최소화"에 도달하는 경로가 다릅니다.
React의 접근, Virtual DOM
React의 핵심 아이디어는 단순합니다. UI = f(state). 상태가 바뀔 때마다 컴포넌트 함수를 다시 실행해서 새로운 Virtual DOM 트리를 만들고, 이전 트리와 비교해서 차이만 실제 DOM에 적용합니다.
VDOM은 실제 DOM의 가벼운 복사본입니다. JavaScript 객체일 뿐이라 생성과 비교가 빠릅니다. React의 reconciliation 알고리즘 (Fiber) 은 두 트리를 비교할 때 O(n) 휴리스틱을 사용합니다, 타입이 같은 노드만 비교하고, 다르면 통째로 교체합니다.
// VDOM은 이런 JavaScript 객체다
const vnode = {
type: 'ul',
props: {},
children: [
{ type: 'li', props: {}, children: ['A'] },
{ type: 'li', props: {}, children: ['B'] },
{ type: 'li', props: {}, children: ['C'] },
]
};VDOM의 장점
선언적 프로그래밍 : 개발자는 "상태가 이러하면 UI는 이렇다"만 기술합니다. DOM 조작의 순서를 고민할 필요가 없습니다.
크로스 플랫폼 : VDOM은 추상화 계층입니다. 같은 컴포넌트를 브라우저 DOM이 아닌 다른 렌더러에 연결할 수 있습니다. React Native가 가능한 이유입니다.
배치 업데이트 : 여러 상태 변경을 모아서 한 번에 처리할 수 있습니다 (React 18의 automatic batching).
"Virtual DOM is pure overhead"
Rich Harris의 반론은 간단합니다. VDOM의 세 단계, (1) 새 VDOM 생성, (2) 이전과 비교, (3) DOM 패치, 에서 (1)과 (2)는 순수한 오버헤드라는 것입니다. 어차피 최종적으로 하는 일은 (3) DOM 패치뿐인데, 거기에 도달하기 위해 왜 두 단계를 더 거쳐야 하는가?
물론 React 팀은 이에 대해 "VDOM이 빠르다고 주장한 적 없다. 선언적으로 작성하면서도 충분히 빠른 것이 핵심이다"라고 답합니다. VDOM의 목적은 속도가 아니라 프로그래밍 모델 이라는 것입니다.
Vue의 접근, 컴파일러 최적화된 VDOM
Vue도 VDOM을 사용합니다. 하지만 React와 같은 VDOM이 아닙니다. Vue의 컴파일러가 템플릿을 분석해서 힌트 를 남기기 때문입니다.
정적 호이스팅 (Static Hoisting)
<template>
<div>
<h1>정적 제목</h1> <!-- 절대 안 바뀜 -->
<p>{{ dynamicText }}</p> <!-- 이것만 바뀜 -->
<footer>정적 푸터</footer> <!-- 절대 안 바뀜 -->
</div>
</template>Vue 컴파일러는 <h1>과 <footer>가 절대 변하지 않는다는 것을 알고 있습니다. 이 노드들의 VDOM은 한 번만 생성 하고 이후 re-render에서 재사용합니다. diff 대상에서도 제외됩니다.
패치 플래그 (Patch Flags)
// Vue 컴파일러가 생성하는 코드 (간소화)
createVNode("p", null, dynamicText, 1 /* TEXT */);
// ^^^^^^^^^^
// "이 노드는 텍스트만 바뀔 수 있다"패치 플래그는 "이 노드에서 뭐가 바뀔 수 있는지"를 숫자로 표시합니다. 1이면 텍스트만, 2이면 class만, 4이면 style만. diff 시 플래그에 해당하는 속성만 비교하면 됩니다.
결과적으로 Vue의 VDOM diff는 동적인 노드만, 바뀔 수 있는 속성만 비교합니다. React보다 비교 범위가 훨씬 좁습니다.
Angular의 접근, Ivy 렌더러
Angular는 VDOM을 사용하지 않습니다. Angular의 Ivy 렌더러(Angular 9+) 는 컴포넌트 템플릿을 일련의 DOM 생성 명령 으로 컴파일합니다.
// Angular 컴파일러가 생성하는 코드 (간소화)
function AppComponent_Template(rf, ctx) {
if (rf & 1) { // 생성 (creation mode)
elementStart(0, 'div');
text(1);
elementEnd();
}
if (rf & 2) { // 업데이트 (update mode)
advance(1);
textInterpolate(ctx.message); // 바인딩된 값만 업데이트
}
}이 접근은 Google의 Incremental DOM 아이디어에 영향을 받았습니다. Incremental DOM은 VDOM처럼 메모리에 전체 트리를 만들지 않고, 실제 DOM 트리를 순회하며 제자리에서 (in-place) 업데이트합니다.
Ivy의 장점은 트리 쉐이킹 에 유리하다는 것입니다. 컴파일된 코드는 사용하는 Angular 명령어만 import합니다. 사용하지 않는 기능 (@if, @for, 애니메이션 등) 은 번들에 포함되지 않습니다.
Svelte의 접근, 컴파일 타임 DOM 조작
Svelte는 가장 급진적인 접근을 택했습니다. VDOM 자체를 없앴습니다.
Svelte 컴파일러는 .svelte 파일을 분석해서, 상태가 바뀌었을 때 정확히 어떤 DOM 노드를 어떻게 수정해야 하는지 아는 코드를 생성합니다.
// 개발자가 작성하는 코드
{#each items as item}
<li>{item}</li>
{/each}
// 컴파일러가 생성하는 코드 (간소화)
// 아이템이 추가되면:
const li = document.createElement('li');
li.textContent = newItem;
parentNode.appendChild(li);
// 비교 없음. 생성 → 삽입. 끝.Rich Harris의 말처럼, "프레임워크는 컴파일러가 되어야 한다." 런타임에 비교하고 판단할 일을 빌드 타임에 해결하면, 브라우저에서 실행될 코드는 최소한으로 줄어듭니다.
Svelte의 번들에는 "범용 diff 알고리즘" 같은 것이 포함되지 않습니다. 각 컴포넌트에 필요한 DOM 조작 코드만 포함됩니다. 그래서 번들 크기가 작습니다.
"충분히 빠른" vs "이론적으로 최적인"
렌더링 전략을 비교할 때 빠지기 쉬운 함정이 있습니다, 벤치마크에 매몰되는 것.
js-framework-benchmark 같은 테스트에서 Svelte가 React보다 빠른 것은 사실입니다. 하지만 벤치마크는 수천 개의 행을 동시에 업데이트하는 극단적인 시나리오입니다. 실제 애플리케이션에서 VDOM의 오버헤드가 체감되는 경우는 드뭅니다.
진짜 병목은 보통 다른 곳에 있습니다.
- 네트워크 : API 응답을 기다리는 시간이 VDOM diff보다 100배 이상 깁니다
- 서버 : 데이터베이스 쿼리, 인증 로직이 렌더링보다 느립니다
- 비즈니스 로직 : 복잡한 데이터 변환, 필터링이 DOM 조작보다 비쌉니다
React의 VDOM 오버헤드는 대부분의 앱에서 몇 밀리초 수준입니다. 사용자는 차이를 느끼지 못합니다. "이론적으로 최적인 것"과 "실용적으로 충분한 것" 사이에서 어떤 가치를 선택하느냐의 문제입니다.
물론 성능이 중요한 특정 영역, 실시간 대시보드, 인터랙티브 시각화, 저사양 기기 대상 앱, 에서는 Svelte나 SolidJS의 접근이 실질적인 이점을 줍니다.
트레이드오프 비교
| React | Vue | Angular | Svelte | |
|---|---|---|---|---|
| 렌더링 방식 | VDOM diff | VDOM + 컴파일러 힌트 | Ivy (no VDOM) | 컴파일된 DOM 조작 |
| 크로스 플랫폼 | React Native | 제한적 | Ionic / NativeScript | 제한적 |
| 번들 기여 | 런타임 ~44KB | 런타임 ~33KB | 런타임 ~90KB+ | 거의 없음 |
| 최적화 주체 | 개발자 (memo) | 컴파일러 + 개발자 | 프레임워크 | 컴파일러 |
React는 크로스 플랫폼 유연성을 얻기 위해 런타임 오버헤드를 감수합니다. Vue는 VDOM의 유연성은 유지하되 컴파일러로 비용을 줄입니다. Angular는 Ivy로 VDOM 없이 효율적인 업데이트를 달성하면서 트리 쉐이킹까지 챙깁니다. Svelte는 런타임을 최소화하는 대신 크로스 플랫폼 유연성을 포기합니다.
어떤 전략이 "최고"라기보다, 각 프레임워크가 무엇을 중시하느냐 에 따른 선택입니다.
다음 글에서는 상태 관리 를 다룹니다. 컴포넌트를 넘어 애플리케이션 전체의 상태를 어떻게 관리하는지, 각 프레임워크의 접근을 비교합니다.