리액트의 특징으로 가장 많이 언급된 것 중 하나는 바로 실제 DOM이 아닌 가상 DOM을 운영한다는 것이다. 하지만 가상 DOM이 외 만들어졌는지, 실제 DOM과 어떻게 다른지, 그리고 정말 실제 DOM을 조작하는 것보다 빠른지에 대해서는 잘 모르는 경우가 많다.
DOM과 브라우저 렌더링 과정
DOM은 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 담고 있다.
브라우저가 웹 사이트 접근 요청을 받고 화면을 그리는 과정에서 정확히 어떤한 일은 아래의 과정과 같다.
- 브라우저가 URL에서 HTML 파일을 다운로드한다.
- HTML을 파싱하여 DOM 노드 트리를 만든다.
- (2)번 과정에서 CSS 파일을 만나면 CSS 파일을 다운로드한다.
- CSS도 파싱해 CSSOM을 구성한다.
- (2)번 DOM 노드를 순회하는데, display: none 처럼 보이지 않는 요소는 작업하지 않는다.
- 해당 노드에 대한 CSSOM 정보를 찾아 스타일을 적용한다.
레이아웃(layout, reflow): 각 노드가 브라우저 화면의 어느 좌표에 정확히 나타나야 하는지 계산하는 과정
페인팅{painting): 레이아웃 단계를 거친 노드에 색과 같은 모습을 그리는 과정
가상 DOM의 탄생 배경
앞선 예제를 통해 브라우저가 웹 페이지를 렌더링하는 과정은 복잡하고 많은 비용이 드는 과정인 것을 알게되었다. 또한 최근 대부분의 앱은 렌더링된 이후 정보를 보여주는 데 그치지 않고 사용자의 인터랙션을 통해 다양한 정보를 노출시킨다.
- 특정한 요소의 생상이 변경되는 경우
페인팅만 일어나므로 비교적 빠르게 처리할 수 있다. - 특정한 요소의 노출 여부가 변경되거나 사이즈가 변경되는 경우
이 경우 레이아웃이 일어나고, 레이아웃이 발생되면 리페인팅도 발생되기에 더 많은 비용이 든다.
이러한 추가 렌더링 작업은 하나의 페이지에서 모든 작업이 일어나는 싱글 페이지 애플리케이션(SPA)에서 더 많아진다. SPA를 사용하면 사용자는 깜빡임 없이 웹페이지를 이용할 수 있다는 장점이 있지만 그만큼 DOM을 관리하는 비용이 증가하게 된다.
개발자 입장에서는 사용자의 인터랙션에 따라 DOM의 모든 변경 사항을 추적하는 것은 번거로운 일이다. 모든 DOM의 변경보다는 결과적으로 만들어지는 DOM 결과물 하나만 아는 것이 개발자에게 매우 유용하다.
이러한 문제를 해결하기 위해 가상 DOM이 등장했다.
가상 DOM은 웹페이지가 표시할 DOM을 우선적으로 메모리에 저장하고 리액트가 실제 변경에 대한 준비가 되었을 때 실제 브라우저의 DOM에 반영한다.
이렇게 DOM 계산을 브라우저가 아닌 메모리에서 계산하는 과정을 한 번 거치게 된다면 실제로는 여러 번 발생했을 렌더링 과정을 최소화할 수 있고 브라우저와 개발자의 부담을 덜 수 있다.
리액트의 가상DOM이 일반 DOM 보다 빠르다는 사실을 반만 맞는 사실이다.
가상 DOM은 대부분의 상황에서 애플리케이션을 만들 수 있을 정도로 충분히 빠르다는 것이 장점이다.
즉, 개발자에게 도움이 되는 가상 DOM 방식이 충분히 빠르기 때문에 채용되었다고 보는 것이 맞다.
리액트 파이버
가상 DOM과 렌더링 과정 최적화를 가능하게 해주는 것이 바로 리액트 파이버(React Fiber)다.
리액트 파이버는 리액트에서 관리하는 평범한 JS 객체이다.
가상 DOM과 실제 DOM을 비교해 변경 사항을 수집하며, 만약 둘 사이의 차이가 있다면 변경에 관련된 정보를 가지고 있는 파이버를 기준으로 화면에 렌더링을 요청하는 역활을 한다.
리액트 파이버의 목표는 애니메이션, 레이아웃, 그리고 사용자 상호작용에 올바른 결과물을 만드는 반응성 문제를 해결하는 것이며 아래와 같은 일을 할 수 있다.
- 작업을 작은 단위로 분활하고 우선순위를 매긴다.
- 이 작업들을 일시 중지하고 다시 시작할 수 있다.
- 이전 작업을 재사용하거나 폐기할 수 있다.
중요한 것은 이 모든 과정이 비동기로 일어난다는 점이다.
과거 리액트의 조정 알고리즘은 스택 알고리즘으로 이뤄져 있어서 동기적으로 동작하여 비효율성이 존재했는데, 이 스택 구조의 문제점을 해결하기 위해 파이버라는 개념을 리액트 팀에서 채용했다고 한다.
파이버의 구현
파이버는 하나의 작업 단위로 구성되어 있다. 리액트는 이러한 작업 단위를 하나씩 처리한 후 finishedWork()라는 작업으로 마무리 한다. 이후 이 작업을 커밋해 실제 브라우저 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;
}
위의 예제에서 볼 수 있듯이 파이버는 하나의 요소에 하나씩 생성되는 1:1 관계를 지니고 있다. 또한 1:1로 연결되는 것은 리액트의 컴포넌트 뿐만 아니라 HTML의 DOM 노드나 그외 다른것일 수 있다.
FunctionComponent (0): 함수형 컴포넌트를 나타냅니다.
ClassComponent (1): 클래스형 컴포넌트를 나타냅니다.
IndeterminateComponent (2): 아직 함수형인지 클래스형인지 결정되지 않은 컴포넌트를 나타냅니다.
HostRoot (3): 루트 Fiber를 나타냅니다. 이는 React 애플리케이션의 최상위 레벨을 나타냅니다.
HostComponent (5): DOM 엘리먼트를 나타냅니다. 예를 들어, <div>, <span> 등의 HTML 태그에 해당합니다.
HostText (6): 텍스트 노드를 나타냅니다.
Fragment (7): React Fragment를 나타냅니다. Fragment는 DOM 트리에 별도의 노드를 추가하지 않고 여러 자식을 그룹화하는 데 사용됩니다.
Mode (8): React의 동작 모드를 나타냅니다. 예를 들어, Strict Mode, Concurrent Mode 등이 여기에 해당합니다.
ContextConsumer (9): Context의 소비자를 나타냅니다.
ContextProvider (10): Context의 제공자를 나타냅니다.
ForwardRef (11): Ref를 하위 컴포넌트로 전달하는 데 사용되는 ForwardRef 컴포넌트를 나타냅니다.
Profiler (15): React Profiler를 나타냅니다.
SuspenseComponent (16): Suspense 컴포넌트를 나타냅니다.
MemoComponent (14): Memo 컴포넌트를 나타냅니다.
SimpleMemoComponent (15): SimpleMemo 컴포넌트를 나타냅니다.
LazyComponent (17): Lazy 컴포넌트를 나타냅니다.
IntrinsicComponent (18): 호스트 컴포넌트를 나타냅니다.
파이버 객체를 살펴보면 children은 없고 child 만이 있다는 것을 알 수 있는데, 이는 파이버의 자식이 여러개 있다면 첫 번째 자식만 child로 두고 두 번째 이상의 자식은 첫 번째 자식의 형제(sibling)으로 구성하는 방식을 갖고 있다.
이렇게 생성된 파이버는 state가 변경되거나 생명주기 메서드가 실행되거나 DOM의 변경이 필요한 시점에 실행되며, 작은 단위로 나눠서 처리되거나 우선순위에 따라 빠르게 처리시키는 등 유연한 처리가 가능하다.
리액트 팀은 리액트가 가상 DOM이 아닌 Value UI, 즉 값을 가지고 있는 UI를 관리하는 라이브러리임을 이야기했다. 리액트의 핵심은 UI를 문자열, 숫자, 배열과 같은 값으로 관리하며 변수에 이 UI값을 보관하고 표현하는 것이다.
리액트 파이버 트리
파이버 트리는 사실 리액트 내부에서 두 개가 존재한다.
- 현재 모습을 담은 파이버 트리이다.
- 작업 중인 상태를 나타내는 workInProgress 트리
리액트 파이버의 작업이 끝나면 리액트는 단순히 포인터만 변경해 workInProgress 트리를 현재 트리로 바꿔버리는데, 이를 더블 버퍼링이라 한다.
더블 버퍼링이란?
컴퓨터 그래픽 분야에서 사용하는 용어이며, 사용자에게 미처 다 그리지 못한 모습을 보여주는 것을 방지하기 위해 보이지 않는 곳에 그림을 미리 그리고 완성되면 상태를 바꾸는 기법을 의미한다.
리액트에서도 더블 버퍼링 기법을 활용해서 랜더링을 진행한다.
현재 UI 렌더링을 위해 존재하는 트리인 current를 기준으로 모든 작업이 시작된다. 여기서 만약 업데이트가 발생하면 파이버는 리액트에서 새로 받은 데이터로 새로운 workInProgress 트리를 빌드하기 시작한다.
이 workInProgress 트리의 빌드가 끝나면 current트리가 workInProgress 트리로 변경된다.
파이버의 작업 순서
- beginWork() 함수를 실행해 파이버 작업을 수행하며 자식이 없는 파이버를 만날 때까지 시작된다.
- completeWork() 함수로 파이버 작업을 완료한다.
- 형제가 있다면 형제로 넘어간다.
- (2)(3)번 작업이 끝나면 자신의 작업이 끝났음을 알린다.
위 방식으로 만든 트리에서 setState 등 상태 변화가 일었다고 가정하자. setState로 인해 업데이트가 일어난다면 workInProgress 트리를 활용해 다시 트리를 만들지만, 이미 파이버가 존재하기 때문에 업데이트된 props를 받아 파이버 내부에서 처리하게 된다.
이러한 동작 방식은 가급적 새로운 파이버를 생성하지 않고 기존에 있는 파이버 객체를 재활용하기 위해 내부 속성값을 초기화하거나 바꾸는 형태로 트리를 업데이트하게 된다.
위에서 리액트가 초기에는 스택 구조로 렌더링 했다고 말했는데, 바로 이 트리 업데이트 과정을 재귀적으로 동작했다. 하지만 현제는 우선순위가 높은 다른 업데이트가 오면 현재 업데이트 작업을 일시 중단하거나 새롭게 만들거나 폐기할 수 있다.
따라서 리액트는 애니메이션이나 사용자가 입력하는 작업은 우선순위가 높은 작업으로 두어 최적의 순위로 작업을 완료할 수 있게끔 만든다.
가상 DOM과 파이버
가상 DOM은 오직 웹 애플리케이션에서만 통용되는 개념이다. 리액트 파이버는 RN(React-Native)와 같이 웹 이외 환경에서도 사용되기 때문에 완전히 같은 개념이라고 볼 수는 없다. 다만 파이버를 활용해서 조정되는 과정은 동일하다.
정리
리액트 컴포넌트에 대한 정보를 1:1로 가지고 있는 것이 파이버이며 비동기로 작업한다.
하지만 실제 브라우저 DOM은 동기적으로 일어나기 때문에 위의 작업은 가상(메모리)에서 먼저 실행한 후, 최종적인 결과물만 실제 브라우저에 적용한다.
'React > 모던 리액트 Deep Dive' 카테고리의 다른 글
리액트의 렌더링과 메모이제이션 (1) | 2024.11.08 |
---|---|
클래스 컴포넌트와 함수 컴포넌트 (0) | 2024.11.05 |
[React Deep Dive] JSX(Javascript XML) (0) | 2024.10.26 |
[React Deep Dive] 타입스크립트 (1) | 2024.10.17 |
[React Deep Dive] 동등 비교 (0) | 2024.10.08 |