[DIP] React Lifecycle Phase

react

React를 사용한 지 벌써 몇 년이라는 세월이 흘렀고 여러 버전을 거쳐 v19 버전까지 오게 되었다. 한 번씩 React 내부 코드를 살펴봐야지라는 생각을 하며 Deep Dive Magic Code | Goidle 블로그의 React 톺아보기 시리즈 정주행을 여러 번 도전하고 실패를 반복했다. 이번에는 React Internals Deep Dive를 보며 스터디를 진행하게 되었고, 이번에는 코드를 가까이 두고 학습을 진행하고 학습한 내용을 카테고리로 나누어 재정리해 보고자 한다. 코드를 가까이 두고 어떻게 학습을 진행할까?를 고민하다 모노레포와 비슷하게 react 프로젝트를 clone하고 빌드된 결과를 웹 페이지를 구성하는 프로젝트에서 dependency로 사용하도록 구성하였다. react 프로젝트에서 debugger 및 console 등 처리를 브라우저 웹 페이지 프로젝트에서 좀 더 직접적으로 브라우저 내 devtools Call Stack을 통해 랜더 및 리랜더 로직을 확인 할 수 있는 환경이 되었다.

React Deep Dive?

이 글은 Deep Dive가 아닌 단순히 위 환경으로 확인된 React의 랜더링 라이프사이클을 따라가며 확인해 본 Phase를 구간별로 나누어 정리해 보고자 한다. 해당 정리는 이전 React 톺아보기 시리즈를 볼 때와 스터디를 진행하는 과정에서 방대한 내용과 코드의 늪에 빠져 지금 내가 React의 어디를 보고 있는지… React의 무엇을 학습하고 있는지에 대한 길을 잃었던 적이 많아 구간별로 나누어 앞으로 학습에서 길을 잃지 않기 위한 지도(지표)처럼 사용하기 위함이다. 단계는 크게 리액트 공식 홈페이지에도 정의된 Trigger, Render, Commit으로 나누어 정리해 보겠다.

정리는 React v18.3.1 버전 기준으로 정리되었고 이후 v19 버전에서의 Lifecycle Phase는 어떻게 달라졌을지 비교해 보려고 한다.


참고: React v18.3.1 버전 기준으로 정리


react-lifecycle-phase-1.png



Trigger

처음 리액트 프로젝트에서 최초 랜더링의 시작인 render에서부터 시작을 해보겠다. 생성된 root(ReactDOMRoot) 기준으로 호출된 render를 통해 아래 형태로 진행된다.

뎁스를 고려하지 않고 진행 순서에 따른 중요하다고 생각되는 함수들

  • render → updateContainer → scheduleUpdateOnFiber → ensureRootIsScheduled

render: App 컴포넌트(ReactNodeList)를 전달 받아 업데이트 시작

tsx
const root = createRoot(document.getElementById("root")); root.render(<App />);
tsx
ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render = function (children) { var root = this._internalRoot; ... updateContainer(children, root, null, null); };

scheduleUpdateOnFiber: 랜더링 프로세스를 시작, 업데이트를 스케쥴링하는 함수로 변경이 필요한 Fiber 노드를 식별하고 업데이트 예약 처리

tsx
function updateContainer(element, container, parentComponent, callback) { ... var root = enqueueUpdate(current$1, update, lane); if (root !== null) { scheduleUpdateOnFiber(root, current$1, lane, eventTime); entangleTransitions(root, current$1, lane); } return lane; }

ensureRootIsScheduled: 업데이트 스케쥴링 과정에서 scheduleUpdateOnFiber 내부에서 호출되어 실제 스케쥴링 역할을 하는 함수로, 렌더링 작업을 수행하는 performConcurrentWorkOnRoot 함수를 예약 처리

tsx
function scheduleUpdateOnFiber(root, fiber, lane, eventTime) { ... ensureRootIsScheduled(root, eventTime); ... }

위 진행 과정을 통해 랜더링이 필요한 업데이트가 스케쥴링된다. 이후 스케쥴러에서는 랜더링의 시작점인 performConcurrentWorkOnRoot 함수를 예약하며, 예약된 작업은 workLoop 반복문을 통해 순차적으로 소비된다.



