React - 가상 DOM과 파이버
DOM이 뭘까
리액트의 가장 큰 특징이자 장점으로 꼽는 가상 DOM에 관한 내용을 알아보겠다. 가상 DOM이라는 것을 보니 그냥 DOM이 있다는 것이고, DOM은 브라우저 렌더링과 관련이 있는 내용이기 때문에 작성된 코드가 어떻게 브라우저에서 렌더링되는지 알아보겠다.
브라우저 렌더링 과정
우선 DOM이라는 것은 Document Object Model의 약자로 웹 페이지에 대한 인터페이스로 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보가 담겨있는 구조이다. 그래서 브라우저는 DOM을 기반으로 페이지를 렌더링하게 된다. 렌더링 순서를 아래와 같다.
- 브라우저는 사용자가 요청한 주소로 HTML 파일을 다운로드 받는다.
- 이후에 브라우저의 렌더링 엔진은 HTML을 파싱해 DOM 노드로 구성된
DOM 트리를 만든다.- 앞의 과정을 거치면서 CSS파일을 만난다면 CSS파일을 다운로드한다.
- 이렇게 다운받은 CSS도 파싱해 CSS 노드로 구성된
CSSOM을 만든다.- 브라우저는 사용자 눈에 보이는 노드만 선별해 DOM 노드를 순회한다.
- 이렇게 선별한 노드를 대상으로 CSSOM에서 정보를 찾고, 여기서 발견한 스타일 정보를 적용해
Render 트리를 구성한다.- 레이아웃 과정(노드의 위치와 크기 계산)과 페인팅 과정(색과 같은 실제 효과 적용)을 거쳐 브라우저에 렌더링한다.
parsing단계에서 HTML과 CSS파일을 기반으로 DOM과 CSSOM을 구성하게 되고,
두가지 모델을 통해 Render 트리를 만들어 브라우저에서 렌더링을 하는 것이다. 그래서 간략하게 정리하면 parsing -> style -> layout/reflow -> paint -> composite, 5단계로 정리할 수 있다. 실제 HTML에서 어떻게 동작하는지 알아보겠다.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="stylesheet" href="style.css" /> <title>Document</title> </head> <body> <div id="nav"> <div class="logo">test</div> <div class="menu-wrapper"> <div class="menu">menu</div> <div class="menu">menu</div> <div class="menu">menu</div> <div class="profile-photo">photo</div> </div> </div> <div id="news-contents"> <div class="news-content-wrapper"> <div class="news-picture">news-picture</div> <div class="news-title">news-title</div> <div class="news-description">news-description</div> </div> </div> <div id="footer">footer</div> </body> </html> ... // style.css .logo { font-weight: 500; font-size: 40px; }
특정 주소를 통해 요청을 보내면 주소를 통해 이런 HTML파일을 다운로드한다. 다운로드와 동시에 HTML을 분석하고, link태그에 있는 style.css를 발견해 CSS파일을 다운로드한다. HTML을 통해 DOM을 생성하고 CSS를 통해 CSSOM을 생성한다. 이렇게 만들어진 모델을 통해 스타일을 적용할 부분을 찾고 CSS파일에 있는 스타일 정보를 결합해 Render Tree를 구성한다. 이렇게 만들어진 Render Tree를 기반으로 브라우저는 렌더링을 진행한다.
가상 DOM의 탄생 배경
앞에서 브라우저 렌더링 과정을 살펴보면 상당히 복잡한 과정을 통해 각 요소를 파악하고 스타일링을 진행하는 것을 알수가 있다. 물론 각 노드가 변하지 않는 경우에는 한번만 생성해두면 된다. 하지만 요즘 웹 서비스들은 인터랙션을 통해 다양한 데이터를 표현한다. 그리고 그에 따른 사용자 동작도 많아진 상태이다.
우리가 많이 사용하는 네이버만 봐도 엄청난 정보를 담고 있고 각 요소들이 전부 사용자가 동작가능한 요소들이다. 여기에서 아무 버튼을 누른다고 가정해보자. 단순히 색상이 변경되는 경우는 paint과정만 발생하기때문에 계산할 요소가 많지 않다. 하지만 로그인 여부에 따라 버튼이 바뀌거나 특정 요소의 크기나 위치가 변경되는 경우는 layout과정이 일어나고, layout과정이 이뤄지면 repainting이 이뤄진다. 심지어 하위에 자식 요소가 많은 경우에는 더 많은 비용을 브라우저가 사용하는 것이다.
이 문제는 SPA에서 더 두드러진다. 페이지를 이동할때마다 새로운 페이지를 다운로드하는 방법과 달리, SPA는 한 페이지에서 계속해서 위치를 계산하고 변경하는 과정이 발생한다. 사용자 경험은 좋아지지만 브라우저 입장에서는 비용이 커지기 마련이다. 그리고 이런 방식은 개발자에게도 DOM의 변경사항을 추적함에 있어서 어려움을 준다.
<form> <input type="text" id="username"> <button id="submit">Submit</button> </form> <div id="status"></div>
간단한 form을 예시로 들어보자. form을 제출하게 되면 #username 비활성화, #submit 버튼 로딩 상태 표시, #status에 성공 메시지 표시등의 다양한 DOM 변경이 이뤄진다. form하나에도 이렇게 많은 변경 사항을 개발자가 직접 추적하고 변경하는 것은 상당히 수고로운 일이다. 그래서 우리는 이런 모든 과정보다는 최종 결과물을 확인하는 것이 더 간편하게 다가온다.
그래서 고안된 것이 가상 DOM, Virtual DOM이다. 가상 DOM이란 브라우저에서 관리하는 DOM이 아닌 React가 관리하는 가장의 DOM이다. 간단하게 설명하면 웹페이지가 표시할 DOM을 메모리에 저장하고 리액트가 변경에 대한 준비가 완료되면 실제 브라우저 DOM에 적용시키는 것이다. 그래서 브라우저에서 처리할 계산을 메모리에서 처리함으로서 변경이 발생할때마다 계산하는 비용을 절감하고, 브라우저와 개발자의 부담을 덜 수 있는 것이다.
가상 DOM이 빠르다?
그럼 계산하는 비용을 줄이고 브라우저의 부담을 절감하는 측면에서 속도가 빠르다는 인식이 있다. 하지만 이건 잘못된 상식이다. 실제로 가상 DOM이 빠르다는 의미는 DOM보다 빠르다는 것이 아니라, 어떤 상황에서도 충분히 빠르다는 의미이다. 왜그런지 생각해보면 결국 가상 DOM도 변경되는 곳을 찾아 계산하는 과정이 동반되기 때문에 속도에 영향을 끼친다. 심지어 단순한 변경이 이뤄지는 경우에는 DOM이 가상 DOM보다 빠르게 동작한다. 이러한 점에서 가상 DOM이 무조건 빠르고 효율적이다라는 생각을 갖지 말자.
React Fiber
이렇게 등장한 가상 DOM을 사용할 수 있도록 해주는 것이 React Fiber이다. 이는 특정 기술이 아니다. 앞에서 얘기한데로 리액트는 가상 DOM을 통해 기존에서 변경이 이뤄지는 부분만 찾아 렌더링을 요청한다. 비교하는 과정은 reconcilitaion, 재조정이라고 하고 재조정 알고리즘이다. Fiber는 처음부터 있던 기능이 아니고 React 16에서 새로 도입한 알고리즘 방식이다. 그럼 그 전에는 어떻게 비교하고 변경이 이뤄졌는지 간단하게 알아보겠다.
React 16이전 재조정과정
이 전에는 스택 알고리즘이 사용되었다. 스택은 상당히 익숙한 단어이다. 자바스크립트에서 컨텍스트를 통해 이미 들어본 바가 있다.
이미지로 빠르게 알아보면 나중에 들어온 동작이 먼저 시작되고 앞의 동작이 끝이 나야 다음 동작을 하는 구조이다. 즉, 모든 과정이 동기적(순차적)으로 이뤄졌고 싱글 스레드인 자바스크립트를 사용하는 리액트에서는 비효율성으로 이어졌다.
이 예시는 리액트 16을 발표하면서 보여준 예시이다. 좌측은 기존 방식인 스택 알고리즘, 우측은 Fiber 알고리즘을 사용한 것이다. 내부에 있는 텍스트도 바뀌고, 원의 크기와 위치가 계속 변경되는 예시이다. 스택 알고리즘은 동시에 여러 동작을 하지 못하기 때문에 한번에 하나씩 동작이 된다. 간단한 웹 서비스에서는 이러한 문제가 보이지 않을 수 있지만 서비스의 크기가 커지고, 조작해야할 DOM이 늘어날수록 사용자의 경험은 나빠질수 밖에 없는 것이다.
리액트16 프레젠테이션
이러한 문제를 타파하고자 채택된 방법이 Fiber이다.
Fiber란?
위와 같은 문제를 해결하기 위해 Fiber의 핵심 기능은 렌더링을 증분하는 것이다. 렌더링 작업을 여러 덩어리로 나누어 여러 프레임에 분산하는 기능이다.
- 작업을 작은 단위로 분할하고 우선순위를 매김
- 작업을 일시 중지하고 나중에 다시 시작 가능
- 이전까지 했던 작업을 다시 재사용하거나 폐기할 수 있다.
그래서 위와같은 기능을 가진 것이 Fiber이다. 이러한 동작을 가지고 있기 때문에 스택과 다르게 동기 동작이 아닌 비동기 동작이 가능한 것이다. 그럼 Fiber는 어떻게 구현되어 있는지 확인해보겠다.
https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiber.js
깃허브에 있는 react의 파일을 찾아보면 Fiber가 어떻게 되어있는지 확인이 가능하다.
... function FiberNode( this: $FlowFixMe, tag: WorkTag, pendingProps: mixed, key: null | string, mode: TypeOfMode, ) { // 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.childLanes = NoLanes; this.alternate = null; ...
Fiber는 단순한 자바스크립트 객체이다. 리액트 Element와 유사하게 보이지만 매번 새롭게 생성되는 Element와 다르게 마운트시에 생성되고 가급적 재사용 된다는 것이다. 자세한 내용은 뒤에서 다시 다뤄보겠다.
... export function createHostRootFiber( tag: RootTag, isStrictMode: boolean, ): Fiber { ... export function createFiberFromSuspense( pendingProps: any, mode: TypeOfMode, lanes: Lanes, key: null | string, ): Fiber { ... export function createFiberFromPortal( portal: ReactPortal, mode: TypeOfMode, lanes: Lanes, ): Fiber { ...
그리고 코드를 더 읽어보면 다양한 파이버 생성 함수들이 존재한다. 이 의미는 파이버는 요소마다 1:1 관계를 형성한다. 이렇게 요소를 기반으로 생성된 파이버를 활용해 변화를 감지하고 가상 DOM에 적용하는 것이다. 파이버에서 사용하는 핵심 속성들을 알아보겠다.
tag
파이버는 1:1관계를 형성하는 만큼 관계를 형성한 매칭 정보를 가지고 있어야한다. 그 정보를 담고 있는데 tag이다.
export const FunctionComponent = 0; export const ClassComponent = 1; export const HostRoot = 3; // Root of a host tree. Could be nested inside another node. export const HostPortal = 4; // A subtree. Could be an entry point to a different renderer. export const HostComponent = 5; export const HostText = 6; export const Fragment = 7; export const Mode = 8; export const ContextConsumer = 9; export const ContextProvider = 10; export const ForwardRef = 11; export const Profiler = 12; export const SuspenseComponent = 13; export const MemoComponent = 14; export const SimpleMemoComponent = 15; export const LazyComponent = 16; export const IncompleteClassComponent = 17; export const DehydratedFragment = 18; export const SuspenseListComponent = 19; export const ScopeComponent = 21; export const OffscreenComponent = 22; export const LegacyHiddenComponent = 23; export const CacheComponent = 24; export const TracingMarkerComponent = 25; export const HostHoistable = 26; export const HostSingleton = 27; export const IncompleteFunctionComponent = 28; export const Throw = 29;
위는 태그 목록이다. 리액트에서 사용하는 각 요소마다 번호가 태그가 지정되어 있다. 여기에서 HostComponent가 웹에서 사용하는 div와 같은 요소를 의미한다.
stateNode
단어에서 알수 있듯이 Fiber가 가리키는 실제 인스턴스인 DOM 또는 클래스형 컴포넌트의 인스턴스 , null 등의 값이 담긴다. 즉, Fiber 자체에 대한 참조 정보를 가지는 것이다. 이 참조를 바탕으로 리액트는 Fiber와 관련된 상태에 접근한다.
child, sibiling, return
Fiber도 리액트 컴포넌트 트리가 형성되는 것처럼 동일하게 트리 형식을 갖게 된다. 트리 형식을 구성하는데 필요한 정보가 이 속성에 의해 정의된다.
<div class="a"> <div class="b1"></div> <div class="b2"> <div class="c1"> <div class="d1"></div> <div class="d2"></div> </div> </div> <div class="b3"></div> </div>
여러 개의 태그로 이뤄진 구조가 있을때 Fiber는 어떻게 표현될까? 형제 관계가 없다면 자식 관계를 찾는 방식으로 트리를 구성하게 된다. Fiber의 관계도를 정리해보면
const d2 = { return: div, index: 1, } const d1 = { return: div, sibiling: d2, index: 0, } const c1 = { return: div, child: d1, index: 0, } ...
이런 식으로 관계가 형성된다. 여기에서 index는 형제 관계에서 자신의 위치가 몇번째인지 숫자로 표현하는 것이다. return은 현재의 Fiber를 처리한 후 반환해야 하는 Fiber이다. 현재는 모두 div태그라서 동일하게 return이 설정되어 있지만 d1, d2의 return은 c1이다. 즉, return은 부모 Fiber라고 생각하면 된다.
pendingProps, memoizedProps
pendingProps는 아직 작업을 미처 처리하지 못한 props로 실행 시작시에 설정되고, memoizedProps는 pendingProps을 기준으로 렌더링이 완료된 이후에 pendingProps을 memoizedProps로 지정해 관리한다. 그래서 이 두가지를 통해 불필요한작업을 방지할 수 있다.
alternate
리액트 Fiber tree와 이어지는 개념으로, 리액트에서 관리하는 tree는 두개인데 alternate는 반대편 tree Fiber를 의미한다.
이렇게 Fiber로 구성된 가상 DOM은 결국 자바 스크립트 객체이다. 그래서 리액트 개발팀에서는 가상 DOM이라는 단어가 아닌 Value UI, 값을 가지고 있는 UI를 관리하는 라이브러리라고 얘기하기도 한다. 이렇게 값을 관리하고 표현하는 것이 리액트가 되는 것이다.
Fiber tree
위에서 조금씩 언급을 했다. 리액트에는 두가지 tree 구조를 가지고 있는다. 하나는 현재 모습을 담은 tree, 다른 하나는 작업 중인 상태를 나타내는 workInProgress tree이다. 두개의 tree를 가지는 이유는 결국 유연한 동작을 위해서이다. 만약 내부 작업중 사용자에게 화면이 보여지게 되면 미처 계산이 끝나지 않는 부분까지 노출되고, 사용자는 이상한 화면을 보게 되거나 화면이 멈추는 현상을 마주하게 된다.
그래서 완성된 화면을 바로 보여주는 방식을 더블 버퍼링이라고 한다. 두개의 tree를 활용해 workInProgress tree의 작업이 완료되면 포인터만 변경해 현재 보여줄 tree를 변경해준다. 이렇게 하면 다 완성하지 못한 모습을 노출하지 않으면서 사용자는 온전한 모습을 볼수 있는 것이다.
현재 current tree를 기준으로 작업이 시작되며, 변경사항이 발생하면 workInProgress tree를 빌드하고 다음 렌더링에 이 tree를 사용한다. 렌더링이 완료되면 current tree로 변경된다.
Fiber의 작업 순서
위에서 여러 예제와 이미지를 통해 대략적으로 어떤 작업 순서를 가지는지 감이 올것이다. 우선 Fiber node생성 과정을 먼저 정리해보겠다.
- beginWork()함수를 실행해 Fiber 작업을 수행
- 더이상 자식이 없는 Fiber를 만날때까지 트리 형성
- 작업이 끝나면 completeWork()를 실행해 Fiber 작업을 완료
- 형제가 있다면 형제로 이동해서 Fiber 작업 실행, return으로 부모 Fiber로 돌아가 작업이 완료됨을 알림
말로 보면 어려울수도 있는데 이미지로 확인하면 좀더 쉽다.
그럼 여기에서 setState로 인한 업데이트가 발생하면 어떻게 될까? 기존에 만든 current tree를 기준으로 workInProgress tree를 빌드하고 이미 Fiber는 존재하기 때문에 업데이트된 내용을 props로 받아 Fiber 내부값을 변경해주는 것이다. 이러한 점때문에 새로운 Fiber를 생성하지 않고 재사용 한다는 것이다. 앞에서 말했듯이 Fiber도 결국 객체이기 때문에 매번 새롭게 생성하는 것은 리소스 낭비다. 그래서 객체의 내부 속성값을 변경해 리소스를 절약하는 것이다.
Fiber와 가상 DOM
이제 Fiber와 가상 DOM의 연관성이 눈에 보인다. Fiber는 리액트 컴포넌트에 대한 정보를 1:1관계로 가지고 있고, Fiber는 자체적인 작업 우선순위를 선정해 비동기로 DOM을 형성한다. 하지만 실제 브라우저 DOM은 동기적으로 적용되어야 하고, 처리하는 작업이 많아질수록 불안정하기 때문에 메모리상에서 계산한 결과를 바로 브라우저 DOM에 적용시키는 것이다.
가상 DOM이라는 단어는 웹 어플리케이션에서만 통용되는 개념이다. 심지어 리액트 Fiber는 브라우저가 아닌 환경에서도 활용할 수 있기 때문에
가상 DOM = Fiber도 잘못된 개념이다. 이점을 유의하자.
마무리
리액트를 사용하면서 이런 내용까지 알게 된것은 이번을 통해 처음 알게 되었다. 아직 직접 와닿는 다는 생각이 들지는 않는다. 개념적으로는 이해했지만 이미 코드를 작성하면서 이런 개념을 염두해두고 사용해본적이 없기 때문이다. 편하게 코딩해서 다행이지만 오히려 편안함이 독이 되는 것이라고 생각한다.
지금 정리한 내용이 엄청 길지만 완전히 파고들지는 않은 것 같다. 이 Fiber라는 것은 좀더 파보고 싶다. 소스코드를 한번 쭉 훑어보고 어떻게 동작하는지도 관심있게 봐야겠다.
개의 댓글
1
DOM이 뭘까
브라우저 렌더링 과정
가상 DOM의 탄생 배경
가상 DOM이 빠르다?
React Fiber
React 16이전 재조정과정
Fiber란?
tag
stateNode
child, sibiling, return
pendingProps, memoizedProps
alternate
Fiber tree
Fiber의 작업 순서
Fiber와 가상 DOM
마무리