본문으로 건너뛰기

React 가상 DOM 동작 정리

·12 min read

리액트 공부를 하다가 "가상 DOM이 빠르다"는 말은 자주 들었지만, 실제 DOM은 정확히 뭐가 느리고 가상 DOM은 어떤 식으로 그 부담을 줄여주는지 머릿속에서 정리가 안 됐다. 그래서 노드 구조부터 재조정 과정까지 한 번 훑었다.

실제 DOM과 가상 DOM은 뭐가 다른가

실제 DOM은 Document, Element, Text 같은 노드 타입의 트리다. 브라우저가 HTML을 파싱해서 만든 진짜 문서 구조 그 자체.

가상 DOM은 그 실제 DOM 구조를 미러링한 경량 JS 객체 트리다. React 엘리먼트들이 중첩되어 만들어진 형태인데, 결국 객체일 뿐이라 만들고 비교하는 비용이 실제 DOM 조작보다 훨씬 싸다.

재조정 — 가상 트리에서 먼저 차이를 본다

업데이트 트리거가 발생해서 UI를 변경할 때, React는 가상 DOM을 먼저 업데이트한다. 그리고 이전 가상 DOM 트리와 비교해서 차이만 실제 DOM에 반영한다. 이 과정이 재조정(reconciliation)이다.

가상 DOM 비교는 자바스크립트 객체 비교라 빠르다. 실제 DOM에 한 번에 최소한의 변경만 가하는 게 핵심 아이디어다.

리플로우가 비싼 이유

레이아웃을 다시 계산하고 화면을 다시 그리는 과정을 리플로우라 부른다. 요소 크기를 바꾸는 CSS, 요소 크기를 조회하는 JS 코드 등에서 발생한다.

특히 조회 시점이 까다롭다. 크기를 읽으려면 브라우저는 최신 크기값을 줘야 하니까, 그 호출 시점에 그동안 미뤄둔 레이아웃 계산을 강제로 처리한다. 읽기와 쓰기를 섞어 가며 호출하면 매번 재계산이 일어나서 레이아웃 스래싱(레이아웃 속성을 읽고 쓰는 작업이 자주 반복될 때 발생하는 불필요한 레이아웃 재계산)이 생긴다.

이걸 줄이려면 getBoundingClientRect() 한 번 호출로 필요한 여러 레이아웃 속성을 한꺼번에 가져오는 식으로 호출 횟수를 줄여야 한다.

function getOffsetWidthWithoutTriggeringReflow(element) {
  const rect = element.getBoundingClientRect();
  const { width, height } = rect;
  return { width, height };
}
 
const element = document.querySelector(".myElement");
const { width } = getOffsetWidthWithoutTriggeringReflow(element);
console.log(width);

읽기 작업을 모아서 먼저 처리하고, 쓰기 작업을 그다음에 일괄로 처리하는 식으로 묶어 두는 패턴도 같은 맥락이다.

querySelector와 getElementById의 탐색 비용은 다르다

DOM에서 엘리먼트를 찾을 때 두 방식의 비용이 다르다는 걸 짚고 갔다.

querySelector는 호출 시 브라우저가 전체 문서 트리에서 일치하는 엘리먼트를 검사한다. 문서 상단부터 하단까지 탐색하는 과정이라, 문서가 크고 구조가 복잡하면 오래 걸린다. 시간 복잡도는 일반적으로 O(n)이 될 수 있다(n은 탐색 대상이 되는 DOM 트리의 엘리먼트 수). 단, 단순 #id 선택자처럼 단순한 케이스는 브라우저 엔진이 ID 맵으로 빠르게 처리하기도 한다.

getElementById는 id가 고유하다는 전제가 있어 유효성 검사가 필요 없다. 사실상 O(1)에 가깝다. 그래서 id로 한 번에 찍을 수 있는 경우는 굳이 querySelector를 쓸 이유가 없다.

실제 DOM이 풀어야 하는 두 가지 문제

가상 DOM이 왜 필요한지 보려면 실제 DOM이 어떤 부담을 안고 있는지부터 봐야 한다.

성능 — 직접 수정마다 리플로우가 따라온다

엘리먼트 추가나 제거, 텍스트나 속성 업데이트 같은 실제 DOM 직접 수정이 일어나면 브라우저는 레이아웃을 다시 계산하고 리플로우가 발생한다. 변경마다 매번 일어나면 비용이 빠르게 쌓인다.

리플로우 최소화는 결국 "읽기와 쓰기 작업을 종류별로 묶어 일괄 처리"하는 방향으로 간다.

브라우저 호환성 — SyntheticEvent로 차이를 덮는다