Render

Trigger 단계에서 스케쥴된 업데이트는 MessageChannel의 postMessage를 통해 등록된 작업을 호출 처리하게된다. MessageChannel을 사용하는 이유는 setTimeout을 사용하게 되는 경우, 중첩 호출 시 최소 4ms 지연 발생으로 성능에 영향을 줄 수 있기 때문이며, 이러한 이유는 코드 내 주석으로도 명시되어있다.

뎁스를 고려하지 않고 나름의 진행 순서에 따른 중요하다고 생각되는 함수들

  • performUnitOfWork → beginWork → attemptEarlyBailoutIfNoScheduledUpdate → bailoutOnAlreadyFinishedWork → reconcileChildren → completeUnitOfWork → completeWork

performUnitOfWork: workLoop를 통해 호출되며, Fiber 트리 노드 작업을 처리할 beginWork 호출 수행하는 랜더 핵심 함수

tsx
function performUnitOfWork(unitOfWork: Fiber): void { ... next = beginWork(current, unitOfWork, subtreeRenderLanes); ... completeUnitOfWork(unitOfWork); ... }

beginWork: 현재(current) Fiber 노드와 새로운(workInProgress) Fiber 노드를 비교하여 작업을 수행하고 변경점이 없는 경우 bailout 로직이 수행되도록 처리

