Virtual DOM은 왜 쓰나요?
- 면접 출제
- ★
- 예상 시간
- 7분
실제 DOM을 직접 조작하는 것은 브라우저 입장에서 비용이 큽니다. DOM API 호출 자체도 느리고, 노드를 바꾸면 레이아웃을 다시 계산하는 reflow와 화면을 다시 칠하는 repaint가 뒤따를 수 있기 때문입니다.
Virtual DOM은 실제 DOM을 흉내 낸 가벼운 JS 객체 트리입니다. 상태가 바뀌면 먼저 이 가상 트리에서 변경 전후를 비교(diffing)하고, 실제로 달라진 최소한의 노드만 골라 실제 DOM에 반영합니다. 그 결과 불필요한 DOM 조작과 reflow·repaint 횟수를 줄여서 화면을 더 빠르게 갱신할 수 있습니다.
일반 JS 객체를 만들고 비교하는 건 실제 DOM을 조작하는 것보다 훨씬 빠릅니다. 그래서 '싼 JS 비교로 뭐가 바뀌었는지 먼저 알아낸 다음, 비싼 실제 DOM 조작은 진짜 바뀐 최소한의 부분에만 한다'는 전략이 성립합니다.
React가 이기는 진짜 비교 대상은 diffing 없이 상태가 바뀔 때마다 관련 부분을 통째로 다시 그리는 순진한 방식입니다. 이 경우와 비교하면 Virtual DOM 쪽이 압도적으로 빠릅니다.
꼬리질문
조금 더 깊게 물어본다면
답변 뒤에 이어질 수 있는 질문들을 하나씩 열어볼 수 있어요.
Virtual DOM은 왜 등장했나요?
React 이전에는 jQuery로 필요한 DOM 노드를 직접 찾아 조작하는 방식이 일반적이었는데, 애플리케이션이 커질수록 어떤 핸들러가 언제 어떤 DOM을 바꾸는지 추적하기 어려워지고 상태와 화면이 어긋나는 문제가 잦았습니다. React는 '상태가 이러면 화면은 이래야 한다'를 선언형(declarative)으로 기술하게 하고, 그 상태를 실제 DOM에 반영하는 과정을 Virtual DOM과 diffing으로 자동화했습니다.
2013년 React가 공개되기 전까지는 이벤트 핸들러마다 필요한 DOM 노드를 직접 찾아 수정하는 jQuery 방식이 지배적이었습니다. 코드가 커질수록 어떤 핸들러가 어떤 DOM을 언제 바꾸는지 한눈에 파악하기 어려워졌고, 여러 곳에서 같은 요소를 건드리다 상태와 실제 화면이 어긋나는 버그가 자주 발생했습니다.
React의 제안은 상태가 바뀔 때마다 '무엇이 바뀌었는지'를 개발자가 직접 계산하지 말고, 매번 전체 UI를 새로 그린다고 선언하자는 것이었습니다. 다만 상태가 바뀔 때마다 실제 DOM 전체를 새로 그리면 너무 느리므로, 그 사이에 Virtual DOM을 두고 diffing으로 실제로 달라진 부분만 걸러내는 절충안을 택했습니다.
React 코어 팀의 Pete Hunt는 2013년 JSConf 발표 Rethinking Best Practices에서 이 접근을 소개하면서, Virtual DOM의 목적이 속도 자체보다 매번 전체를 다시 그린다고 선언해도 되는 프로그래밍 모델을 가능하게 하는 데 있다고 설명했습니다. 레거시 React 공식 문서(Virtual DOM and Internals)도 같은 관점에서, Virtual DOM이 '상태가 이렇다면 DOM도 그래야 한다'는 선언형 API를 가능하게 하려고 존재한다고 설명하며 속도를 핵심 근거로 내세우지 않습니다.
Virtual DOM은 항상 가장 빠른 방법인가요?
아닙니다. 사람이 직접 DOM을 조작하는 명령형(imperative) 방식이 이론적으로는 더 빠를 수 있습니다.
이게 앞의 30초 답변과 언뜻 모순처럼 보일 수 있는데, 비교 대상이 다릅니다. 앞에서는 'JS 객체를 만들고 비교하는 것'과 '실제 DOM을 조작하는 것' 중 어느 쪽이 싼지를 비교했습니다(diffing 자체는 싸다는 뜻). 여기서는 'React의 전체 접근 방식(diffing + DOM 패치)'과 '개발자가 직접 DOM을 조작하는 명령형 방식(diffing 없이 바로 DOM 패치)'을 비교합니다.
React는 diffing 비용이 아무리 싸도 어쨌든 추가로 치러야 합니다. 반면 명령형 코드는 뭐가 바뀌었는지 이미 알고 있어서 diffing 단계 자체를 건너뛰고 바로 최소한의 DOM 패치만 합니다. 예를 들어 카운터 하나라면:
// 명령형: diffing 없이 바로 필요한 노드만 조작
count++;
document.getElementById("count").textContent = count;이 코드는 'count가 바뀌면 이 텍스트만 바꾸면 된다'는 걸 개발자가 이미 알고 있어서 비교 과정이 아예 없습니다. 그래서 이론적으로 더 빠를 수 있습니다.
다만 화면 요소와 상태가 수백 개로 얽힌 실제 서비스에서는 '정확히 뭘 바꿔야 하는지'를 사람이 일일이 추적하는 게 번거롭고 실수하기 쉽습니다. Virtual DOM의 진짜 강점은 속도 그 자체가 아니라, '이 상태 값이 바뀌면 화면이 이렇게 보여야 한다'를 선언형(declarative)으로 작성해도 개발자가 DOM 변경을 직접 추적하지 않으면서 합리적인 성능을 낸다는 데 있습니다.
diffing 없이 상태와 DOM을 연결하는 방식도 있나요?
있습니다. Svelte나 SolidJS 같은 프레임워크는 fine-grained reactivity라는 방식으로, 어떤 상태가 어떤 DOM 노드에 연결되는지를 컴파일 시점에 미리 알아내서 diffing 없이 바로 그 노드만 업데이트합니다.
React는 런타임에 Virtual DOM tree를 비교해서 변경점을 찾는 반면, 이런 프레임워크는 빌드 시점에 '이 변수가 바뀌면 이 DOM 노드의 이 속성을 바꾼다'는 연결을 코드로 미리 생성해둡니다. 그래서 런타임에 비교 과정 자체가 없습니다. Virtual DOM이 '싼 비교 후 최소 반영'이라면, fine-grained reactivity는 '비교 자체를 생략하고 미리 정해둔 대로 반영'하는 접근입니다.
리스트를 렌더링할 때 key는 왜 필요한가요?
React의 diffing은 이전 트리와 새 트리를 전부 비교하지 않고, 비교 비용을 줄이기 위한 몇 가지 휴리스틱(heuristic)을 씁니다. 그중 하나가 같은 레벨의 자식 목록을 짝지을 때 key로 매칭하는 것인데, key가 없으면 순서(인덱스)로 짝을 맞춥니다.
두 트리를 완전 비교해서 최소 편집 거리를 찾는 방식은 O(n³) 복잡도라 리스트가 조금만 커져도 감당이 안 됩니다. React는 두 가지 가정으로 이 비용을 O(n)으로 줄입니다. 타입이 다른 엘리먼트는 하위 트리를 통째로 갈아엎고, 같은 레벨의 형제는 key로 짝을 맞춥니다.
key 없이 인덱스로 짝을 맞추면, 리스트 맨 앞에 항목 하나가 추가됐을 때 실제로는 모든 항목이 한 칸씩 밀린 것뿐인데 React는 인덱스 0번 자리의 내용이 바뀐 것으로 오인합니다. 그러면 DOM 노드를 재사용하면서 내부 state(예를 들어 input의 입력값)까지 엉뚱한 항목에 붙어버리는 버그가 생깁니다.
O(n³), O(n) 같은 복잡도 표현은 Fiber 도입 이전 legacy 문서(reactjs.org의 Reconciliation 문서)의 설명이고, 최신 react.dev 문서는 같은 규칙을 복잡도 없이 '같은 위치·같은 타입이면 상태 유지, key로 위치를 직접 지정한다'는 식으로 설명합니다. 다만 타입 다르면 서브트리 교체, key로 형제 매칭이라는 동작 자체는 지금도 동일합니다.
// key 없이 인덱스로 매칭하면
// [B, C] → [A, B, C]가 됐을 때 A가 새로 생긴 게 아니라
// 기존 0번(B)이 A로, 1번(C)이 B로 바뀐 것처럼 처리된다
list.map((item) => <Item value={item} />);이 매칭 규칙은 '뭐가 바뀌었는지 판단하는 기준'이고, 그 판단 작업을 실제로 어떻게 실행하는지는 별개 층위입니다. 이어지는 Fiber 질문에서 다룹니다.
Fiber는 왜 도입됐나요?
앞의 key 매칭이 '뭐가 바뀌었는지 판단하는 규칙'이라면, Fiber는 그 판단 작업을 '어떻게 실행하는지'에 대한 것입니다. React 16 이전(stack reconciler)은 트리 전체를 한 번의 동기 작업으로 재귀 순회했습니다. 트리가 크면 이 작업이 끝날 때까지 메인 스레드를 계속 점유해서, 그동안 브라우저는 애니메이션이나 입력 이벤트 같은 다른 작업을 처리하지 못하고 화면이 버벅였습니다.
Fiber는 이 재귀 순회를 fiber node라는 단위로 쪼개서, 작업을 작은 조각(unit of work)으로 나눠 실행합니다. 조각 하나를 처리할 때마다 브라우저에게 제어권을 돌려주고, 더 급한 작업(예를 들어 사용자 입력)이 있으면 지금 하던 렌더링 작업을 중단했다가 나중에 이어서 할 수 있습니다. 이걸 interruptible(중단 가능한) rendering이라고 부릅니다.
이 구조 덕분에 업데이트마다 우선순위를 다르게 줄 수 있습니다. 애니메이션처럼 즉각 반응해야 하는 업데이트는 우선순위를 높게, 데이터 fetch로 인한 업데이트처럼 조금 늦어도 되는 업데이트는 우선순위를 낮게 줘서, 급한 작업이 먼저 화면에 반영되도록 스케줄링합니다.
React 18의 Suspense, startTransition 같은 concurrent 기능들은 모두 이 fiber 구조 위에서 우선순위를 조절하는 방식으로 동작합니다.
batching은 Virtual DOM과 어떤 관계가 있나요?
diffing 자체는 한 번 실행될 때는 싸지만, 상태 변경이 여러 번 연달아 일어나면 그만큼 diffing과 commit을 반복하게 됩니다. batching은 짧은 시간 안에 일어난 여러 상태 변경을 모아서 diffing을 한 번만 실행하는 최적화입니다.
이벤트 핸들러 안에서 상태를 세 번 바꾸면, batching이 없을 경우 diffing과 실제 DOM commit이 세 번 일어납니다. React는 이런 변경을 큐에 모아뒀다가, 핸들러 실행이 끝난 뒤 한 번만 diffing을 돌립니다.
React 18 이전에는 이 batching이 React가 직접 제어하는 이벤트 핸들러 안에서만 일어났고, setTimeout이나 프라미스 콜백처럼 React 바깥에서 발생한 상태 변경은 각각 따로 diffing이 실행됐습니다. React 18의 automatic batching은 이 범위를 넓혀서, 어디서 일어난 상태 변경이든 기본적으로 묶어서 처리합니다.
function handleClick() {
setCount((c) => c + 1); // 즉시 반영되지 않고 큐에 쌓인다
setFlag((f) => !f); // 즉시 반영되지 않고 큐에 쌓인다
// 핸들러가 끝난 뒤 두 변경을 합쳐 diffing 한 번, commit 한 번만 실행한다
}React.memo나 useMemo가 줄여주는 것도 diffing 비용인가요?
아닙니다. React.memo나 useMemo가 막는 건 컴포넌트 함수를 다시 실행하는 리렌더링 비용이지, Virtual DOM diffing 비용이 아닙니다.
리렌더링과 diffing은 순서가 다른 별개의 단계입니다. 부모 컴포넌트가 리렌더링되면 자식 컴포넌트 함수가 다시 호출되어 새 Virtual DOM 노드를 만들고, 그 다음에야 diffing이 이전 트리와 비교합니다. React.memo는 props가 안 바뀌었으면 자식 함수 호출 자체를 건너뛰어서, 애초에 비교할 새 트리를 만들지 않습니다.
diffing은 이미 두 트리가 있다는 전제로 최소 비용으로 비교하는 단계라, 트리를 아예 안 만드는 것과는 최적화하는 지점이 다릅니다. 'Virtual DOM이 느려서 memo를 쓴다'는 표현은 정확히는 '리렌더링(함수 재실행) 비용을 줄이려고 memo를 쓴다'는 뜻입니다.
실제로 diffing 자체가 병목이 되는 경우도 있나요?
있습니다. 리스트 항목이 수천 개 단위로 한 화면에 동시에 존재하면, 각 항목이 실제로 바뀌지 않았어도 diffing이 모든 노드를 순회하면서 비교 비용이 쌓입니다.
diffing 비용이 O(n)이라도 n 자체가 매우 커지면 무시할 수 없는 비용이 됩니다. 대표적으로 수천 행을 가진 테이블이나 무한 스크롤 리스트에서, 화면에 실제로 보이는 건 수십 개뿐인데 나머지 수천 개도 Virtual DOM 트리에 남아 있으면 매 리렌더링마다 diffing 대상에 포함됩니다.
windowing(또는 virtualization, react-window 같은 라이브러리가 구현하는 방식)은 이 문제를 diffing 최적화가 아니라 애초에 화면 밖 항목을 Virtual DOM 트리에서 제외하는 방식으로 해결합니다. 보이지 않는 항목은 렌더링도, diffing도 하지 않으니 n 자체가 줄어듭니다.
부가 설명
reflow와 repaint가 정확히 어느 단계를 다시 타는지는 reflow와 repaint 질문에서 더 자세히 다룹니다.
상태 변경 → 새 Virtual DOM tree 생성 → 이전 tree와 diffing → 달라진 노드만 실제 DOM에 commitdiffing은 메모리상의 JS 객체끼리 하는 작업이라, 실제 DOM에 손대지 않고도 '뭐가 달라졌는지'를 먼저 계산할 수 있습니다. 이 결과로 실제로 달라진 최소한의 노드만 골라내는 과정을 reconciliation이라고 부릅니다.
한 줄 정리
Virtual DOM은 비용이 큰 실제 DOM 조작 전에 저렴한 JS 객체 비교(diffing)로 변경점을 먼저 찾아내, 최소한의 DOM만 갱신하는 방식입니다. 다만 diffing 자체도 비용이라 사람이 완벽하게 최적화한 직접 조작보다 항상 빠른 건 아니며, 진짜 강점은 속도보다 선언형 코드로도 합리적인 성능을 낸다는 데 있습니다.