2023. 1. 8. 15:29ㆍFrontend
Overview
이전 글들 (Conceptual Model of React Suspense, Algebraic Effects of React Suspense, Suspense SSR Architecture in React 18)에 이어 Suspense의 동작 원리에 대해 Source Code Level에서 다루는 포스팅을 준비하고 있었습니다. 하지만 막상 Code Level에서 이를 분석하려고 하니 너무나 방대한 React의 개념들과 용어들(Fiber, Scheduler, Task, Lane, performUnitOfWork, Double Buffering)을 능숙하게 이해하고 있음을 전제하여야 했고, 이 모든 내용을 하나의 포스팅에 담기는 어려울 것 같다는 결론을 내렸습니다.
따라서 React18에서 이야기하는 Concurrency가 정확히 무엇인지, 그리고 Suspense가 여기에서 어떤 역할을 하는지를 Source Code Level에서 파악하기 전에 먼저 React가 "이전에"(여기서 이전이란, Concurrency mode가 도입되기 전, React 17 + Fiber Architecture를 의미합니다. 코드 상에서는 이를 Sync Mode, 혹은 Legacy Mode라 칭합니다.) 화면을 어떻게 렌더하고 있는지에 대해 이해해야 합니다. 이번 포스팅은 이에 관한 것입니다.
즉, 이번 포스팅에서는 Concurrency Mode가 활성화되지 않은 기존 ReactDOM.render() 를 사용해서 화면을 렌더 할 때, 실제로 어떤 코드들이 실행되고, 어떤 단계들을 거쳐 브라우저 화면에 나타나는지를 살펴보려고 합니다.
Double Buffering Structure
In computer science, multiple buffering is the use of more than one buffer to hold a block of data, so that a "reader" will see a complete (though perhaps old) version of the data, rather than a partially updated version of the data being created by a "writer". It is very commonly used for computer display images. - wiki
아래에서 살펴볼 React Source Code를 보면, current, workInProgress, alternate와 같은 용어들이 등장합니다. 이는 수십년 전부터 게임 프로그래밍에 사용되던 Double Buffering 개념을 React에 도입한 것인데, 한쪽에서 화면을 보여주는 동안(current) 다른 한쪽에서는 렌더링 작업을 진행하고(workInProgress), 렌더링 작업이 끝나면 서로 alternate로 참조하고 있는 current와 workInProgress를 서로 Switching 하는 방식으로 동작하는 것을 의미합니다. 따라서 코드를 살펴보다가 current, workInProgress, alternate와 같은 참조들이 등장한다면 "이는 위의 Double Buffering Mode를 위한 것이고, 실제 렌더링 작업은 workInProgress에서 진행한 뒤에, 작업이 끝나면 이를 alternate참조를 통해 current로 변경하는구나"라고 이해하면 됩니다. (current, workInprogress는 모두 FiberNode를 가리킵니다.)
Phase of React
Imagine that your components are cooks in the kitchen, assembling tasty dishes from ingredients. In this scenario, React is the waiter who puts in requests from customers and brings them their orders. This process of requesting and serving UI has three steps:
- Triggering a render (delivering the guest’s order to the kitchen)
- Rendering the component (preparing the order in the kitchen)
- Committing to the DOM (placing the order on the table)
React가 우리의 JSX Element를 화면에 렌더링하는 과정은 크게 3가지로 나눌 수 있습니다. (다시 한번 강조하지만, 이 포스팅은 Concurrent Mode에 대한 내용이 아니며, Sync Mode(Legacy Mode)를 기준으로 설명합니다.) Mount나 Update를 위해 ReactDOM.render()를 호출하는 Trigger Phase, 컴포넌트를 Rendering하는 Render Phase, 그리고 Render된 Fiber Node를 실제 화면에 반영하고 Effect를 소비하는 Commit Phase입니다.
여기서의 Render는 React가 렌더 할 JSXElement들을 Fiber Node로 만들고, Tree 구조를 만들고, 각각의 Task들을 스케줄 하고, Reconcile(재조정)하는 것까지를 포함합니다. 실제로 화면에 이 Fiber Node를 HTML Element로 반영하는 DOM Update 단계는 Render 단계에서 수행하지 않으며, 이는 Commit 단계에서 수행하게 됩니다.
initialMount를 다룰 때에는 ReactDOM.render가 단 한번 호출되게 되므로 Trigger Phase는 건너뛰고, Render Phase부터 소스코드 분석을 시작해보도록 하겠습니다.
Source Code Deep Dive
React Contribution Guide를 살펴보면 React Source Code가 어떻게 구성되어 있고, 어떻게 빌드하고 어떻게 테스트하고 어떻게 개발하면 좋은지에 대한 공식적인 가이드가 나와 있습니다. 이 가이드대로 실습 환경을 구성한 뒤에 Chrome Debugger를 사용해서 실제로 React가 어떤 함수들을 호출하며 동작하는지 살펴보도록 하겠습니다. 테스트에 사용한 파일은 아래의 dev.html 파일이며, 원래 Code에는 h1 태그 하나만 있지만, Tree구조에서 Child Element가 제대로 렌더링 되는지를 확인하기 위해 이를 아래와 같이 수정해서 사용하도록 하겠습니다.
https://github.com/facebook/react/blob/main/fixtures/packaging/babel-standalone/dev.html
<html>
<body>
<script src="../../../build/node_modules/react/umd/react.development.js"></script>
<script src="../../../build/node_modules/react-dom/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
<div id="container"></div>
<script type="text/babel">
ReactDOM.render(
<h1>
<span>
<span>Yeoul</span>
<span>
<i>Coding</i>
</span>
</span>
</h1>,
document.getElementById('container')
);
</script>
</body>
</html>
해당 화면을 렌더하고 Performance Recording을 한 뒤 Performance Tab을 살펴보면 다음과 같이 브라우저가 화면을 paint 하기 전에 많은 function call들이 발생하고 있음을 알 수 있습니다. 자세히 살펴보면, render, performUnitOfWork, legacyCreateRootFromDOMContainer 등의 여러 함수 호출들이 연쇄적으로 일어나고, 마지막에 appendChild가 호출되어 DOM을 업데이트하면 브라우저에 이 내용물이 반영되는 흐름인 것을 알 수 있습니다.
이제 여기에 각종 break point를 걸어가면서 React가 화면을 렌더 하는 동안 어떤 과정들을 거치는지를 알아보도록 하겠습니다. 함수 코드를 따라가다 보면 수많은 Detail과 조건문, 용어들이 나오지만 이번 포스팅에서는 sync mode에서의 initialMount를 설명하기 위해 필요한 부분만 따라가도록 하겠습니다. (거치지 않는 분기와 에러 처리, effect를 처리하는 부분등에 대한 내용은 다루지 않습니다.)
Trigger & Render Phase
HTML Source Code 상에서도 확인이 가능하듯, 가장 먼저 수행되는 함수는 ReactDOM.render입니다. 여기서는 legacyRenderSubtreeIntoContainer를 호출하고 그 결과를 리턴하는데, 이때 넘겨주는 매개변수는 render호출 시 넘겨준 h1 element와 container(react에서 <div id="container">으로 넘겨주는) element입니다.
element = h1 react element
container = div#container (일반적으로 <div id="root"></div>로 표현되는 container element)
legacyRenderSubtreeIntoContainer는 legacyCreateRootFromDOMContainer를 호출해서 rootFiberNode를 생성하고 이를 리턴합니다. 여기서의 rootFiberNode는 그대로 가장 먼저 불린 render 함수의 리턴값이 됩니다. 중간에 여러 조건문들이 있지만 initialMount때에는 container element에 _reactRootContainer가 없는 상황이므로 legacyCreateRootFromDOMContainer
만 호출한 뒤 종료합니다.
legacyCreateRootFromDOMContainer에서는 위에서 말한 대로 rootFiberNode를 생성합니다. 그리고 container element의 _reactRootContainer property를 선언하고 여기에 이 값을 할당합니다. 이 부분의 코드를 거치게 되면 콘솔에서 container element의 _reactRootContainer property를 조회할 수 있고, 이 값은 FiberRootNode 타입의 객체입니다. 이렇게 container element를 업데이트하고 updateContainer를 호출합니다.
updateContainer에서는 해당 RootFiberNode를 스케줄 하기 위해 scheduleUpdateOnFiber를 호출합니다. (위에 createUpdate, enqueueUpdate 관련된 내용이 있는데, 이 부분은 Lane에 관한 것으로, SyncMode에서는 크게 상관이 없으므로 이번 포스팅에서는 넘어가도록 하겠습니다.)
scheduleUpdateOnFiber는 동작이 조금 독특한데, 우선 실제로 React가 Fiber 단위로 렌더 작업을 수행하기 위한 엔트리포인트인 performSyncWorkOnRoot를 scheduleLegacySyncCallback이라는 함수를 통해 스케줄 합니다. 즉, 렌더링 작업은 performSyncWorkOnRoot가 호출되면서부터 시작되며, 이 함수를 호출하기 위한 스케줄링을 scheduleLegacySyncCallback을 통해 진행한다는 의미입니다. 바로 아래에 scheduleMicrotask와 flushSyncCallbacks 함수가 있음을 유의하면서 이 scheduleLegacySyncCallback 함수를 살펴보도록 하겠습니다.
scheduleLegacySyncCallback 함수는 그냥 단순히 넘어온 callback(여기서는 performSyncWorkOnRoot. 즉 실제로 컴포넌트를 render 하는 로직의 entrypoint)를 scheduleSyncCallback에 넘겨주는 역할을 할 뿐입니다. 중요한 부분은 scheduleSyncCallback인데, 이 함수는 전역 변수인 syncQueue(Array)를 생성하고, 여기에 callback을 push 하고 종료합니다. 이 함수가 이렇게 syncQueue에 performSyncWorkOnRoot를 push만 하고 종료된다면 push된 callback은 어느 시점에 실행되는 걸까요?
위에 scheduleUpdateOnFiber를 다루면서 scheduleMicrotask와 flushSyncCallbacks를 유의해야 한다고 언급했었는데, 바로 이 부분이 이 syncQueue에 들어있는 callback을 처리하는 부분입니다. scheduleUpdateOnFiber는 scheduleLegacySyncCallback함수를 호출해서 syncQueue에 performSyncWorkOnRoot를 callback으로 push 한 뒤에 flushSyncCallbacks라는 함수를 microtask에 스케줄 합니다. 이는 말 그대로 flushSyncCallbacks라는 함수를 microtask로 등록한 뒤에 브라우저의 Event Loop를 사용해서 이를 실행하겠다는 의미입니다.
scheduleUpdateOnFiber의 호출이 끝난 뒤, 브라우저가 microtask를 처리하는 시점에 이 flushSyncCallbacks 함수가 불리게 되며 이 함수는 아래와 같이 callback을 실행하고 종료합니다. 바로 이 시점에 렌더링을 처리하는 performSyncWorkOnRoot 함수가 호출되는 것입니다.
Wrap Up Before performSyncWorkOnRoot
지금까지의 단계를 도표로 정리해보면 다음과 같습니다. 처음 Render를 호출하고 난 이후부터 실제로 렌더링 작업을 시작하기 위한 workLoopSync가 불리는 단계까지를 살펴보았으며, 이 단계까지 오는 동안 FiberRootNode와 _reactRootContainer가 생성되었고, 작업을 시작하기 위한 performSyncWorkOnRoot가 브라우저의 event loop에 microtask로 등록되었다가 실행되는 것을 살펴보았습니다. 지금부터는 performSyncWorkOnRoot부터 시작해서 실제로 React가 어떻게 컴포넌트들을 렌더 하는지를 살펴보도록 하겠습니다.
Rendering Fiber Tree
performSyncWorkOnRoot에서는 크게 2가지의 작업을 수행합니다. Fiber Tree Structure를 렌더링 하기 위해 renderRootSync를 호출하는 부분 (Render Phase)과, Render가 완료된 후 돌아와서 이를 DOM에 반영하기 위해 commitRoot를 호출하는 부분 (Commit Phase). 지금은 Render Phase에 대한 내용을 설명하고 있으므로, commitRoot를 호출하는 부분은 아래에서 다시 살펴보도록 하고, 우선 renderRootSync에 대해서만 살펴보도록 하겠습니다.
renderRootSync에서는 이 글의 제일 위에서 살펴보았던 Double Buffering 구조를 만들기 위해 current Fiber Node의 alternate인 workInProgress Node를 생성하는 함수인 prepareFreshStack을 호출하게 됩니다. prepareFreshStack에서는 createWorkInProgress를 호출하여 FiberRootNode의 workInProgress Node를 생성합니다. 생성 후, root의 current.alternate는 workInProgress로, workInProgress.alternate는 current의 매핑을 만들어주어 아래와 같은 Double Buffering 구조를 만들어 줍니다.
이렇게 FiberRootNode에 대해서 workInProgress가 생성된 이후에는 renderRootSync로 돌아와 workLoopSync를 호출하여 workInProgress fiber에 대한 작업을 시작하게 됩니다.
workLoopSync는 비교적 간단한 구조로 되어 있습니다. workInProgress가 null이 될 때까지 workInProgress FiberNode에 대해서 performUnitOfWork를 수행하게 되며, 여기서의 unit은 incremental rendering을 위한 단일 Fiber Node가 됩니다. (뒤에서 살펴보겠지만 하나의 performUnitOfWork를 수행할 때마다 workInProgress 변수는 다음 Fiber Node의 workInProgress를 가리키도록 재할당 되기 때문에 이 workLoopSync가 종료되는 시점에는 렌더링해야 할 모든 Fiber가 처리된 상태가 됩니다.)
performUnitOfWork는 React Rendering 알고리즘의 핵심 로직입니다. 넘어온 workInProgress FiberNode를 받아서 beginWork를 호출해서 작업을 시작합니다. beginWork가 리턴하는 값은 workInProgress Node의 children이기 때문에 이 performUnitOfWork는 더 이상의 children이 없을 때까지 이 작업을 수행할 수 있습니다. 더 이상의 children이 없는 경우, completeUnitOfWork를 호출해서 해당 노드로부터의 렌더링을 마무리합니다.
위의 내용을 dev.html의 element들이 렌더링 되는 예시를 통해 가볍게 확인해 보겠습니다.
- 처음 performUnitOfWork의 매개변수로 들어가는 workInProgress는 FiberRootNode를 의미합니다. 바로 h1을 처리하는 것이 아닌 FiberRootNode를 먼저 처리하는 점에 주의해야 합니다.
- 처음 beginWork에서 리턴되어 next에 할당되는 값은 FiberRootNode의 child인 h1입니다. next = h1인 상태이므로 다음에 처리할 workInProgress가 next인 h1 FiberNode로 변경되고 다시 performUnitOfWork를 실행합니다.
- h1노드가 처리되고 다음 next에 할당되는 값은 span입니다. 위와 동일한 순서대로 span을 처리하고 그다음 span을 처리하고 마지막 "Yeoul"이 담겨있는 TextNode까지 처리하고 나면 next = null이 되고 completeUnitOfWork가 호출됩니다.
- completeUnitOfWork가 호출되고 나면 최하단 Leaf Node (여기서는 TextNode)부터 다시 거슬러 올라가면서 해당 FiberNode에 sibling Node가 있는지를 확인합니다. 아직 "Coding"이 들어있는 span 노드가 렌더링 되지 않았으므로 이 span Node를 workInProgress로 변경한 뒤에 이 작업을 수행합니다.
- 이렇게 모든 노드를 순회하고 나면 performUnitOfWork가 종료되고, 렌더해야 하는 모든 Element가 FiberNode와 stateNode(reactElement)의 Tree 형태로 저장됩니다.
- 이 상태에서 Commit Phase에 진입하며, Commit Phase에서 이 FiberNode와 StateNode를 기준으로 DOM에 화면을 그리게 됩니다.
beginWork에서는 넘어온 workInProgress Node의 tag를 보고 HostRoot / HostComponent 등의 타입을 판단합니다. 가장 처음에 넘어오는 workInProgress는 FiberRootNode이기 때문에 HostRoot 타입에 걸리게 되고, 다음(h1)부터는 하위 Element인 HostComponent에 걸리게 됩니다.
updateHostRoot 함수에서는 넘어온 workInProgress Fiber Node에 대해 reconcileChildren을 호출해서 각 Children에 대한 FiberNode를 생성하게 됩니다. 이미 FiberNode가 있으면 reconcileChildFibers가, 아직 생성되지 않았으면 mountChildFibers가 호출됩니다.
reconcileChildFibers에서는 child의 element 타입을 보고 어떤 함수를 호출할지를 결정하는데, h1, span, textnode 등은 전부 REACT_ELEMENT_TYPE에 속하므로 reconcileSingleElement가 호출됩니다. 만약 newChild가 단일 Element가 아니라 Array일 때, 그러니까 dev.html의 바깥 span 태그 안에 들어가 있는 2개의 span 태그와 같이 React Element의 배열로 들어올 때는 별도로 reconcileSingleElement가 아닌 reconcileChildrenArray를 호출해서 fiber node 사이의 sibling 관계를 형성하게 됩니다.
여기까지 생성하고 나면 performSyncWorkOnRoot로 제어권이 다시 넘어오게 되는데, 이제 여기서는 commitRoot를 호출함으로써 Commit Phase로 진입하게 됩니다. 여기까지의 과정이 Render Phase에 대한 코드의 간략한 개요이며, 이 과정이 마무리되었을 때, 리액트는 다음과 같은 Fiber Tree Structure를 얻게 됩니다.
Commit Phase
commit phase는 render phase에 비해 비교적 간단합니다. 이번 포스팅에서 다룰 부분은 아니지만 commit Phase에서 effect를 handle 하기 때문에 이 단계에서 useEffect의 callback들이 호출됩니다. React는 commitRoot로부터 시작되는 연쇄적인 함수 호출을 통해 결국 host의 DOM Element에 대해 appendNode를 호출하게 되며, 여기서 실제 DOM 노드를 업데이트하게 되고, 비로소 우리가 보는 화면에 DOM Node가 보이게 됩니다.
performSyncWorkOnRoot에서 renderRootSync 함수의 호출이 끝나고 Fiber Tree 구조가 완성되면 이어서 commitRoot를 호출합니다. 이후 commitRoot -> commitRootimpl -> commitMutationEffects -> commitMutationEffectsOnFiber -> commitReconcilationEffects -> commitPlacement -> insertOrAppendPlacementNode -> appendNode의 흐름을 거쳐 DOM Node에 최종적으로 반영됩니다. 정확히는 commitMutationEffects 호출이 완료되었을 때, requestPaint() 호출을 통해 브라우저에게 paint를 요청함으로써 화면에 DOM Node를 그리게 됩니다.
Summary
지금까지 매우 긴 여정을 거쳐 ReactDOM.render를 호출했을 때 실제로 React가 어떻게 FiberNode를 생성하고, 이를 순회하여 렌더 하고, 화면에 반영하는지를 살펴보았습니다. 굉장히 많은 함수 호출들이 여러 가지 상호작용을 통해 이루어지면서 로직을 복잡하게 만들었는데, 앞서 살펴본 내용들을 순서대로 나타내 보면 다음과 같습니다.
React의 Fiber Structure와 기존 렌더링 로직에 대한 이해가 없이 바로 React의 Concurrency Mode를 이해하는 일은 여간 어려운 것이 아닙니다. React Core Team이 기존의 구조로부터 수년간 안정적인 업그레이드를 위해 노력해온 결과물이기 때문에 Concurrency Mode의 구현도 기존 Fiber Structure와 Sync Mode에 대한 이해로부터 출발합니다.
다음 포스팅부터는 이 Sync Mode에 대한 이해로부터 출발해서 Concurrency Mode가 무엇인지, 오늘 살펴보았던 기존의 렌더링 방식과는 어떤 것인지를 알아보고, Scheduler, Lane Model 등 Concurrency Mode의 이해에 핵심이 되는 내용들을 살펴보려고 합니다.
Reference
https://github.com/facebook/react
https://tv.naver.com/v/23652451
https://beta.reactjs.org/learn/render-and-commit
'Frontend' 카테고리의 다른 글
How @next/font Works (0) | 2023.01.15 |
---|---|
Concept of React Scheduler (0) | 2023.01.14 |
Node.js + Puppeteer Memory Leak Handling (2) | 2022.11.20 |
Suspense SSR Architecture in React 18 (1) | 2022.09.18 |
Algebraic Effects of React Suspense (1) | 2022.09.12 |