React

리액트 Virtual DOM(가상 돔)

GoJay 2024. 11. 20. 01:43

Virtual DOM(가상 돔)이란?

Virtual DOM이란 직역한 대로 '가상의 돔'이다. 이러한 정의를 보면 당연하게도 'DOM은 무엇인가?'라는 물음이 따를 것이다.

웹 페이지는 하나의 문서다. .docx 확장자 문서를 워드 프로그램이 실행하고, .hwp 확장자 문서를 한글 프로그램이 실행하는 것처럼, .html 또는 .xml 확장자의 문서를 브라우저가 해석하고 화면에 보여준다. 브라우저가 해석하는 문서는 사용자와의 인터랙션에 특화되어 있다는 점과, 서버와의 통신이 가능하다는 점 때문에 일반 문서와 다르게 느껴질 수 있다. 하지만, 사실 모든 웹 페이지의 실체는 문서이다.

특별히 브라우저는 HTML 또는 XML로 작성된 문서에 자바스크립트 언어가 접근하여 조작할 수 있도록 객체로 변환(파싱)해주는데, 그렇게 객체로 변환된 문서의 요소를 트리 구조로 구성해 놓은 것이 DOM이다(참고: MDN 문서).

DOM에 대한 더 자세한 설명은 생략하겠다. 어찌 됐건 DOM은 브라우저에 표현될 문서 내용에 자바스크립트가 접근하고 조작할 수 있도록 제공되는 인터페이스이고, 자바스크립트는 DOM을 통해 화면에 표시되는 내용들을 제어한다는 게 중요하다.

Virtual DOM은 메모리 상에만 존재하는 자바스크립트 객체 형태의 DOM 복사본이다. 리액트 공식 문서에서는 'Virtual DOM은 특정 기술이라기보다는 패턴에 가깝기 때문에 사람들마다 의미하는 바가 다르다'라고 했는데, 이러한 정의를 생각해 보면 단순 'DOM의 복사본'이라기 보단, 리액트 Element의 변화를 감지해서 실제 DOM과 동기화시키기 위한 추상적인 방법론(패턴)이라고 하는 게 좀 더 정확할 수도 있겠다. 아무튼 어려운 개념이지만, 리액트가 DOM을 제어하기 위해 Virtual DOM이라는 컨셉을 사용한다라는 건 꼭 기억하자.

Virtual DOM이 필요한 이유

사용자가 브라우저에 있는 화면을 제어하면 자바스크립트는 페이지 전환, 데이터 요청, 이벤트 처리 등 사용자의 입력에 맞춰 DOM을 업데이트해준다. 예를 들어 의류 커머스 서비스에서 사용자가 어떠한 옷 상품에 대한 상세 정보 페이지를 클릭하면 그 페이지에 대한 정보들을 화면에 보여주는 처리를 해주게 된다.

그런데, DOM을 업데이트해서 브라우저의 화면을 다시 그려준다는 것은 꽤 비싼(많은 컴퓨터 리소스와 시간을 잡아먹는) 작업이다(DOM 업데이트 과정의 비용에 대해 상세히 이해하기 위해 브라우저의 렌더링 과정에 대해 잘 공부해 둘 필요가 있다).

그래서, 좋은 성능의 웹 애플리케이션을 만들기 위해선 DOM 업데이트를 최소화해주는 게 좋다. 리액트는 DOM 업데이트를 최소화해서 웹 애플리케이션의 성능을 최적화하려고 시도했고, 이러한 배경에서 Virtual DOM이 등장했다.

Virutal DOM의 컨셉

Virtual DOM은 메모리 상에만 존재하는 자바스크립트 객체라고 했다(메모리에 저장되는 자바스크립트 객체를 통해 DOM을 조작하는 어떠한 패턴 정도로 해석할 수도 있다). 해당 객체에는 실제 DOM의 요소(Element)와 속성(Attribute)에 대한 정보가 있을 것이다. 추상화해 보면 아래와 같다(출처: The Virtual DOM).

const prevVirtualDOM = {
  tagName: 'ul',
  attributes: { class: 'list' },
  children: [
    {
      tagName: 'li',
      attributes: { class: 'list__item' },
      textContent: 'list item 1'
    }
  ]
}

이렇게 메모리 상에 DOM 정보를 저장해 둔 상태에서, 만약 특정 컴포넌트에서 관리하는 상태에 변경이 감지되면 변경된 내용을 반영해 Virtual DOM을 추가로 생성한다.

