[React DeepDive] React의 가상 DOM
[React DeepDive] React의 가상 DOM
리액트의 특징 중 하나는 실제 DOM이 아닌 가상 DOM을 운영한다는 것이다. 이번 글에서는 React DeepDive에서 다루는 가상 DOM이 무엇인지, 그리고 실제 DOM에 대해 어떤 이점이 있는지 살펴보고, 가상 DOM을 다룰 때 주의할 점에 대해서도 다루려고 한다.
DOM과 브라우저 렌더링 과정
DOM: 웹페이지에 대한 인터페이스로 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 담고 있다.
브라우저가 웹사이트 접근 요청을 받고 화면을 그리는 과정은 다음과 같다.
-
브라우저가 사용자 요청한 주소에서 HTML파일을 다운로드한다.
-
브라우저의 렌더링 엔진이 HTML을 파싱해 DOM노드로 구성된 트리 즉,
DOM을 만든다. -
2번 과정에서 CSS파일을 만나면 해당 CSS 파일도 다운로드한다.
-
브라우저의 렌더링 엔진이 이 CSS역시 파싱해 CSS 노드로 구성된 트리 즉,
CSSOM을 만든다 -
브라우저는 DOM 노드를 순회하는데 모든 노드가 아닌, 사용자 눈에 보이는 노드만(display:none과 같은 노드는 방문 x) 방문한다.
-
눈에 보이는 노드를 대상으로 해당 노드에 대한 CSSOM 정보를 찾고 여기서 발견한 CSS 스타일 정보를 이 노드에 적용한다.
레이아웃: 각 노드가 브라우저 화면의 어느 좌표에 정확히 나타나야 하는지 계산하는 과정이며, 이 과정을 거치면 페인팅 과정도 거치게 된다.페인팅: 레이아웃 단계를 거친 노드의 색과 같은 실제 유효한 모습을 그리는 과정
가상 DOM의 탄생 배경
웹페이지를 추가로 렌더링 하는데 드는 비용
앞에서 살펴본 브라우저가 웹페이지를 렌더링하는 과정은 매우 복잡하고 많은 비용이 든다.
또한 정보를 보여주는데 그치지 않고 사용자의 인터렉션을 통해 다양한 정보를 노출해야 하기에, 렌더링이 완료된 이후에도 사용자의 인터렉션으로 웹페이지가 변경되는 상황 또한 고려해야 한다.
이 과정에서 예를 들어,
-
특정 요소의 색상이 변경되는 경우: 페인팅만이 일어나서 빠른처리가 가능하다.
-
특정 요소의 노출 여부나 사이즈가 변경되는 경우: 레이아웃이 일어나고, 레이아웃은 필연적으로 리페인팅을 발생하기 때문에 더 많은 비용이 든다.
-
DOM 변경이 일어나는 요소가 자식 요소를 많이 가지고 있는 경우: 하위 자식 요소 역시 변경되야 해서 더 많은 비용 지불
이러한 추가 렌더링 작업은 하나의 페이지에서 모든 작업이 일어나는 싱글 페이지 애플리케이션(SPA)에서 더 많아진다.
페이지가 변경될 때 처음부터 HTML을 새로 받아서 다시금 렌더링 과정을 시작하는 일반적인 웹페이지와는 다르게, 하나의 페이지에서 계속해서 요소의 위치를 재계산하게 된다.
그러므로 라우팅이 변경되는 경우 고정된 헤더와 같은 요소들을 제외하고 대부분의 요소를 삭제, 삽입 및 요소의 위치를 다시 계산해야 하므로, DOM을 관리하는 과정에서 부담하는 비용이 커진다.
가상 DOM의 탄생
사용자의 인터렉션에 따라 DOM의 모든 변경 사항을 추적하는 것은 개발자에게는 너무나 수고스러운 일이다. 그렇기에 모든 DOM의 변경보다 결과적으로 만들어지는 DOM 결과물 하나만 아는 것이 개발자의 입장에서 더 유용할 것이다.
이것을 해결하기 위해 탄생한 것이 바로 가상 DOM이다.
가상 DOM은 실제 브라우저가 아닌 리액트가 관리하는 가상의 DOM을 의미한다.
가상 DOM은 웹페이지가 표시해야 할 DOM을 일단 메모리에 저장하고, 리액트가 실제 변경에 대한 준비가 완료되었을 때, 실제 브라우저의 DOM에 반영한다.
이렇게 DOM 계산을 메모리에서 계산하는 과정을 한 번 거치게 된다면 실제로는 여러 번 발생했을 렌더링 과정을 최소화하여 부담을 덜 수 있다.
가상 DOM은 일반적인 브라우저보다 무조건 항상 빠르지는 않다.
무조건 빠른 것이 아닌, 대부분의 상황에서 웬만한 애플리케이션을 만들 정도로 충분히 빠르다고 보는 것이 옳다.
가상 DOM을 위한 아키텍처, 리액트 파이버
가상 DOM과 렌더링 과정 최적화를 가능하게 해주는 것이 리액트 파이버이다.
리액트 파이버는 리액트에서 관리하는 평범한 자바스크립트 객체이다.
파이버는 파이버 재조정자(fiber reconciler)가 관리하는데, 가상 DOM과 실제 DOM을 비교하여 변경에 관련된 정보를 가지고 있는 파이버를 기준으로 화면에 렌더링을 요청하는 역할을 한다.
재조정(reconcilation): 리액트에서 어떤 부분을 새로 렌더링 해야하는지 가상 DOM과 실제 DOM을 비교하는 과정
파이버는 애니메이션,레이아웃,사용자 인터랙션에 올바른 결과물을 만드는 반응성 문제를 해결하며, 다음과 같은 일을 할 수 있다.
-
작업을 작은 단위로 쪼개고 우선순위를 매긴다.
-
이 작업들은 일시 정지 및 다시 시작이 가능하다.
-
이전 작업을 재사용 하거나 필요 없는 경우에는 폐기할 수 있다.
이러한 모든 과정은 비동기로 일어난다. 과거에는 이러한 조정 알고리즘이 동기적인 스택 알고리즘으로 이뤄져 있었고, 동기적으로 작업이 이뤄졌기에 자바스크립트의 싱글 스레드의 특징 상 수행 중인 작업은 중단될 수 없었다. 이러한 문제 때문에 리액트 팀은 스택 조정자 대신 파이버라는 개념을 탄생시킨다.
파이버는 어떻게 구현되어 있을까?
파이버는 일단 하나의 작업 단위로 구성되어 있다. 리액트는 작업 단위를 하나씩 처리하고, finishWork( ) 라는 작업으로 마무리한다. 그리고 이 작업을 커밋해 브라우저 DOM에 가시적인 변경 사항을 만들어 낸다.
이러한 단계는 두 단계로 나뉘는데,
렌더 단계: 사용자에게 노출되지 않는 모든 비동기 작업을 수행하고, 우선 순위를 지정하거나 중지시키거나 버리는 등의 작업이 일어난다.커밋 단계: DOM에 실제 변경 사항을 반영하기 위한 작업, commitWork( )가 실행되는데, 동기식으로 일어나고 중단될 수 없다.
파이버는 실제 리액트 코드에서 이렇게 구현되어 있다.
function FiberNode(tag, pendingProps, key, mode) {
// Instance
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;
// Fiber
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// Effects
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
this.expirationTime = NoWork;
this.childExpirationTime = NoWork;
this.alternate = null;
}
위와 같이 파이버는 단순한 자바스크립트 객체로 구성되어 있다.
파이버와 리액트 요소의 한가지 중요한 차이점은 리액트 요소는 렌더링이 발생할 때 마다 새롭게 생성되지만, 파이버는 컴포넌트가 최초로 마운트 되는 시점에 생성되어 가급적이면 재사용된다는 것이다.
function createFiber(tag, pendingProps, key, mode) {
return new FiberNode(tag, pendingProps, key, mode);
}
function createFiberFromElement(element, mode, expirationTime) {
let owner = null;
const type = element.type;
const key = element.key;
const pendingProps = element.props;
const fiber = createFiberFromTypeAndProps(
type,
key,
pendingProps,
owner,
mode,
expirationTime,
);
return fiber;
}
이제 여기서 선언된 주요 속성을 살펴보면서 어떤 내용을 담고 있는지 살펴보자.
-
tag: 파이버는 하나의 element에 하나의 파이버가 생성되어 1:1 관계를 가진다. 여기서 1:1로 매칭된 정보를 가지고 있는 것이 tag필드이다. 연결되는 것은 컴포넌트, DOM노드 등이 될 수 있는데, 될 수 있는 것들은 다음과 같다. -
stateNode: 이 속성은 파이버 자체에 대한 참조 정보를 가지고 있다. -
child,sibling,return: 파이버 간의 관계 개념을 나타내는 속성이다. 파이버는 트리 형식을 구성하는데 이 트리 형식을 구성하는데 필요한 정보가 이 속성 내부에 적용된다. 한 가지 리액트 컴포넌트 트리와 다른 점은 children이 없다는 것 즉 단 하나의child만이 존재한다는 것이다.여러 개의 자식이 존재할 경우 항상 첫 번째 자식의 참조로 구성되며, 나머지는 자식들은 동등한
sibling으로 구성,return은 부모 파이버를 의미한다. -
index: 여러 형제들 사이에서 자신의 위치를 나타낸다. -
pedingProps: 아직 처리하지 못한 props -
memoizedProps: pendingProps를 기준으로 렌더링이 완료된 이후에 pendingProps를 memoizedProps에 저장하여 관리한다. -
updateQueue: 상태 업데이트, 콜백 함수, DOM 업데이트 등 작업을 담아두는 Queue. -
memoizedState: 함수 컴포넌트의 훅 목록이 저장된다. -
alternate: 뒤이어 설명할 리액트 파이버 트리와 이어질 개념. 리액트의 트리는 두 개인데, alternate는 반대 트리의 파이버를 가리킨다.
이렇게 생성된 파이버는 state가 변경되거나 생명주기 메서드가 실행되거나 DOM의 변경이 필요한 시점 등에 실행된다. 이러한 작업들은 작은 단위로 나눠서 처리할 수도, 애니메이션과 같이 우선순위가 높은 작업은 빨리 처리하거나, 낮은 작업을 연기시키는 등 좀 더 유연하게 처리된다.
리액트 개발 팀은 사실 리액트는 가상 DOM이 아닌 Value UI, 즉 값을 가지고 있는 UI를 관리하는 라이브러리라는 내용을 피력한 바가 있다.
즉, 리액트의 핵심원칙은 UI를 문자열, 숫자, 배열과 같은 값으로 관리한다는 것이다. 변수에 이러한 UI관련 값을 보관하고, 리액트의 자바스크립트 코드 흐름에 따라 이를 관리하고, 표현하는 것이 바로 리액트다.
파이버 트리
파이버 트리는 하나는 현재 모습을 담은 파이버 트리, 다른 하나는 작업 중인 상태를 나타내는 workInProgress 트리다. 작업이 끝나면, 리액트는 단순히 포인터만 변경해 workInProgress 트리를 현재 트리로 바꿔버린다. 이러한 기술을 더블 버퍼링이라고 한다.

