Fluent React 7 - Reconcile
2024/03/18
n°54
category : React
☼
2024/03/18
n°54
category : React
Fluent React의 챕터 4는 조정Reconciliation에 관한 내용이다. 리액트 문서에서는 파이버나 조정에 관한 문서가 따로 없어 답답했는데, 이 장에서 꽤 깊이 있게 다룬다. 이 글을 통해 파이버와 조정이 무엇이고, 어떻게 리액트 내부에서 렌더링 최적화가 이루어지는지, 그리고 왜 필요했는지 살펴볼 수 있다. 궁금했던 내용이니 자세히 알아보자!
(italic은 인용입니다)
React.createElement -> 가상 DOM 구성 -> 조정Reconciliation -> 실제 DOM 구성
import { useState } from "react";
const App = () => {
const [count, setCount] = useState(0);
return (
<main>
<div>
<h1>Hello, world!</h1>
<span>Count: {count}</span>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
</main>
);
};
1. 리액트 트리로 변환하기 위해 JSX는 다음과 같이 트랜스파일된다
const App = () => {
const [count, setCount] = useState(0);
return React.createElement(
"main",
null,
React.createElement(
"div",
null,
React.createElement("h1", null, "Hello, world!"),
React.createElement("span", null, "Count: ", count),
React.createElement(
"button",
{ onClick: () => setCount(count + 1) },
"Increment"
)
)
);
};
2. createElement는 다음과 같은 리액트 엘리먼트 트리를 반환한다
{
type: "main",
props: {
children: {
type: "div",
props: {
children: [
{
type: "h1",
props: {
children: "Hello, world!",
},
},
{
type: "span",
props: {
children: ["Count: ", count],
},
},
{
type: "button",
props: {
onClick: () => setCount(count + 1),
children: "Increment",
},
},
],
},
},
},
}
간단한 카운터 앱을 예시로, 리액트 내부에서 어떤 일이 일어나는지 이해해보자.
리액트는 조정 과정 중 실제 DOM에 대한 업데이트를 배치 처리한다
…React는 조정reconcile 과정 중에 실제 DOM에 대한 업데이트를 배치 처리하여, 여러 개의 vDOM 업데이트를 단일 DOM 업데이트로 결합합니다. 이는 실제 DOM이 업데이트되어야 하는 횟수를 줄이므로 웹 애플리케이션의 성능 향상에 도움이 됩니다.
조정 단계는 리액트의 핵심이라고 할 수 있는 가상 DOM의 목적을 실현해주는 과정으로 이해할 수 있다! 실제 DOM 변경을 여러번 일으키는 것이 아닌, 여러 상태 변화(가 일으키는 DOM의 변화, UI 업데이트)를 묶어 단일한 DOM 업데이트로 묶는다. 성능 향상에 도움이 되는 것도 자연스럽게 이해된다.
여러번 상태를 업데이트 하는 예시
function Example() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
}
이 예제에서 handleClick 함수는 빠르게 연속적으로 세 번 setCount를 호출한다. React의 배치 처리를 통해 count + 1을 각각 세 번 업데이트하는 대신 count + 3으로 DOM을 한 번만 업데이트할 것이다. 즉 Increment 버튼을 누를 때 1, 2, 3이 순차적이 보이는 것이 아닌 3이 바로 보이는 것이다.
React는 DOM에 가장 효율적인 배치 업데이트를 계산한다. 이를 위해 vDOM 트리의 포크인 새로운 vDOM 트리를 생성하고, 여기서 count를 3일 것이다. 이 트리는 브라우저에 현재 있는 것과 조정reconcile하여 0을 3으로 바꾼다.
조정 과정 중 배치에 대해 간략히 살펴보았으니, 구체적으로 조정 내부에선 어떤 일이 일어나는지 살펴보자. 이해를 위해, 이전 방식인 Stack Reconciler와 Fiber reconciler를 순차적으로 살핀다.
React는 16버전 이전까지 렌더링을 위해 스택 데이터 구조를 사용했다. 스택Stack은 후입선출(LIFO, Last In, First Out) 원칙을 따르는 선형 데이터 구조이다. 이는 스택에 마지막으로 추가된 요소가 가장 먼저 제거된다. 자바스크립트의 실행 컨텍스트가 대표적이다. (이전 글 참조)
스택의 기본적인 방법으로 push와 pop을 통해 아이템을 스택 상단에 추가하고 제거한다. 자바스크립트 배열 메서드를 떠올리면 쉽다.
React의 원래 reconciler는 오래된 가상 트리와 새로운 가상 트리를 비교하고 DOM을 업데이트하는 데 사용되는 스택 기반 알고리즘이었습니다. stack reconciler는 간단한 경우에는 잘 작동했지만, 애플리케이션이 크기와 복잡성에서 성장함에 따라 여러 도전적인 상황을 보여줬습니다
도전적인 상황의 예시
import React, { useReducer } from "react";
const initialState = { text: "", isValid: false };
function Form() {
const [state, dispatch] = useReducer(reducer, initialState);
const handleChange = (e) => {
dispatch({ type: "handleInput", payload: e.target.value });
};
return (
<div>
<ExpensiveComponent />
<input value={state.text} onChange={handleChange} />
<Button disabled={!state.isValid}>Submit</Button>
</div>
);
}
function reducer(state, action) {
switch (action.type) {
case "handleInput":
return {
text: action.payload,
isValid: action.payload.length > 0,
};
default:
throw new Error();
}
}
스택 조정자reconciler는 작업을 일시 중지하거나 연기할 수 없이 스택의 상단부터 순차적으로 업데이트를 렌더링할 것이다. 여기서 다음과 같은 문제가 생긴다.
직관적인 해결법은 렌더링의 순서를 중요도에 따라 바꾸는 것이다. 중요하지 않지만 비용이 많이 드는 컴포넌트 렌더링은 나중에, 사용자 입력을 더 먼저 우선순위를 두고 먼저 렌더링한다. 즉 스택의 순서대로가 아닌 우선순위 기반으로 가상 DOM을 업데이트하여 해결할 수 있을 것이다. 그러기 위해서 리액트는 사용자 입력 같은 특정 유형의 렌더링 작업을 다른 렌더링보다 우선순위를 매길 수 있어야한다.
이같은 문제 인식과 해결법을 통해 기존 스택 조정자reconciler의 한계와 렌더링 우선순위 부여의 필요성을 이해할 수 있다.
스택 조정자의 한계
스택 조정자reconciler는 업데이트를 우선순위에 따라 처리하지 않았다. 렌더링에 우선순위가 없기 때문에, 이는 중요하지 않은 업데이트가 더 중요한 업데이트를 차단할 수 있었다.
스택 조정자reconciler의 또 다른 한계는 업데이트를 중단하거나 취소할 수 없다는 것이다. 이는 스택 reconciler가 업데이트 우선순위를 인식하더라도, 고 우선순위 업데이트가 예약되었을 때 낮은 우선순위의 작업을 취소하거나 중단할 수 없었다. 이는 곧 불필요한 업데이트가 이루어진다는 뜻이며, 가상 트리와 DOM에서 불필요한 작업이 수행되어 애플리케이션의 성능에 부정적인 영향을 줄 수 있다.
업데이트 우선 순위의 예시들
결론
이전의 조정 방식은 스택 순서로 일관되게 업데이트를 처리하여 성능 저하, 사용성 저하로 이어졌다. 효율적인 순서로 가상 DOM을 업데이트할 필요성이 생겼고, 파이버 조정자Fiber reconciler가 개발되었다.
파이버의 데이터 구조를 자세히 살펴보자
기본적으로, 파이버 데이터 구조는 React 애플리케이션에서 컴포넌트 인스턴스와 그 상태의 표현입니다. 논의된 바와 같이, 파이버 데이터 구조는 가변 인스턴스로 설계되어 조정reconcile 과정 중에 필요에 따라 업데이트되고 재배열될 수 있습니다.
파이버는 객체는 이렇게 생겼다
{
"tag": 3, // 3 = ClassComponent
"type": "App",
"key": null,
"ref": null,
"props": {
"name": "Tejas",
"age": 30
},
"stateNode": "AppInstance",
"return": "FiberParent",
"child": "FiberChild",
"sibling": "FiberSibling",
"index": 0
// ...
}
이는 App이라고 불리는 클래스형 컴포넌트의 파이버 노드다. 파이버 노드는 컴포넌트의 태그, 타입, props, stateNode, 그리고 컴포넌트 트리 내의 위치 등의 정보를 포함한다.
tag:
tag는 각 컴포넌트 유형을 나타낸다. 클래스 컴포넌트, 함수 컴포넌트, Suspense 및 에러 경계, 프래그먼트 등은 자신만의 숫자 ID를 Fiber로 갖는다. 예시의 3은 클래스 컴포넌트이다.
type:
이 Fiber에 대응하는 함수 또는 클래스 컴포넌트를 가리킨다.예시에서 type은 App이라는 컴포넌트다.
props:
({name: "Tejas", age: 30})은 컴포넌트에 대한 props나 함수의 인자를 뜻한다
stateNode:
이 Fiber가 대응하는 App 컴포넌트의 인스턴스.
컴포넌트 트리에서의 위치(return, child, sibling, index ):
return, child, sibling, index 각각은 Fiber 조정자가 “트리를 걷는” 방법을 제공하며, 부모, 자식, 형제 및 Fiber의 인덱스를 식별한다.
파이버 조정자reconciler는 현재 파이버 트리와 다음 파이버 트리를 비교하여 업데이트되거나 추가되거나 제거되어야 할 노드를 파악하는 과정을 포함한다. 즉 앞서 살핀 우선순위를 기반으로 업데이트 순서를 조정하기 위한 것.
이 과정은 두 함수로 실현된다:
beginWork: “업데이트가 필요한” 컴포넌트를 표시하며 아래로 진행. 3번 과정
completeWork: 브라우저로부터 분리된 실제 DOM 요소들의 트리를 구성하며 위로 거슬러 올라감. 4번 과정
이러한 렌더링(조정) 과정은 사용자가 보기 이전(오프스크린)에 실행되어 언제든 중단되고 버려질 수 있다.
파이버 아키텍처는 게임 세계에서 "더블 버퍼링"이라는 개념에서 영감을 받았으며, 여기서 다음 화면은 오프스크린에서 준비되고 현재 화면으로 "플러시"됩니다.
더블 버퍼링은 컴퓨터 그래픽스와 비디오 처리에서 깜빡임을 줄이고 인지된 성능을 개선하기 위해 사용되는 기술이다. 이 기술은 이미지나 프레임을 저장하기 위한 두 개의 버퍼(또는 메모리 공간)를 생성하고, 최종 이미지나 비디오를 표시하기 위해 정기적인 간격으로 이들 사이를 전환한다.
실제로 더블 버퍼링이 작동하는 방식:
이를 통해 최종 이미지나 비디오가 중단이나 지연 없이 표시되므로 깜박임이나 기타 시각적인 잡음을 줄일 수 있다. (화면 깜빡임의 줄임은 서버사이드 렌더링을 떠올리게 한다)
파이버 조정은 더블 버퍼링과 유사한 방식으로 작동한다.
커밋 단계 이전에 파이버를 통해 미리 생성된 DOM 트리는 더블 버퍼링의 두번째 버퍼에 해당할 수 있다.
이점:
앞서 파이버 조정 과정에서 두 개의 트리가 생성되는데, (더블 버퍼링의 첫번째 버퍼에 해당하는) 현재 파이버 트리와 (두번째 버퍼인) work-in-progress 파이버 트리이다. 새로운 내용은 아니고, 앞선 내용을 이해하면 자연스럽다.
파이버가 엘리먼트와 달리 상태를 보존하고 장기간 유지되기에 현재 파이버를 갖고, 작업 루프의 completeWork에서 트리를 거슬러가며 새로운 트리를 생성한다고 하였다. 전자를 첫번째 버퍼, 후자를 두번째 버퍼로 이해하면 되겠다.
여기까지 파이버의 근간이 되는 개념, 기본 요소들, 작동 과정의 기초 개념을 살폈다. 이제 파이버 조정에서 어떤 일들이 일어나는지 깊이 있게 살펴보자.
파이버 조정의 두 단계
바로 리액트 공식문서의 설명이 떠오른다. 앞서 파이버 조정자는 UI를 업데이트하기 위한 작업 루프를 사용한다고 하였다. 이 두 단계 접근법은 React가 DOM에 커밋하고 사용자에게 새로운 상태를 보여주기 전에 언제든지 폐기할 수 있는 렌더링 작업을 할 수 있게 한다 = 렌더링을 중단 가능하게 만든다. 앞선 스택 조정자의 문제를 해결하는 구체적인 방식이다.
조금 더 자세히 설명하자면, 렌더링을 중단 가능하게 느끼게 하는 것은 React 스케줄러가 메인 스레드로 실행을 다시 양보하는 휴리스틱을 사용하는데, 이는 120fps 장치에서조차 단일 프레임보다 작은 5ms 마다 발생합니다.
React는 대체 트리에서 화면 밖에서 변경 작업을 수행하여 각 Fiber를 재귀적으로 거치면서 업데이트가 보류 중임을 신호하는 플래그를 설정한다.
beginWork의 시그니쳐는 다음과 같다:
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null;
이 함수의 매개변수를 알아보자.
current
workInProgress
renderLanes
renderLanes 깊게 살펴보기
`renderLanes` 값은 `beginWork` 함수에 전달되어 업데이트가 올바른 순서로 처리되도록 합니다. 더 높은 우선순위 레인에 할당된 업데이트는 더 낮은 우선순위 레인에 할당된 업데이트보다 먼저 처리됩니다. 이는 사용자 상호작용이나 접근성에 영향을 미치는 고우선순위 업데이트가 가능한 한 빨리 처리되도록 합니다.
업데이트를 우선순위 지정할 뿐만 아니라, `renderLanes`는 React가 동시성을 더 잘 관리할 수 있게 도와줍니다. React는 "타임 슬라이싱"이라고 불리는 기법을 사용하여 장기 실행 업데이트를 더 작고 관리하기 쉬운 조각으로 나눕니다. `renderLanes`는 이 과정에서 핵심 역할을 합니다. React가 어떤 업데이트를 우선 처리해야 하는지, 어떤 업데이트를 나중으로 미룰 수 있는지 결정하는 데 도움을 줍니다.
렌더링 단계가 완료된 후, `getLanesToRetrySynchronouslyOnError` 함수가 호출되어 렌더링 단계 동안 생성된 지연된 업데이트가 있는지 결정합니다. 지연된 업데이트가 있다면, `updateComponent` 함수가 `beginWork`와 `getNextLanes`를 사용하여 업데이트를 처리하고 그들의 레인을 기반으로 우선순위를 지정하는 새로운 작업 루프를 시작합니다.
completeWork 함수는 새로운 실제 DOM 트리를 구성한다. 이 트리는 브라우저 밖에서 DOM과 분리되어 구성된다. 브라우저인 경우, 이는 document.createElement 또는 newElement.appendChild와 같은 작업을 의미한다. 하지만 이 DOM 트리는 아직 브라우저 내 document에 연결되지 않았다는 점이 중요하다. React는 화면 밖에서 UI의 다음 버전을 생성한다. (더블 버퍼링의 두번째 버퍼 개념을 떠올려보자.)
DOM 트리를 미리 화면 밖에서 수행하는 것은 렌더링을 중단 가능하게 만든다. 이것이 Fiber 조정자의 중요 포인트이다. React가 계산하고 있는 다음 상태는 아직 화면에 그려지지 않았으므로, 더 높은 우선순위의 업데이트가 예정되어 있는 경우 폐기될 수 있다.
completeWork의 시그니쳐는 다음과 같다:
function completeWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null;
매개변수는 beginWork와 동일하다.
completeWork 함수는 beginWork 함수와 밀접한 관련이 있다.
completeWork가 맨 위에 도달하고 새로운 DOM 트리를 구성했을 때, "렌더링 단계가 완료되었다"고 한다.
그후, React는 커밋 단계로 이동한다.
커밋 단계에서 렌더링 단계 동안 가상 DOM에 이루어진 변경 사항을 실제 DOM에 업데이트한다
커밋 단계 동안, 새로운 가상 DOM 트리는 호스트 환경에 커밋되고, work-in-progress 트리는 현재 트리로 교체된다. 이 단계에서 모든 (부수) 효과도 실행된다. 커밋 단계는 두 부분으로 나뉜다.
function commitMutationEffects(Fiber) {
switch (Fiber.tag) {
case HostComponent: {
// DOM 노드를 새로운 props나 children으로 업데이트
break;
}
case HostText: {
// DOM 노드를의 text content를 업데이트
break;
}
case ClassComponent: {
// componentDidMount and componentDidUpdate 같은 컴포넌트 생명주기 메서드를 호출
break;
}
// ... 다른 종류의 노드를 위한 분기 처리 로직이 이어진다
}
}
레이아웃 단계가 완료되면, React는 렌더링 단계 동안 가상 DOM에 이루어진 변경 사항을 반영하여 실제 DOM을 성공적으로 업데이트할 수 있다.
커밋 단계를 두 부분(변형과 레이아웃)으로 나눔으로써, React는 효율적인 방식으로 DOM에 업데이트를 적용할 수 있습니다. 조정자의 다른 핵심 함수들과 협력하여 작업함으로써, 커밋 단계는 React 애플리케이션이 더 복잡해지고 더 많은 데이터를 처리하면서도 빠르고 반응적이며 신뢰할 수 있게 유지될 수 있도록 돕습니다.
React의 조정 과정의 커밋 단계 동안, 커밋 단계의 (부수) 효과가 발생한다. 이 효과들은 유형에 따라 특정 순서로수행된다. 커밋 단계 동안 발생할 수 있는 여러 유형은 다음과 같다:
이 효과는 새로운 컴포넌트가 DOM에 추가될 때 발생한다. 예를 들어, form에 새 버튼이 추가되면, 그 버튼을 DOM에 추가하기 위한 배치 효과가 발생한다.
컴포넌트가 새로운 props나 상태로 업데이트될 때 발생한다. 예를 들어, 버튼의 텍스트가 변경되면, DOM 내의 텍스트를 업데이트하기 위한 업데이트 효과가 발생
컴포넌트가 DOM에서 제거될 때 발생. 예를 들어, 폼에서 버튼이 제거되면, 그 버튼을 DOM에서 제거하기 위한 삭제 효과가 발생한다.
이 효과는 브라우저가 페이지를 그리기 전에 발생하며, 페이지의 레이아웃을 업데이트하는 데 사용된다. 레이아웃 효과는 함수 컴포넌트에서 useLayoutEffect 훅을 사용하여 관리되고, 클래스 컴포넌트에서는 componentDidUpdate 생명주기 메서드를 사용하여 관리됩니다.
커밋 단계의 효과와 달리, 수동 효과는 브라우저가 페인트한 후에 실행되도록 예약된 사용자 정의 효과이다. 수동 효과는 페이지의 초기 렌더링에 중요하지 않은 작업을 수행하는 데 유용하다. 예를 들어, API에서 데이터를 가져오거나 분석 추적을 수행하는 등의 작업입니다. 수동 효과는 렌더링 단계 동안 수행되지 않기 때문에, 컴포넌트를 업데이트하는 시간에 영향을 주지 않는다.
React는 현재 트리, work-in-progress 트리 중 하나를 가리키는 FiberRootNode를 두 트리 상단에 유지한다. FiberRootNode는 조정 과정의 커밋 단계를 관리하는 핵심 데이터 구조다.
이것이 조정Reconcile이 작동하는 방법이다.
마지막으로 살펴본 내용들을 요약해보자.
조정Reconciliation이란?
조정자
파이버
파이버 조정자의 과정 개요
파이버 조정자(Fiber Roconciler)를 통한 렌더링 과정