리액트를 사용하는 팀에 면접을 보게 되면 항상 받는 질문인 가상 DOM. 어렴풋이 알고 있는 내용을 정리해보자면,
이 정도의 특징을 알고 있는데, 더 깊숙히 들어가기에는 막연한 두려움도 있고, 리액트 기술자들이 아닌 이상 알 필요가 없다 생각했었다.
최근 원티드에서 프리온보딩 챌린지 강의를 듣다가 파이버(Fiber) 와 재조정(Reconcilication) 에 대해서 듣게 되었다. 파이버는 어렴풋이 들었지만 재조정은 처음 듣는 용어였다. 설명을 보니 diffing과 commit 단계를 아우르는 용어라는 것을 알게 되었다. 이 정도에서 넘어갈 수 있었지만, 강의를 더 듣다보니 파이버와 비교 알고리즘에 대해 관심이 생겼고 리액트 Deep Dive 책을 통해 학습을 해 보았다.
먼저 왜 리액트 팀이 가상 DOM이라는 시스템을 채택했는지 알아보아야 한다. 그러기 위해서는 실제 브라우저의 DOM에서는 어떠한 형식으로 DOM이 형성되고 업데이트 되는지를 알아야 한다.
// styles.css
# text {
background-color: red;
color: white;
}
// index.html
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" type="text/css" href="./style.css" />
<title>Hello React!</title>
</head>
<body>
<div style="width: 100%;">
<div id="text" style="width: 50%;">Hello world!</div>
</div>
</body>
</html>
예시와 같은 코드가 있을 때 브라우저는 다음 순서대로 렌더링한다.
HTML을 다운로드한다. 다운로드와 함께 HTML을 분석하기 시작한다.
스타일시트가 포함된 link 태그를 발견해 style.css를 다운로드한다.
body 태그 하단의 div는 width: 100%이므로 뷰포트로 좌우 100%너비로 잡는다.
3번 하단의 div는 width: 50%, 즉 부모의 50%를 너비로 잡아야 하므로 전체 영역의 50%를 너비로 잡는다.
2번에서 다운로드한 CSS에 id="text"에 대한 스타일 정보를 결합한다.
화면에 HTML 정보를 그리기 위한 모든 정보가 준비됐으므로 위 정보를 바탕으로 렌더링을 수행한다.
브라우저는 이러한 과정을 거쳐 웹페이지를 렌더링한다. 이 때 1번을 통해 DOM Tree를 형성하고 2~5번을 통해 CSSOM Tree 마지막 6번을 통해 Render Tree를 형성한다.
어떠한 요소의 노출 여부가 변경되거나, 사이즈가 변경되는 등 요소의 위치와 크기를 재계산해야 하는 상황이 발생하면, 브라우저는 Render Tree에서 해당 요소의 레이아웃 정보를 다시 계산(Reflow) 하게 된다. 이 과정은 필연적으로 많은 비용이 든다. 또한 DOM 변경이 일어나는 요소가 많을 경우 하위 요소들도 덩달아 변경돼야 하기 때문에 더 많은 비용을 브라우저와 사용자가 지불하게된다.
예를 들어, 현대의 웹 페이지들은 복잡한 레이아웃과 애니메이션을 사용하고 있으며, 요소가 사라지거나 나타나고, 이동해야 할 상황이 많고, 윈도우 사이즈를 조정하거나 요소들의 사이즈가 변화할 일이 많다. 이러한 과정은 Reflow를 유발하고 해당 요소뿐만 아니라 하위 요소들도 재계산해야 하기에 클라이언트에 부하를 유발할 수 있고, 또한, 노드를 삭제하거나 추가 할 경우가 있다면 DOM트리부터 재생성해야하기 때문에 더 큰 부하를 유발할 수 있다.
이러한 문제점을 해결하기 위해 탄생한 것이 바로 가상 DOM이다. 가상 DOM은 말 그대로 실제 브라우저의 DOM이 아닌 리액트가 관리하는 가상의 DOM을 의미한다. 가상 DOM은 웹페이지가 표시해야 할 DOM을 일단 메모리에 저장하고 리액트가 실제 변경에 대한 준비가 완료됐을 때 실제 브라우저의 DOM에 반영한다. 이렇게 DOM 계산을 브라우저가 아닌 메모리에서 계산하는 과정을 한 번 거치게 된다면 실제로는 여러 번 발생했을 렌더링 과정을 최소화할 수 있고 클라이언트의 부담을 덜 수 있다.
여기까지는 기술면접을 위한 공부였다면, 실제로 리액트가 어떻게 처리하고 있을지 알아보겠다.
리액트 파이버는 리액트에서 관리하는 자바스크립트 객체다. 파이버는 파이버 재조정자(fiber reconciler)가 관리한다. 여기서 재조정이란 리액트에서 어떤 부분을 새롭게 렌더링해야 하는지 가상 DOM과 실제 DOM을 비교하는 작업 혹은 알고리즘이라고 이해하면 된다. 리액트 파이버는 다음과 같은 특징이 있다.
여기서 중요한 것은 이러한 모든 과정이 비동기로 일어난다는 것이다. 과거 리액트의 조정 알고리즘은 스택 알고리즘 즉, 스택이 빌 때까지 동기적으로 작업이 이루어졌다. 자바스크립트의 특징인 싱글 스레드라는 점으로 인해 이 동기 작업은 중단될 수 없었기에, 파이버는 비동기로 동작하게끔 설계되었다.
// 리액트 내부 코드에 작성돼 있는 파이버 객체 예시
function Fiber(tag, pendingProps, key) {
this.tag = tag; =
this.key = key;
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.memoizedState = null;
this.return = null;
this.child = null;
this.sibling = null;
this.updateQueue = null;
this.lanes = 0;
this.alternate = null;
this.flags = 0;
this.type = null;
}
파이버는 앞서 말한대로 단순 자바스크립트 객체로 구성돼 있다. 리액트 요소가 생성될때마다 파이버도 리액트의 정보들을 해당 객체로 저장하여 사용한다. 다만, 파이버가 리액트 요소와 다른점은 리액트 요소는 렌더링이 발생할 때마다 새롭게 생성되지만 파이버는 가급적이면 재사용된다.
이렇게 생성된 파이버는 state가 변경되거나 생명주기 메서드가 실행되거나 DOM의 변경이 필요한 시점 등에 실행되고, 리액트가 파이버를 처리할 떄마다 작업을 바로 처리하거나 스케줄링하기도 한다. 애니메이션과 같이 우선순위가 높은 작업은 가능한 한 빠르게 처리하거나, 낮은 작업을 연기시키는 등 좀 더 유연하게 처리된다.
파이버 트리는 리액트 내부에 두 개가 존재하는데, 하나는 현재 모습을 담은 파이버 트리이고, 다른 하나는 작업 중인 상태를 나타내는 workInProgress 트리다. 리액트 파이버의 작업이 끝나면 리액트는 단순히 포인터만 변경해 workInProgress 트리를 현재 트리로 바꿔버린다. 이러한 기술을 더블 버퍼링이라고 한다.
즉, 먼저 현재 UI 렌더링을 위해 존재하는 트리인 current를 기준으로 작업을 시작하고 업데이트가 발생하면 파이버는 리액트에서 새로 받은 데이터로 workInProgress 트리를 빌드하기 시작한다. 이 과정을 비교(diffing) 이라고 한다. 이 workInProgress 트리를 빌드하는 작업이 끝나면 다음 렌더링에 이 트리를 사용한다. workInProgress 트리가 최종적으로 렌더링이 완료되면 current가 이 workInProgress 트리로 변경된다. 이 과정을 커밋(commit) 이라고 한다.
<A1>
<B1></B1>
<B2>
<C1>
<D1 />
<D2 />
</C1>
</B2>
<B3 />
</A1>
다음과 같은 코드가 있다면 파이버트리는 다음과 같이 동작한다.
이러한 트리를 형성한 가운데, setState 등으로 업데이트하게 된다면, workInProgress 트리를 다시 빌드하기 시작한다. 최초 렌더링 시에는 모든 파이버를 새롭게 만들어야 했지만 이제는 파이버가 이미 존재하므로 되도록 새로 생성하지 않고 기존 파이버에서 업데이트 된 props를 받아 파이버 내부에서 처리한다.
이렇게 새로운 파이버를 생성하지 않고 업데이트하는 과정으로 리소스를 아낄 수 있기에 파이버트리가 근간이 되는 가상 DOM은 좀 더 효율적으로 렌더링 될 수 있다. 더군다나 파이버는 우선순위가 높은 작업을 수행하기에 최적의 순위로 작업을 할 수 있다.
리액트는 작업을 파이버 단위로 나눠서 수행한다. 애니메이션과 사용자가 입력하는 작업은 우선순위가 높은 작업으로 분리하거나, 목록을 렌더링하는 등의 작업은 우선순위가 낮은 작업으로 분리해 최적의 순위로 작업을 완료할 수 있게끔 만든다.
이러한 파이버들이 모여서 파이버트리를 형성하고, 업데이트 요청이 생길경우 새로운 파이버트리를 생성하면서 기존 파이버트리와 비교해 달라진 부분만 업데이트 된 props를 받아 파이버 내부에서 처리한다. 렌더링이 모두 완료되면 workInProgress 트리가 current트리가 된다.
이렇게 트리가 바뀌는 과정을 Reconciliation(재조정) 이라고 하며, 이 과정에서 생성된 새로운 트리를 가상 DOM이라고 한다. 가상 DOM은 실제 DOM과 비교하여 변경 사항을 효율적으로 관리하며, 최종적으로 업데이트된 내용만 실제 브라우저 DOM에 적용한다.