리액트에서는 미처 다 그리지 못한 모습을 노출시키지 않기 위해 (불완전한 트리를 보여주지 않기 위해) 더블 버퍼링 기법을 쓰는데, 이러한 더블 버퍼링을 위해 트리가 두 개 존재하며, 이 더블 버퍼링은 커밋 단계에서 수행된다.
- 먼저 현재 UI 렌더링을 위해 존재 하는 current를 기준으로 모든 작업이 시작된다.
- 업데이트가 발생하면 파이버는 리액트에서 새로 받은 데이터로 새로운 workInProgress 트리를 빌드하기 시작한다.
- 빌드하는 작업이 끝나면 다음 렌더링에 이 트리를 사용한다.
- 빌드된 workInProgress 트리가 UI에 최종적으로 렌더링되어 반영이 완료되면 current가 이 workProgress로 변경된다.
파이버의 작업 순서
일반적인 파이버 노드의 생성흐름은 다음과 같다.
- 리액트는
beginWork()함수를 실행해 파이버 작업을 수행하고, 더 이상 자식이 없는 파이버를 만날 때까지 트리 형식으로 시작된다. - 1번의 작업이 끝나면
completeWork( )함수를 실행해 파이버 작업을 완료한다. - 형제가 있다면 형제로 넘어간다.
- 2,3번이 끝난다면
return으로 돌아가 자신의 작업이 완료되었음을 알린다.
이러한 작업으로 트리가 생성이 되었는데, setState 등으로 업데이트가 발생하면 어떻게 될까? 이미 앞서 만든 current트리가 존재하고, setState로 업데이트 요청을 받아 workInProgress트리를 다시 빌드하기 시작한다.
최초 렌더링 시에는 모든 파이버를 새로 만들어야 했지만 이제는 파이버가 이미 존재하므로 되도록 새로 생성하지 않고, 기존 파이버에서 업데이트 된 props를 받아 파이버 내부에서 처리한다.
이처럼, 재조정 작업 때마다 새롭게 파이버 자바스크립트 객체를 만드는 것 이 아닌, 기존의 객체를 재활용하기 위해 내부 속성값만 초기화하거나 바꾸는 형태로 트리를 업데이트한다.
과거에는 이 작업을 동기식으로 처리했고, 트리 업데이트 과정 및 새로운 트리를 만드는 작업은 동기식이고 중단될 수 없다. 그러나 현재는 우선순위가 높은 다른 업데이트가 오면 현재 업데이트 작업을 일시 중단하거나 새로 만들거나 폐기할 수 있으며 작업 단위를 파이버 단위로 나누어 우선순위를 할당하는 것 역시 가능하다.
파이버와 가상 DOM
리액트 컴포넌트에 대한 정보를 1:1로 가지고 있는 것이 파이버이며, 이 파이버는 리액트 아키텍처 내부에서 비동기로 이뤄진다. 이와 달리 실제 브라우저 구조인 DOM에 반영하는 것은 동기적으로 이뤄져야 하기에, 메모리 상에서 이 작업을 먼저 수행해서 최종적인 결과물만 실제 브라우저 DOM에 적용하는 것이다.
사실 가상 DOM은 오직 웹 어플리케이션에서만 통용되는 개념이고, 리액트 파이버는 리액트 네이티브와 같은 브라우저가 아닌 환경에서도 사용할 수 있기에 엄밀히 하면 파이버와 가상 DOM은 동일한 개념이 아니다.
정리
결국 가상 DOM과 리액트의 핵심은 브라우저의 DOM을 더욱 빠르게 그리고 반영하는 것이 아니라 바로 값으로 UI를 표현하는 것이다.
화면에 표시되는 UI를 자바스크립트의 문자열, 배열 등과 마찬가지로 값으로 관리하고 이러한 흐름을 효율적으로 관리하기 위한 메커니즘이 바로 리액트의 핵심이다.