tsx
function beginWork( current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes, ): Fiber | null { ... if (current !== null) { const oldProps = current.memoizedProps; const newProps = workInProgress.pendingProps; if (oldProps !== newProps) { ... } else { ... // No pending updates or context. Bail out now. didReceiveUpdate = false; return attemptEarlyBailoutIfNoScheduledUpdate( current, workInProgress, renderLanes, ); } ...

attemptEarlyBailoutIfNoScheduledUpdate: 업데이트가 없는 경우 불필요한 렌더링을 최적화하기 위해 bailout을 시도하는 함수

tsx
function attemptEarlyBailoutIfNoScheduledUpdate( current: Fiber, workInProgress: Fiber, renderLanes: Lanes, ) { ... return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); }

bailoutOnAlreadyFinishedWork: 하위 트리에 보류 중인 작업이 없는 경우 더 이상 트리를 탐색하지 않고 렌더링을 중단하는 bailout 로직을 수행하는 함수

tsx
function bailoutOnAlreadyFinishedWork( current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes, ): Fiber | null {

reconcileChildren: 새로운 자식 노드와 이전 자식 노드를 비교하여 새로운 Fiber 노드 트리 생성

tsx
export function reconcileChildren( current: Fiber | null, workInProgress: Fiber, nextChildren: any, renderLanes: Lanes, ) { if (current === null) { ... workInProgress.child = mountChildFibers(... } else { ... workInProgress.child = reconcileChildFibers(... } }

completeUnitOfWork: beginWork()에서 자식 노드를 생성하지 않은 경우 현재 Fiber 노드에 대한 작업을 완료하고, 필요한 경우 DOM 노드를 생성하거나 업데이트를 표시

tsx
function completeUnitOfWork(unitOfWork: Fiber): void { ... do { ... const current = completedWork.alternate; const returnFiber = completedWork.return; if ((completedWork.flags & Incomplete) === NoFlags) { ... completeWork(current, completedWork, subtreeRenderLanes); } else { ... unwindWork(current, completedWork, subtreeRenderLanes); ... } ... workInProgress = completedWork; } while (completedWork !== null);

completeWork: Fiber 노드의 태그에 따라 DOM 노드를 생성하거나 업데이트하고, 필요한 effect flag를 설정

tsx
function completeWork( current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes, ): Fiber | null { ... switch (workInProgress.tag) { ... }

스케줄링 된 업데이트는 Fiber 트리를 순회하며 처리된다. 이 과정에서 React는 Reconciliation(재조정) 단계에서 이전 트리와 새로운 트리를 비교하고, Bailout과 같은 최적화 기법을 적용하여 실제로 변경이 필요한 작업만 식별한다. 이렇게 선별된 작업은 이후 처리할 Effect 목록으로 수집된다.



Commit

Render Phase가 완료되면, React는 계산된 결과를 실제 브라우저 환경에 반영하는 Commit Phase로 진입한다. 이 단계에서는 업데이트가 완료된 Fiber 트리를 기반으로 DOM 조작, 컴포넌트 생명주기 처리, 등록된 Effect 실행 등이 수행된다.

뎁스를 고려하지 않고 나름의 진행 순서에 따른 중요하다고 생각되는 함수들

  • commitRoot → commitMutationEffectsOnFiber → flushPassiveEffects → commitPassiveMountOnFiber

commitRoot: 모든 passive effects 처리, 모든 렌더 프로세스 완료, 커밋 단계 시작

tsx
function commitRoot( root: FiberRoot, recoverableErrors: null | Array<CapturedValue<mixed>>, transitions: Array<Transition> | null, ) { ... commitRootImpl( root, recoverableErrors, transitions, previousUpdateLanePriority, ); ... }

commitMutationEffectsOnFiber: 삽입(Placement), 삭제(ChildDeletion), 업데이트(Update) 등의 mutation effect를 처리하는 단계를 시작 (랜더 트리 기반으로 실제 DOM 조작을 수행하는 핵심 함수)

tsx
function commitMutationEffectsOnFiber( finishedWork: Fiber, root: FiberRoot, lanes: Lanes, ) { ... recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); ... commitUpdate( instance, updatePayload, type, oldProps, newProps, finishedWork, ); ... }

flushPassiveEffects: DOM 업데이트 이후 비동기 적으로 실행되는 Passive 이펙트를 실행하고 컴포넌트 언마운트시 cleanup 함수 실행

tsx
export function flushPassiveEffects(): boolean { ... return flushPassiveEffectsImpl(); ... } function flushPassiveEffectsImpl() { ... commitPassiveUnmountEffects(root.current); commitPassiveMountEffects(root, root.current, lanes, transitions); ... }

commitPassiveMountOnFiber: Fiber 노드에 대해 Passive 마운트 이펙트를 실행하고 Fiber 노드 타입에 따라 다른 종류 Passive 이펙트를 처리한다. 캐시 비교하여 업데이트 필요한 경우에만 업데이트

tsx
function commitPassiveMountOnFiber( finishedRoot: FiberRoot, finishedWork: Fiber, committedLanes: Lanes, committedTransitions: Array<Transition> | null, ): void { ... commitHookEffectListMount(HookPassive | HookHasEffect, finishedWork); ... }

정리하자면 Commit Phase는 모든 렌더링 작업이 완료된 후 시작되며, 먼저 이전에 등록된 패시브 이펙트를 정리하고 실행한다. 그다음, 변경이 필요한 DOM 요소들을 실제로 삽입하거나 삭제하고, 속성값을 업데이트하는 작업이 수행된다. 이러한 DOM 변화와 부수 효과 처리는 화면에 변경 내용을 반영하기 위한 마지막 절차로, 사용자에게 보이는 결과를 구성한다.



In Conclusion

Trigger, Render, Commit 단계로 나누어 아주 간단히 정리했고 앞서 말했듯, 이 글은 React를 학습하며 길을 잃지 않기 위해 내가 만들고 있는 하나의 ‘지도’ 같은 것이며, 나무를 보기 전에 숲을 먼저 바라보는 감각으로 작성해 보았다. 이 글을 읽는 분들도 지금 정리된 이 ‘숲’이 틀릴 수도 있다는 가능성을 염두에 두고, React의 동작 원리에 대해 더 깊이 알고 싶다면 React Internals Deep Dive를 학습하거나 직접 소스 코드를 분석해보는 것을 권한다.

이렇게 정리를 해보며 느낀것은 React 코드를 디버깅하면서 정리를 하다보니 미로를 속에서 직접 손으로 지도를 그리며 길을 찾아가는듯한 기분이 들었다. 이렇게 미로를 헤매며 지도를 그리는일도 언젠가는 익숙해지는 날이 오고 다음 미로를 만났을때 빠르게 지도를 완성하고 빠져나갈 수 있지 않을까 기대해본다.

react-lifecycle-phase-2.png

“The creation of a thousand forests is in one acorn.” — Ralph Waldo Emerson