const curVirtualDOM = {
  tagName: 'ul',
  attributes: { class: 'list' },
  children: [
    {
      key: 0,
      tagName: 'li',
      attributes: { class: 'list__item' },
      textContent: 'list item 1'
    },
    {
      key: 1,
      tagName: 'li',
      attributes: { class: 'list__item' },
      textContent: 'list item 2'
    }
  ]
}

이제 리액트는 기존 DOM 상태를 나타내는 prevVirtualDom과 새로운 상태 변화가 반영된 curVirtualDOM까지 두 개의 Virtual DOM을 갖게 된다. 이 둘을 비교하면 DOM에서 어떤 부분이 변경되었는지를 알 수 있을 것이다(참고로, 예시에서 Virtual DOM에 각각 붙인 이름은 임의의 이름이다).

두 개의 Virtual DOM을 비교해 변경된 부분을 찾는 과정을 Diffing이라고 하며, Virtual DOM을 비교해 DOM에서 리렌더링이 필요한 컴포넌트들을 트리 구조 상에서 파악하는 과정을 reconciliation(재조정)이라고 한다. 해당 과정을 통해 변경 사항이 파악됐다면, 변경 사항이 존재하는 컴포넌트에서 시작해 그 하위에 있는 컴포넌트들의 변경 사항을 실제 DOM에 반영(Commit; 커밋)한다. 해당 과정은 변경이 발생한 일부 컴포넌트만을 선택적으로 바꾸기 때문에 DOM 전체를 리렌더링 하는 것 보다 훨씬 경제적이다.

이렇게 Reconcilation 작업이 완료되고, 과거의 DOM 상태를 저장해 두던 prevVirtualDOM이 더 이상 불필요해지면 메모리에서 해제(Garbage Collecting)되고, 변경 사항이 반영된 Virtual DOM이 prevVirtualDOM이 된다. 이렇게, 리액트는 비싼 비용을 지불해야 하는 DOM 수정을 필요한 부분만 선택적으로 실행하여 웹 애플리케이션의 성능을 올리고 있다.

또한, 상태가 변경될 때 Virtual DOM을 생성해서 기존 Virtual DOM과 비교하고, 변경된 부분을 DOM에 반영하는 작업을 배치(Batch)로 처리한다. '배치로 처리한다'는 것은 하나의 상태가 한 번 변경될 때마다 매번 DOM을 변경해 주는 것이 아니라, (리액트 내부에 설정되어 있는) 일정 작업 단위의 상태 변경이 쌓이면 해당 변경 사항들을 한 번에 반영해서 DOM을 바꿔준다는 의미다. 당연히 낱개의 변화를 따로 떼서 Diffing 연산을 수행하거나 개별로 DOM을 바꿔주지 않고, 여러 작업을 묶어서 한 번에 처리하기 때문에 연산 횟수를 훨씬 더 줄일 수 있다(렌더링 비용을 아낄 수 있다).

Virtual DOM의 Diffing 알고리즘

두 개의 Virtual DOM을 비교해서 상태의 변화가 생긴 컴포넌트를 탐색하는 과정을 Diffing이라고 했다. 그런데, 웹 애플리케이션이 복잡해지고 컴포넌트의 깊이가 깊어질수록 트리 구조로 형성된 DOM 요소들의 상태 변화를 확인하기 위해 탐색하는 비용이 늘어난다. 찾아보니 두 개의 트리 구조를 비교해서 탐색하는 작업의 시간 복잡도는 O(N^3)이라고 한다.

Diffing에 이렇게 많은 시간과 연산 비용을 사용하면 렌더링 비용을 아끼겠다는 명분이 희미해질 수밖에 없다. 그래서, 리액트는 조금 더 경제적으로 이전과 현재의 Virtual DOM 상태를 비교하기 위한 알고리즘을 선택했다.

현재의 리액트는 Diffing의 시간을 최적화하기 위해 상대적으로 합리적이라 판단할 수 있는 기준을 세워서 비교하는 알고리즘이 적용되어 있고, 알고리즘이 적용되는 데 있어 두 가지 전제를 사용한다.

  • 이전 트리와 새로운 트리에서 타입이 같은 노드끼리만 비교한다. 예를 들어, <div><span>은 서로 다르다고 간주하고 완전히 교체한다(이전과 타입이 달라진 React 요소의 하위 노드들은 비교하지 않고 변경된 것으로 간주한다).
  • 형제 중 같은 타입인 요소는 key로 구분하며(e.g. 배열 렌더링), 변경 사항의 비교는 이전과 현재의 Virtual DOM 요소 중 key가 같은 요소끼리 매칭해서 진행한다. key 이외에는 별도의 연산을 통해 형제 중 같은 타입인 요소의 순서 변화, 추가-삭제 등을 검증하는 과정을 거치지 않는다.

