프론트엔드 면접에서 리액트에서 렌더링이 발생될때 가상 DOM은 몇개 생성되는지 질문을 받은적이 있습니다.
그당시 가상DOM과 렌더링에 대한 지식이 명확하지 않게 쉽게 답변하지 못한 기억이 있습니다.
이에 본 글을 통하여 가상DOM과 렌더링에 대하여 학습하고 정리해보도록하겠습니다.
DOM(Document Object Model)은 웹페이지에 대한 인터페이스로 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 트리 자료구조로 담고있습니다.
다음은 HTML에 대한 DOM Tree 예시입니다.
<!DOCTYPE html>
<html>
<head>
<title>My Page</title>
</head>
<body>
<div>
<h1>Hello, world!</h1>
<p>Welcome to my website.</p>
</div>
<ul>
<li>one</li>
<li>two</li>
<li>three</li>
</ul>
</body>
</html>
DOM API를 통해 DOM에 접근하거나 수정할 수 있습니다. 다음은 자바스크립트의 DOM API 메서드 예시입니다.
getElementById(id) // 지정된 id 값을 가진 요소를 찾아 반환
getElementsByTagName(tagName) // 지정된 태그 이름을 가진 모든 요소를 찾아 반환
getElementsByClassName(className) // 지정된 클래스 이름을 가진 모든 요소를 찾아 반환
querySelector(selector) // 지정된 CSS 선택자와 일치하는 첫 번째 요소를 반환
querySelectorAll(selector) // 지정된 CSS 선택자와 일치하는 모든 요소를 반환
createElement(tagName) // 지정된 태그 이름의 요소를 생성
appendChild(node) // 지정된 노드를 해당 요소의 자식으로 추가
removeChild(node) // 지정된 노드를 해당 요소의 자식에서 제거
setAttribute(name, value) // 지정된 요소에 속성을 추가하거나 속성 값을 변경
addEventListener(event, handler) // 지정된 이벤트가 발생할 때 실행할 핸들러 함수를 등록
DOM은 주로 브라우저에서 동작하기에 자바스크립트와 밀접하지만 서로 종속되지 않은 다른 구현체입니다. 따라서 다른 프로그밍언어도 DOM API를 통해 DOM을 조작을 할 수 있습니다.
예를들어 파이썬의 beatiful soup 라이브러리는 DOM API를 통해 DOM 조작을 할 수 있으며 주로 웹크롤링에 이용됩니다.
CSSOM(CSS Object Model)은 DOM과 유사하지만 문서(HTML, XML)가 아닌 CSS가 주체인 모델입니다. 마찬가지로 트리구조로 구현되어있습니다.
프로그래밍언어에서 CSSOM API 를 통해 동적으로 CSS를 조작할 수 있습니다.
다음은 자바스크립트 CSSOM API 일부입니다.
getComputedStyle(element, pseudoElement) // 지정된 요소에 대한 계산된 스타일을 반환
style.setProperty(propertyName, value, priority) // 지정된 요소의 스타일 속성을 설정
style.getPropertyValue(propertyName) // 지정된 요소의 스타일 속성 값을 반환
style.removeProperty(propertyName) // 지정된 요소의 스타일 속성을 제거
sheet.insertRule(rule, index) // 스타일 시트에 새로운 CSS 규칙을 삽입
sheet.deleteRule(index) // 스타일 시트에서 지정된 인덱스에 해당하는 CSS 규칙을 삭제
sheet.addRule(selector, style, index) //스타일 시트에 새로운 CSS 규칙을 추가
sheet.removeRule(index) // 스타일 시트에서 지정된 인덱스에 해당하는 CSS 규칙을 제거
웹에서의 렌더링은 웹 페이지의 내용을 브라우저에 표시하는 과정을 말합니다.
일반적으로 웹페이지 렌더링 과정은 다음과 같습니다.
1. HTML 파싱: 브라우저는 다운로드받은 HTML 문서를 파싱하여 DOM 트리를 생성합니다.
2. CSS 파싱: 브라우저는 CSS를 발견할 경우 이를 파싱하여 CSSOM 트리를 생성합니다.
3. 렌더 트리(Render Tree) 생성: DOM 노드과 CSSOM과 결합하여 렌더 트리를 생성합니다.
4. 레이아웃(layout, reflow): 각 노드가 화면 어느 좌표에 표시되어야하는지 계산하는 과정이며 이 과정을 거치면 반드시 페인팅 과정을 거칩니다.
5. 페인팅(paiting): 레이아웃 단계를 거친 노드에 색상과 같이 실제 유효한 모습을 그리는 과정입니다.
브라우저 웹페이지에서 DOM 조작을 통해 요소가 변경되는 상황이 있을 수 있습니다. 요소의 색상만 변경되는 경우 렌더링 과정에서 페인팅 과정만 거치면 되지만, 요소의 노출 여부나 사이즈, 위치가 변경되는 경우는 다시 레이아웃과 페인팅 과정을 거쳐야합니다. 또한 자식 요소를 가지고 있다면 하위 자식 요소도 같이 일괄 변경이 발생합니다.
특히 과거에 비해 많이 사용되고있는 SPA 환경의 경우 요소를 삭제하거나 삽입하는 작업이 잦습니다.
실제 브라우저의 DOM이 아닌 리액트가 메모리상에서 관리하는 가상의 DOM을 의미합니다. 가상DOM은 파이버(fiber) 라고하는 JS객체로 트리 구조로 구현되어있습니다. 추가로 DOM API가 제외되어 있기 때문에 일반 DOM보다 가볍다는 장점도 있습니다.
일반 DOM은 DOM 조작이 발생될때마다 브라우저위에서 리렌더링이 발생하였습니다. 하지만 가상DOM은 여러개의 렌더링이 동시에 발생할 경우 일괄로 메모리상에서 렌더링 작업을 끝낸뒤 실질적으로 변경된 부분만 실제 DOM에 적용하기 때문에 브라우저 DOM 렌더링을 횟수를 줄일 수 있습니다.
가상DOM은 리렌더링이 발생되면 작업이 반영되기전의 데이터를 가지고 있는 기존 트리와 작업을 실시간으로 반영하는 작업 트리 이렇게 총 두개의 트리를 가지고 작업합니다. 다음은 가상DOM의 리렌더링의 순차적 과정입니다.
- 상태 변화 감지를 통해 리렌더링 트리거(setState 등등)
- 작업할 가상DOM 트리 생성
- 작업 가상DOM 트리에서 DOM 조작 작업을 모두 진행
- 기존 트리와 작업 트리와 비교하여 변경된 부분을 찾음 (Diff 알고리즘)
- 찾은 변경된 부분을 실제 브라우저 DOM에 적용
이렇게 가상DOM은 여러번 렌더링이 발생될 작업을 메모리에서 처리하기 때문에 실직적으로 브라우저 DOM 렌더링은 한번만 발생됩니다.
구체적으로 예제 코드를 통해 비교해보겠습니다. 다음은 코드의 상황 설명입니다.
- 부모 컴포넌트와 자식 컴포넌트가 존재
- 부모 컴포넌트에 하나의 state가 존재
- 부모 컴포넌트에서 해당 state를 사용
- 부모 컴포넌트에서 해당 state를 자식 컴포넌트로 전달
- 자식 컴포넌트에서도 전달 받은 props를 사용
이때 state가 변경되면 렌더링이 몇번 일어날까요?
일반 DOM에서 구현입니다.
<div id="parent">
<div>이곳은 부모 컴포넌트입니다. state(value): <span id="parentValue">Hello</span></div>
<div id="child">
<div>이곳은 자식 컴포넌트입니다. props(value): <span id="childValue">Hello</span></div>
</div>
</div>
let parentValue = document.getElementById('parentValue');
let childValue = document.getElementById('childValue');
setTimeout(() => {
parentValue.textContent = 'World'; // 렌더링 발생
childValue.textContent = 'World'; // 렌더링 발생
}, 1000);
부모 요소와 자식 요소를 각각 DOM 조작을 통해 변경하였기 때문에 렌더링이 2번 발생합니다.
다음은 React의 가상DOM 구현입니다.
import React, { useState, useEffect } from 'react';
function Parent() {
const [value, setValue] = useState('Hello');
useEffect(() => {
setTimeout(() => {
setValue('World');
}, 1000);
}, []);
return (
<div>
<div>
이곳은 부모 컴포넌트입니다.
state(value): {value}
</div>
<Child value={value} />
</div>
)
}
function Child({ value }) {
return (
<div>
<div>
이곳은 자식 컴포넌트입니다.
props(value): {value}
</div>
<Child value={value} />
</div>
)
}
export default Parent;
부모 컴포넌트와 자식 컴포넌트에서 같은 value 값을 사용하고 있어 값 변화가 발생할 경우 부모 컴포넌트와 자식 컴포넌트에서 각각 렌더링이 발생합니다.
이때 리액트는 가상DOM에서 변화된 부분에 대한 계산을 마치고 최종적으로 브라우저 DOM 트리에는 변경된 부분을 교체하는 한번의 렌더링만 발생합니다.
다만 위 설명은 이론적인 부분이며 실제로는 브라우저 디버깅을 통해 일반 JS환경의 DOM조작에서 실제로 렌더링이 여러번 발생하는지 확인해보았습니다.
그런데 예상과 달리 DOM 조작이 발생한 시점에서 레이아웃과 페인트가 한번만 발생한 것을 확인하여 최종적으로 리렌더링이 한번만 발생한 것을 확인하였습니다.
사실 브라우저도 비효율적으로 렌더링을 매번 발생시키지 않고 최적화 작업을 진행한다고합니다.
실제로 위의 코드 내부에서 두 개의 DOM 변경이 거의 동시에 일어나므로, 브라우저는 이를 하나의 렌더링 단계로 처리한 것으로 보입니다.
리액트로 구현한 것도 역시 렌더링이 한번 발생함을 확인할 수 있습니다.
이전 주제에서 가상DOM은 DOM을 JS 객체로 추상화 시킨것이라고 하였고 그 객체는 리액트 파이버라고 언급하였습니다. 파이버는 트리구조를 가지고 있으며 가상DOM이 생성하는 트리가 바로 이 파이버 트리입니다.
본 섹션에서는 가상DOM 구조와 렌더링 과정을 리액트 파이버를 개념을 포함하여 다시 서술하도록하겠습니다.
파이버는 컴포넌트에 대한 정보들을 가지고 있는 JavaScript 객체입니다. 구성 요소는 다음과 같습니다.
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.refCleanup = null
this.pendingProps = pendingProps
this.memoizedProps = null
this.updateQueue = null
this.memoizedState = null
this.dependencies = null
this.mode = mode
// Effects
this.flags = NoFlags
this.subtreeFlags = NoFlags
this.deletions = null
this.lanes = NoLanes
this.childLandes = NoLanes
this.alternate = null
}
대표적으로 tag, child, sibling, return, index 을 소개하겠습니다.
tag
: 파이버 객체의 역할을 결정 짓는 필드이며 이값에 따라 리액트의 컴포넌트, HTML의 노드 등으로 결정됩니다.
tag가 가질 수 있는 값은 다음과 같습니다. 우리가 리액트에서 자주 사용하던 함수형 컴포넌트, 클래스 컴포넌트등이 포함되어 있습니다.
var FunctionComponent = 0;
var ClassComponent = 1;
var IndeterminateComponent = 2; // Before we know whether it is function or class
var HostRoot = 3; // Root of a host tree. Could be nested inside another node.
var HostPortal = 4; // A subtree. Could be an entry point to a different renderer.
var HostComponent = 5;
var HostText = 6;
var Fragment = 7;
var Mode = 8;
var ContextConsumer = 9;
var ContextProvider = 10;
var ForwardRef = 11;
var Profiler = 12;
var SuspenseComponent = 13;
var MemoComponent = 14;
var SimpleMemoComponent = 15;
var LazyComponent = 16;
var IncompleteClassComponent = 17;
var DehydratedSuspenseComponent = 18;
var SuspenseListComponent = 19;
...
❗️ 파이버 트리에서 자식은 무조건 한개만을 가지며 여러개의 자식을 가질 수 없습니다.
child
: 파이버 트리에서 해당 파이버가 가지고 있는 자식요소가 무엇인지 지칭
sibling
: 파이버 트리에서 해당 파이버의 형제요소가 무엇인지 지칭
return
: 파이버 트리에서 해당 파이버의 부모요소가 무엇인지 지칭
index
: 여러 형제(sibling)들에서 자신의 위치가 몇 번째인지 숫자로 표현
다음은 파이버 객체와 부모, 자식, 형제 관계의 예시입니다.
const l3 = {
return : ul,
index: 2,
}
const l2 = {
sibling: l3,
return : ul,
index: 1,
}
const l1 = {
sibling: l2,
return : ul,
index: 0,
}
const ul = {
// ...
child: l1,
}
파이버 트리를 생성하는 알고리즘은 다음과 같습니다.
1. 리액트는 beginWork() 함수를 실행해 파이버 작업을 수행하는데, 더 이상 자식이 없는 파이버를 만날 때까지 수행합니다.
2. 1번에서 작업이 끝나면 그다음 completeWork() 함수를 질행해 파이버 트리 생성 작업을 완료합니다.
3. 형제가 있다면 형제로 넘어갑니다.
4. 2번, 3번 작업이 모두 끝나면 return으로 돌아가 작업이 완료됩을 알립니다.
실제로 리액트 렌더링과정을 살펴보면 beginWork() 함수 실행을 통해 파이버 트리를 생성하는 것을 확인할 수 있습니다.
추가로 다음 예제 코드를 중심으로 설명해보겠습니다.
<A1>
<B1>안녕하세요</B1>
<B2>
<C1>
<D1 />
<D2 />
</C1>
</B2>
<B3 />
</A1>
1. A1의 beginWork()가 수행됩니다.
2. A1은 자식이 있으므로 B1로 이동해 beginWork()를 수행합니다.
3. B1은 자식이 없으므로 completeWork()가 수행됩니다. 자식은 없으므로 형제인 B2로 넘어갑니다.
4. B2의 beginWork()가 수행됩니다. 자식이 있으므로 C1으로 이동합니다.
5. C1의 beginWork()가 수행됩니다. 자식이 있으므로 D1으로 이동합니다.
6. D1의 beginWork()가 수행됩니다.
7. D1은 자식이 없으므로 completeWork()가 수행됩니다. 자식은 없으므로 형제인 D2로 이동합니다.
8. D2는 자식이 없으므로 completeWork()가 수행됩니다.
9. D2는 자식도 더이상의 형제도 없으므로 위로 이동해 D1, C1, B2 순으로 completeWork()를 호출합니다.
10. B2는 형제인 B3으로 이동해 beginWork()를 수행합니다.
11. B3의 completeWork()가 수행되면 반환해 상위로 타고 올라갑니다.
12. A1의 completeWork()가 수행됩니다.
13. 루트 노드가 완성되는 순간, 최종적으로 commitWork()가 수행되고 이 중에 변경 사항을 비교해 업데이트가 필요한 변경 사항이 DOM에 반영됩니다.
리액트에서 파이버 트리는 두개가 존재합니다. 하나는 현재 모습을 담은 파이버 트리이고 다른 하나는 작업 중인 상태를 나타내는 workInProgress 트리입니다. workInProgress 트리에서 작업을 하고 작업이 완료되었으면 단순히 포인터만 변경하여 WorkInProgress 트리를 현재 트리로 변경합니다. (더블 버퍼링)
리액트는 16 미만 버전에는 스택 알고리즘을 이용하여 요소 조정 작업을 하였고 이는 동기식으로 동작하는 단점이 있었습니다.
이후 16 버전부터 파이버를 도입하였고 작업을 분할하거나 분할된 작업들을 비동기로 처리하는 작업이 가능해졌습니다. 이는 아래에서 설명하는 리렌더링 동작 과정에 포함되는 작업들을 자유롭게 처리할 수 있음을 의미합니다.
다만 파이버 트리를 실제 DOM 에 반영하는 작업인 commitWork()는 동기적으로 실행됩니다. 실제 유저가 사용하는 브라우저 화면에 요소가 비동기적으로 반영이 되면 의도하지 않는 결과가 발생될 수 있기 때문입니다.
마지막으로 리렌더링이 발생할 경우 가상DOM에서 파이버 트리가 어떻게 작동하는지 서술하겠습니다.
- 상태 변화 감지를 통하여 리렌더링 트리거
- workInProgress 트리 생성
- workInProgress에서 상태 변화에 따른 파이버 조작
- 이때 최대한 기존 파이버를 재사용
- 현재 파이버 트리와 workInProgress 트리와 비교하여 변경된 부분을 찾음
- 찾은 변경된 부분을 실제 DOM에 적용
본 글을 작성하면서 저의 한계를 찾을 수 있었습니다. 저는 필요한 기능을 리액트로 구현할 수 있었지만 실제로 리액트가 내부적으로 어떻게 동작하는지 잘 모르고있었습니다. 저는 그저 리액트가 제공하는 편리한 인터페이스 기능만을 사용할뿐이였으며 실제로 그마저도 완벽하지 않았다고 생각합니다.
그리고 바닐라JS나 J-Query를 이용하여 웹개발을 해보지 않아서 리액트가 제공하는 기능들의 등장 배경을 알지못하였고 이때문에 순수 자바스크립트와 리액트의 렌더링 차이를 이해하는 것이 쉽지 않았습니다.
따라서 앞으로 순수 자바스크립트 개발을 해보려합니다. 클론코딩이라도 바닐라JS로 개발을 해보고 불편한점을 느껴보고 싶습니다. 그리고 그때부터 불편한점들을 리액트 기능을 참고하여 프로젝트를 개선해보려합니다.
모던리액트 Deep Dive
An Introduction to React Fiber - The Algorithm Behind React