특정 DOM 엘리먼트와 속성을 지원하지 않는 브라우저가 있을 수 있다. 리액트는 합성 이벤트 시스템(SyntheticEvent)으로 이 차이를 추상화해준다.

SyntheticEvent는 브라우저의 기본 이벤트를 둘러싼 래퍼 객체로, 여러 브라우저에서 일관성을 보장하도록 설계됐다. 사용자가 직접 addEventListener로 핸들러를 붙이지 않는다. React 17 이전에는 이벤트 핸들러를 document 노드 한 곳에 위임했고, React 17부터는 React 트리가 렌더링되는 루트 DOM 컨테이너에 위임한다. 브라우저의 네이티브 이벤트가 필요하면 event.nativeEvent로 접근할 수 있다.

// 리액트 사용하지 않는 경우 (구 IE 호환용 레거시 폴백)
const targetElement = event.target || event.srcElement;
 
// 리액트 사용하는 경우
function handleClick(event) {
  const target = event.target;
  // ...
}

여기서 event.srcElement는 구 IE용 비표준 속성이었다. 지금은 모든 모던 브라우저가 표준 event.target을 지원하므로 폴백은 더 이상 필요 없다. 그래서 리액트 안에서는 그냥 event.target만 쓰면 된다.

DocumentFragment — 변경을 모아 한 번에 커밋한다

가상 DOM 동작 원리를 보다가 결이 비슷한 브라우저 기본 API가 있어서 같이 짚었다. DocumentFragment다.

DOM 노드를 저장하는 가벼운 컨테이너인데, 기본 DOM에 영향을 주지 않고 여러 업데이트를 모아둘 수 있는 임시 저장소처럼 동작한다.

  • 업데이트가 끝난 문서 조각을 DOM에 추가하면 리플로우와 리페인팅이 1회만 발생한다.
  • 문서 조각에 추가된 노드는 문서 조각을 DOM에 붙이는 시점에 그쪽으로 이동된다(DocumentFragment에서 빠져나가 부모로 이동). 이 동작 때문에 문서 재정렬 시 메모리 사용량을 최적화하기에 좋다.
  • 문서 조각 자체는 활성화된 문서 DOM 트리에 속하지 않는다. 그래서 안에서 노드를 바꿔도 실제 문서에는 영향이 없고, 스타일 재계산이나 스크립트 중복 실행도 일어나지 않는다.
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
  const li = document.createElement("li");
  li.textContent = `목록 항목 ${i + 1}`;
  fragment.appendChild(li);
}
document.getElementById("myList").appendChild(fragment);

여러 변경을 한 번에 커밋한다는 발상이 React의 가상 DOM 일괄 업데이트와 닮았다. 단, React가 내부적으로 DocumentFragment를 사용하지는 않는다. React는 Fiber 노드 트리에서 변경을 모았다가 커밋 단계에서 한 번에 실제 DOM에 반영한다.

가상 DOM이 실제로 하는 일

가상 DOM은 브라우저마다 다른 실제 DOM 구현 차이를 추상화한 일관된 API를 제공한다. 그래서 엘리먼트를 만들고 업데이트하는 환경이 한결 깔끔해진다. 예를 들어 document.appendChild가 다른 실행 환경에서 다르게 동작해도, JSX와 가상 DOM을 사용하면 그 차이를 신경 쓰지 않아도 된다.

리액트 엘리먼트는 그냥 객체다

React.createElement(type, props, ...children) 시그니처로 React 엘리먼트 객체({ type, props, key, ref })를 만든다. children은 가변 인자다.

createElement 자체는 함수 컴포넌트를 호출하지 않는다. 함수 컴포넌트를 호출해 자식 엘리먼트를 얻는 동작은 렌더링 단계(재조정)에서 일어난다. React가 엘리먼트 트리를 재귀적으로 처리하다가 type이 함수 컴포넌트면 그때 호출해 반환된 엘리먼트로 자리를 채운다.

자식 자리의 문자열, 숫자(스칼라 값)는 텍스트 노드로 렌더링된다. null, undefined, true, false는 빈 노드로 처리되어 아무것도 렌더링되지 않는다.

재조정 프로세스

리액트 컴포넌트가 렌더링되면 새 가상 DOM 트리를 만들고 이전 가상 DOM 트리와 비교한다. 그리고 이전 트리를 새 트리와 일치하도록 업데이트하는 데 필요한 최소 변경 횟수를 계산한다.

가상 DOM은 결국 "실제 DOM을 직접 두드리지 말고, 객체 트리에서 먼저 계산하고 한 번에 커밋하자"는 발상이다. 리플로우 비용을 줄이는 DocumentFragment 패턴, 브라우저 차이를 덮는 SyntheticEvent까지 같은 결로 묶이는 게 인상적이었다.