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는 현재 파이버 트리와 다음 파이버 트리를 비교하여 업데이트되거나 추가되거나 제거되어야 할 노드를 파악하는 과정을 포함한다. 즉 앞서 살핀...