이러한 두 가지 알고리즘을 통해, 실제로 리액트 내부 동작 상 Diffing를 수행하는 데 걸리는 시간 복잡도를 O(N)까지 줄일 수 있었다. 리액트가 채택한 이 방식이 변경 사항을 100%의 정확도로 파악하는 알고리즘은 아니지만, 합리적인 수준에서 납득 가능한 정도의 판단을 해준다고 하며, 이러한 최적화 방식을 컴퓨터 과학에서 '휴리스틱 알고리즘'이라고 부른다. 즉, 리액트의 Diffing 알고리즘에는 휴리스틱 알고리즘이 적용되어 있다.

Fiber Architecture

현재 리액트의 Diffing 알고리즘은 Fiber Reconciler(파이버 재조정자)를 사용한 Fiber Architecture 위에서 동작한다. 해당 구조는 리액트 DOM을 업데이트하는 데 필요한 작업들을 작은 단위로 쪼개고(이 단위를 Fiber라고 한다), 각 조각마다 작업의 우선순위를 기반으로 스케줄링해서 처리할 수 있게 해준다.

기존의 리액트 렌더링 과정은 동시성 처리를 충분히 지원하지 못해 유저가 컴포넌트가 마운트 되는 과정에서 어떠한 작업을 수행했을 때 웹 애플리케이션의 버벅거림을 느낀다던가, 애니메이션 요소가 끊기는 등의 아쉬운 사용자 경험을 만드는 요소가 존재했다. DOM 업데이트 작업을 하나의 큰 덩어리로 처리하기 때문에 중간에 사용자 입력이 들어오거나, 화면의 애니메이션을 부드럽게 보여주는 등의 작업을 우선순위를 높여 먼저 처리해 줄 수 없었던 것이다.

하지만 Fiber Architecture는 사용자의 경험에 직접적인 영향을 미치는 사용자 입력 처리(마우스 클릭, 휠 스크롤, 타이핑 입력 등. DOM 업데이트 중간에 사용자 입력이 들어오면 사용자 입력과 관련된 처리를 먼저 해주고 나머지 작업을 지연 처리할 수 있다), 애니메이션, 화면에 바로 보이는 UI 업데이트 등을 높은 우선순위로 먼저 처리한다. 그리고 상대적으로 우선순위가 낮은 현재 보이는 Viewport 외부 영역의 렌더링, 데이터 로드 등의 작업은 우선순위를 낮춰서 지연 실행한다.

현재 리액트의 Virtual DOM Diffing 알고리즘도 Fiber Architecture 위에서 동작하며, Fiber 단위로 나눠진 작업 단위의 우선순위에 따라 변경 사항을 비교해 실제 DOM에 반영하는 작업을 처리해 준다.

사실, Fiber Architecture에 대해 여러 자료들을 찾아봤지만, 현재 시점에서 이해할 수 있는 내용은 이 정도였다. 100% 이해가 안 된 관계로, 이후에 경험이 쌓이고 다시 읽어볼 만한 글 몇 개를 참고해 둔다.

결론

리액트의 Virtual DOM이 무엇인지, 그리고 Virtual DOM이라는 패턴을 사용해 어떻게 리액트가 낮은 비용으로 DOM을 업데이트하는지 알아봤다. 리액트 개발을 공부하면서 여러 자료들에서 계속 강조하는 게 '성능 최적화'에 대한 부분이었다. 그 과정에서 중요한 건, 결국 리액트로 만은 웹 애플리케이션(어쩌면 웹 전체)에서 성능을 최적화하기 위해 관리해야 하는 핵심 요소는 DOM 업데이트(리렌더링)를 최소화하는 것이라고 생각된다. 그를 위한 핵심적인 개념들이 Vritual DOM과 연결되어 있다고 생각되며, 이 내용에 대해선 계속 반복해서 깊이 있게 알아가야겠다.

'React' 카테고리의 다른 글

useState 상태 업데이트와 useEffect 실행 시점  (2) 2024.12.06
BrowserRouter와 createBrowserRouter  (1) 2024.12.03
제어 컴포넌트와 비제어 컴포넌트  (1) 2024.11.25
JSX란?  (3) 2024.11.21
createRoot와 createRoot.render  (0) 2024.11.18