본문으로 건너뛰기
Tech Blog

Virtual DOM이 왜 필요할까요

글 복사 완료!

Virtual DOM이 왜 있고 무엇으로 이루어져 있는지 짚어봐요.

·7분·

처음 React를 배울 때 Virtual DOM이 빠르다고 들었어요. 브라우저 DOM 조작이 느리니까, 가벼운 JS 객체 트리로 먼저 계산한 다음 바뀐 곳만 DOM에 반영한다고요. 그럴듯했습니다. 근데 막상 react.dev를 한참 뒤져도 "Virtual DOM"이라는 단어 자체가 거의 안 나와요. Reconciliation이라는 용어가 나오고, render tree나 element 같은 이름이 그 자리를 대신하고 있어요.

이유는 Virtual DOM이 빠름을 위한 장치가 아니라는 데 있어요.

"DOM이 느려서"라는 오해

"DOM 조작이 느려서 VDOM이 필요하다"는 설명은 어느 정도는 맞고 어느 정도는 빗나가요. 맞는 건 브라우저가 DOM 노드를 수정할 때 레이아웃과 페인트까지 다시 도는 일이 드물지 않다는 거예요. 빗나간 건 React가 DOM보다 빠른 뭔가를 발명한 게 아니라는 점이죠.

react.dev Render and Commit 문서는 이렇게 말해요.

"React only changes the DOM nodes if there's a difference between renders."

풀어 쓰면, React는 렌더 사이에 달라진 노드만 건드린다는 뜻이에요. 직접 DOM을 조작하는 코드를 대신 쓰는 게 아니라, 건드릴 자리를 추려내는 게 핵심이죠. Virtual DOM의 존재 이유가 "DOM을 우회"에서 "최소 변경점 계산"으로 넘어가는 지점이에요.

React element는 DOM이 아니다

그럼 React가 쏟아내는 그 객체는 뭘까요.

createElement 레퍼런스를 보면 답이 나와요. React element는 type, props, ref, key를 가진 평범한 JS 객체예요.

const element = {
  type: 'h1',
  props: { className: 'title', children: '안녕' },
  ref: null,
  key: null,
}

공식 문서는 이걸 "lightweight description"이라고 부르는데, 굳이 옮기면 "가벼운 설명서" 정도 돼요. 여기서 주목할 지점은 이 객체를 만들었다고 컴포넌트가 실행되는 게 아니라는 사실이에요. 아무 DOM 노드도 생기지 않아요. 나중에 React가 이 설명서를 읽고 "그럼 이제 그려볼까"를 결정하거든요.

이 객체 트리가 우리가 보통 Virtual DOM이라고 부르던 것이에요. 실제 DOM을 복제한 것도, 더 빠른 뭔가도 아니에요. React에게 넘겨주는 명령서에 가깝죠.

변한 곳만 건드리는 render-and-commit

React의 렌더링은 두 단계로 나뉘어요. 먼저 Render 단계에서 React가 컴포넌트 함수를 호출해요. 이때 나오는 게 방금 본 element 객체 트리예요. 그 다음 Commit 단계에서 React는 이 트리를 이전 렌더와 비교해 달라진 곳만 실제 DOM에 반영해요.

여기서 개발자가 직접 DOM을 건드리지 않는다는 사실이 핵심이에요. React가 호출자고, 우리는 "이번엔 이런 모양이어야 해요"라고 설명서만 던지는 셈이거든요. react.dev Rules of React가 이 지점을 분명히 해요.

"React is responsible for rendering components and Hooks when necessary."

React가 호출 시점까지 쥐고 있기 때문에 지난번 설명서와 이번 설명서를 비교할 수 있어요. 비교가 가능하다는 게 중요하죠. 개발자가 setText 같은 걸 직접 호출해 화면을 갱신한다면, React는 뭐가 바뀌었는지 알 길이 없거든요.

<input>에 포커스가 있을 때 이 이야기가 확 와닿아요. 주변 UI가 다시 렌더돼도 input 자체는 건드리지 않기 때문에 포커스가 유지돼요. 설명서가 이전과 같다는 걸 React가 확인했으니까요. 리렌더 자체를 줄이고 싶다면 useMemo 같은 도구가 따로 있지만, 그건 이 층위 위의 이야기예요.

두 가정으로 줄인 비교 비용

두 트리를 비교하는 건 원래 비싼 작업이에요. 일반적인 tree diff 알고리즘은 노드 수 n에 대해 O(n³)이라서, 1,000개 노드면 10억 번 비교가 필요해요. 이걸 그대로 매 렌더마다 돌리면 React는 그냥 안 쓰는 게 나을 정도죠.

legacy.reactjs.org Reconciliation 문서는 React가 어떻게 이걸 O(n)까지 줄였는지 설명해요. 두 가지 가정을 걸었거든요.

첫 번째 가정은 타입이 다르면 트리가 다르다는 거예요. <div><section>으로 바뀌면 그 아래 전부를 새로 만들어요. 비교를 아예 시도하지 않고요. 같은 타입이면 props만 비교하고 자식 트리로 내려가는 방식이에요.

"Two elements of different types will produce different trees."

두 번째 가정은 개발자가 key로 힌트를 준다는 거예요. 리스트를 그릴 때 React는 순서가 아니라 key로 같은 노드인지 판별해요. array index를 key로 쓰면 이 가정이 깨져요. 리스트 중간에 삽입이 일어날 때 엉뚱한 노드가 재사용돼서 state가 튀거든요.

이 두 가정 덕분에 설명서 트리 비교가 실시간 렌더마다 돌아갈 정도로 가벼워졌어요. Virtual DOM이라는 중간 계층이 성립하는 건 이 휴리스틱이 있어서죠. 설명서 트리만 있고 비교 알고리즘이 비싸면 의미가 없으니까요.

Virtual DOM이라는 말이 희미해진 이유

react.dev를 자세히 보면 "virtual DOM"이라는 표현이 거의 사라졌어요. Render and Commit 문서뿐 아니라 상태와 규칙을 설명하는 문서에서도 그 단어가 안 나와요. 대신 "render tree"나 "reconciliation", "React elements" 같은 이름이 그 자리에 들어가 있어요.

이유는 Virtual DOM이라는 말이 설계의 본질을 가렸기 때문인 것 같아요. "DOM의 가상 버전"이라고 하면 사람들은 더 빠른 DOM을 상상하게 돼요. 실제로는 declarative UI 모델을 위한 중간 표현이고, 비교 알고리즘을 올려놓기 위한 데이터 구조죠. 이름을 바꾸니까 "왜 중간 계층이 있냐"는 질문이 "이 객체 트리를 어떻게 쓰냐"로 자연스럽게 옮겨가요.

Preserving State 문서에 나오는 한 줄이 이걸 잘 설명해요. React에게 중요한 건 JSX 마크업 순서가 아니라 UI 트리에서의 위치라는 이야기예요. 성능이 아니라 identity 모델에 가까운 이야기죠. 컴포넌트의 신원을 무엇으로 정의할지 말이에요. Virtual DOM은 이 identity를 담는 그릇으로 의미가 있어요. DOM을 돌려 달리게 하는 장치가 아니라요.

결국 우리가 React로 뭔가를 만들 때 실제로 다루는 건 설명서 트리예요. DOM 노드 수 자체가 병목인 경우는 또 다른 문제고, 거기엔 가상 리스트 같은 별도 기술이 붙어요. Virtual DOM이 푸는 건 그 앞 단계의 문제죠. 선언적으로 쓴 UI를 어떻게 효율적으로 반영할 것인가, 그게 Virtual DOM이 푸는 문제예요.

참고 자료

관련 글