React Fiber에 대한 구조를 공부하고자 했다!
https://github.com/acdlite/react-fiber-architecture?tab=readme-ov-file
해당 블로그를 참고해서 공부를 했다!
https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiber.js
물론 Fiber 코드도 조금 참고했다..!
React Fiber의 핵심 기능은 점진적 렌더링이다!
fiber 등장 이전에는 stack Reconciler 알고리즘을 사용했다. 이는 javascript의 호출 스택에 크게 의존했기에 붙여진 이름이다!
stack Reconciler는 호출 스택에 의존했기에 모든 노드를 동기적으로 처리했다. 이는 재귀 호출을 통해 트리의 모든 노드가 처리될 때까지 다른 작업이 끼어들 수 없는 구조였고 그렇기에 한 번 렌더링이 시작되면 전체 렌더링이 끝날 때까지 UI는 업데이트를 멈추게 된다.
이러한 문제 해결을 위해 도입된 새로운 아키텍처가 바로 Fiber이다. Fiber라는 이름 자체는 컴포넌트 트리의 각 노드에 대한 새로운 경량화된 데이터 구조를 나타낸다.
Fiber는 호출 스택에 의존하지 않고 각 컴포넌트의 작업을 독립적으로 관리할 수 있기에 점진적 렌더링이 가능하다. 또한 업데이트 유형에 따라 우선순위를 할당할 수 있는 기능도 포함되고 동시성 기능 또한 지원한다.
필자는 react 팀의 일원이 아니지만 React 팀의 구성원들에게 해당 문서 검토를 요청했다는 내용이 있다!
블로그 오우너의 한 마디
이 순간을 위해 앞에서 내용들을 정리했다!! 해당 게시글에서는 링크가 잘 연결되어 있지 않은데 확실하지 않지만 제가 따로 찾아서 블로그에 정리한 글이 있습니다. 클릭하시면 이동할 수 있고 해당 블로그에서 원본 내용 링크도 달아 놓았으니 확인하지면 될 거 같아요!
React Components, Elements, and Instances : "컴포넌트"라는 용어는 자주 여러 가지 의미로 사용된다. 이러한 용어들을 확실히 이해하는 것이 중요하다!
Reconciliation : React의 reconciliation algorithm에 대해 자세히 설명한 것이다!
React Basic Theoretical Concepts : 구현 부담 없이 React의 개념적 모델을 설명한 것이다!
위의 내용 일부를 복습하고 갑시다!
reconciliation : React가 두 가지 트리를 비교하여 어떤 부분을 변경해야 하는지 결정하는 알고리즘
update : React 앱을 렌더링하는 데 사용되는 데이터의 변경. 보통 setState의 결과로 발생한다.
React API의 아이디어는 업데이트가 전체 앱을 다시 렌더링하게 만든다고 생각하는 것이고 이는 개발자가 선언적으로 사고할 수 있게 해준다(A에서 B로 어떻게 효율적으로 전환하는지 생각할 필요가 없음)
사실 전체 앱을 다시 렌더링하는 것은 성능 측면에서 매우 비효율적이지만 React는 성능을 유지하면서도 전체 앱이 다시 렌더링되는 것처럼 보이도록 최적화를 적용한다. 이러한 최적화의 대부분은 reconciliation이라고 불리는 과정의 일부다.
Reactg 어플리케이션을 렌더링할 때, 어플리케이션을 설명하는 노드들의 트리가 생성되어 메모리에 저장된다. 그런 다음 이 트리는 렌더링 환경으로 전송된다. 어플리케이션이 업데이트될 때 새로운 트리가 생성된다.(Virtual DOM이 새로 생성된다.)
새로운 트리(workInProgress)는 이전 트리(current)와 비교되어 렌더링된 어플리케이션을 업데이트하기 위해 어떤 작업이 필요한지 계산된다.
이 과정에서 중요한 점은 다음과 같다.
서로 다른 컴포넌트 유형은 실질적으로 다른 트리를 생성하는 것으로 간주하기에 비교하지 않고 이전 트리를 완전히 대체한다.
리스트의 비교는 키를 사용하여 수행한다.
DOM은 React가 렌더링할 수 있는 여러 환경 중 하나일 뿐이며 다른 주요 대상은 React Native를 통해 렌더링되넌 iOS 및 Android의 네이티브 뷰이다.
이것이 Virtual DOM이라는 용어가 약간 오해의 소지가 있는 이유이다.
React가 이렇게 많은 대상을 지원할 수 있는 이유는 reconciliation과 rendering이 별개의 단계로 설계되었기 때문이다.
Reconciler는 트리에서 어떤 부분이 변경되었는지를 계산하는 작업을 수행하고 renderer는 그 정보를 사용하여 실제로 렌더링된 어플리케이션을 업데이트한다.
이러한 분리는 React DOM과 React Native가 동일한 reconciler를 공유하면서도 각기 다른 renderer를 사용할 수 있음을 의미한다.
Fiber를 통해 reconciler를 새로 구현할 수 있었다. 기본적으로 렌더링에는 크게 관여하지 않지만 새로운 아키텍처를 지원하고 활용하기 위해 renderer도 변경될 필요가 있다.
scheduling
Work
중요한 요점은 다음과 같다.
현재 React는 스케줄링을 크게 활용하지 않고 있으며 업데이트가 발생하면 전체 서브트리가 즉시 다시 렌더링된다. 스케줄링을 활용하기 위해 React의 핵심 알고리즘을 전면 수정하는 것이 Fiber의 기본 아이디어이다.
Fiber의 주요 목표는 React가 스케줄링을 활용할 수 있도록 하는 것임을 알 수 있다.
구체적으로 work를 일시 중지하고 나중에 다시 시작할 수 있어야 한다. 다양한 종류의 work에 우선 순위를 할당할 수 있어야 하고 이저에 완료한 work를 재사용할 수 있어야 한다. 더 이상 필요하지 않은 work는 작업을 중단할 수 있어야 한다. 이러한 기능을 수행하기 위해서는 먼저 작업을 단위로 나누는 방법이 필요하다.
그것이 바로 Fiber이고 Fiber는 작업의 단위를 나타낸다.
React components as functions of data개념으로 돌아가 보자!
v=f(d)
v는 view, d는 data를 의미하고 이는 React 컴포넌트가 데이터를 입력 받아 UI를 출력하는 함수처럼 동작한다. 따라서 React 앱의 렌더링 과정은 마치 함수가 다른 여러 함수를 호출하는 것처럼 컴포넌트들이 서로 호출되며 이루어 진다. 이는 재귀적인 함수 호출 구조와 비슷하다.
컴퓨터가 프로그램 실행을 추적하는 일반적인 방법은 콜 스택을 사용하는 것이다. 함수가 실행되면 새로운 스택 프레임이 스택에 추가된다.
콜 스택은 함수 호출을 관리하는 구조이다. 프로그램이 함수 A를 호출하면 이 함수의 정보가 콜 스택에 스택 프레임이라는 형태로 저장된다.
콜 스택은 LIFO방식으로 작동한다. 함수 호출 관리할 때 스택 자료구조가 적합한 이유는 함수 A가 함수 B를 호출하고, B가 다시 함수 C를 호출하면 C -> B -> A 순으로 값을 반환해야 하는데 이는 정확히 스택의 LIFO 원칙에 부합한다.
스택 프레임에는 함수의 실행과 관련된 여러 정보가 저장된다.
스택 프레임의 역할
함수 실행 관리 : 스택 프레임은 각 함수의 실행 상태를 추적한다. 이를 통해 함수가 호출되면 해당 함수에 필요한 모든 정보가 저장되고 함수가 끝나면 이 정보를 사용하여 원래 호출한 지점으로 돌아간다.
메모리 관리 : 스택 프레임은 함수의 지역 변수를 관리하며 함수가 끝나면 이 변수를 자동으로 해제하여 메모리를 효율적으로 관리한다.
UI를 다룰 때의 문제는 너무 많은 작업이 한꺼번에 실행되면 애니메이션이 프레임을 놓치고 화면이 끊겨 보일 수 있다는 점이다. 또한 최신 업데이트가 이전 작업을 대체해버린다면 그 이전 작업을 수행하는 것은 비효율적이다.
UI컴포넌트는 단순히 함수 이상의 복잡한 요구 사항을 처리해야 한다.
최신 브라우저(및 React Native)는 이 문제를 해결하는 API를 구현했다.
requestIdleCallback은 유휴 기간 동안 낮은 우선 순위의 함수를 호출하고, requestAnimationFrame은 다음 애니메이션 프레임에 높은 우선순위의 함수를 호출한다.
문제는 이러한 API를 사용하려면 렌더링 작업을 작은 단위로 쪼개어 관리할 수 있어야 한다. 콜 스택에만 의존하면 스택이 비워질 때까지 작업을 계속 수행하게 된다.
콜 스택의 동작을 customize하여 UI 렌더링에 최적화할 수 있다면 얼마나 좋을까??
또 콜 스택을 원하는대로 중단하고 스택 프레임을 수동으로 조작할 수 있다면 얼마나 좋을까??
그 해결사로 등장한 것이 React Fiber이다. Fiber는 React 컴포넌트에 특화된 스택의 재구현이다. 하나의 Fiber는 가상의 스택 프레임으로 생각할 수 있다.
스택을 재구현하는 것의 장점은 스택 프레임을 메모리에 유지하고 원하는 방식으로 또 원하는 시점에 실행할 수 있다는 것이다. 이는 스케줄링 목표를 달성하는 데 매우 중요하다.
React Fiber는 기존의 JavaScript 스택 구조 대신 작업을 힙 메모리에 객체 형태로 저장하고 관리한다.
피보나치 예제를 보자
function fib1(n) {
if(n <= 2) {
return 1;
} else {
var a = fib1(n - 1);
var b = fib1(n - 2);
return a + b;
}
} // stack 기반 접근 방식
function fib2(n) {
var fiber = { arg: n, returnAddr: null, a: 0 /* b is tail call */ };
rec: while (true) {
if (fiber.arg <= 2) {
var sum = 1;
while (fiber.returnAddr) {
fiber = fiber.returnAddr;
if (fiber.a === 0) {
fiber.a = sum;
fiber = { arg: fiber.arg - 2, returnAddr: fiber, a: 0 };
continue rec;
}
sum += fiber.a;
}
return sum;
} else {
fiber = { arg: fiber.arg - 1, returnAddr: fiber, a: 0 };
}
}
} // fiber 기반 접근 방식
tail call이란 함수가 자신의 마지막 작업으로 또 다른 함수를 호출하고 그 결과를 그대로 반환하는 것을 이야기한다.
//tail call
function tailCallExample(a, b) {
return anotherFunction(a + b);
}
function anotherFunction(sum) {
return sum * 2;
}
console.log(tailCallExample(3, 4)); // 14
//tail recursion
function factorial(n, acc = 1) {
if (n <= 1) {
return acc;
}
return factorial(n - 1, acc * n); // Tail Recursion
}
console.log(factorial(5)); // 120
스택 프레임 재사용: Tail call 최적화(Tail Call Optimization, TCO)는 현재 함수의 스택 프레임을 제거하고 새로운 함수 호출에 같은 메모리 공간을 재사용하는 방법으로 스택 오버플로우를 방지할 수 있다.
fib1 : 전통적인 재귀 접근 방식으로 스택 기반이다.
fib2 : 반복적 접근 방식으로 힙을 사용하여 스택 프레임을 관리한다.
스케줄링 외에도 스택 프레임을 수동으로 다루면 동시성 및 에러 경계와 같은 기능의 가능성을 열어준다.
블로그 주인장의 한마디: fiberNode 코드 구조를 직접 확인해가면서 작성했기에 제가 참고한 옛날 게시글의 부족한 점을 개선하려 노력했습니다!
FiberNode 구현 링크
https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiber.js
Fiber는 컴포넌트에 대한 정보와 그 입력, 출력을 포함하는 javascript 객체이다.
Fiber는 스택 프레임에 대응하지만 컴포넌트의 인스턴스에도 대응한다.
Fiber에 속하는 몇 가지 중요한 필드가 있다.
Fiber의 type과 key는 React 엘리먼트에서와 동일한 목적을 가진다. 실제로 엘리먼트에서 Fiber가 생성될 때 이 두 필드는 직접 복사된다.
type과 함께 key는 reconciliation과정에서 Fiber를 재사용할 수 있는지를 결정하는 데 사용된다.
child와 sibling 필드는 Fiber 간의 관계를 나타내며 이러한 관계를 통해 컴포넌트 트리가 구성이 된다. 이 필드를 통해 Fiber 트리가 형성되고 부모-자식 및 형제 관계를 추적할 수 있다.
컴포넌트의 render 메서드가 반환하는 JSX 요소는 해당 컴포넌트의 자식 Fiber로 나타난다. 즉 부모 컴포넌트의 자식 요소가 Fiber 구조 내에서 child 필드로 연결된다.
function Parent() {
return <Child />
}
Parent의 자식 Fiber는 Child에 해당한다.
sibling 필드는 render가 여러 자식을 반환하는 경우를 처리한다.
function Parent() {
return [<Child1 />, <Child2 />]
}
자식 Fiber는 첫 번째 자식을 head로 하는 단일 연결 리스트를 형성한다.
자식 Fiber는 tail-called function으로 생각할 수 있다.
return Fiber는 현재 처리 중인 Fiber를 처리한 후 프로그램이 돌아가야 할 Fiber이다.
이는 개념적으로 스택 프레임의 리턴 주소와 동일하다.
또한 return Fiber는 부모 Fiber로 생각할 수 있다.
만약 한 Fiber가 여러 자식 Fiber를 가지고 있다면 각 자식 Fiber의 return은 그 부모이다.
개념적으로 props는 함수의 인수와 같다.
Fiber의 pendingProps는 실행 시작 시 현재 렌더링 작업이 시작될 때 설정된다.
memoizedProps는 실행 종료 시 설정되고 이후 다시 렌더링할 때 비교하기 위해 저장된다.
들어오는 props(pendingProps)와 기존의 props(memoizedProps)가 동일하면 이는 해당 Fiber의 이전 출력을 재사용할 수 있음을 의미하며 불필요한 작업을 방지한다.
Lanes
우선순위 표시: Lane은 특정 작업의 우선순위를 나타내는 비트 필드로 각 비트는 다른 우선순위 레벨을 나타낸다. 낮은 비트(Lane)는 높은 우선순위 작업을 의미한다.
여러 작업 처리: 여러 Lane이 동시에 활성화될 수 있으며 스케줄러는 가장 우선순위가 높은 Lane을 먼저 처리한다.
스케줄링: 스케줄러는 각 Fiber의 Lane을 확인하여 어떤 작업을 먼저 수행할지 결정한다. 이 시스템은 특히 동시성 모드에서 중요한 역할을 한다.
Lane 코드
https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberLane.js
각 Lane은 하나의 비트를 차지한다.
여러 작업이 있을 경우, 스케줄러는 가장 낮은 비트를 가진 Lane부터 처리한다. 예를 들어 만약 00000000000000000000000000000111라면 스케줄러는 가장 먼저 00000000000000000000000000000001 그 다음 00000000000000000000000000000010 순으로 처리한다.
flush : Fiber의 출력을 화면에 렌더링하는 것을 의미한다.
work-in-progress : 아직 완료되지 않은 Fiber를 의미한다.
current fiber의 alternate는 work-in-progress이고 work-in-progress의 alternate는 current fiber이다.
Fiber의 alternate는 cloneFiber라는 함수로 지연 생성된다. 새로운 객체를 생성하는 대신 cloneFiber는 이미 존재하는 alternate를 재사용하여 메모리 할당을 최소화한다.
diV, span 등이 해당된다.fiber의 output은 함수의 반환값과 같다.
최종적인 output은 리프 노드에서 생성된 후 상위 노드로 전달되어 전체 UI 구조를 완성한다.
output은 결국 렌더러에게 전달되어 렌더링 환경에 변경 사항을 반영한다.
를 이야기한다고 github에 적혀서 끝났는데 나중에 따로따로 정리해야겠당
react fiber 참고 영상: https://www.youtube.com/watch?v=crM1iRVGpGQ