onschan.me
테마 변경

React Fiber Architecture 톺아보기

2024-12-26

💡 이 글은 깃허브 facebook/react에 레포지토리에 아카이빙되어있는 코드와 Fiber 개발 과정들을 다시 밟아보며, Fiber는 왜 탄생하게되었는지, 어떤 과정으로 구현되었고, 어떻게 동작하는지 정리하기 위해 작성되었습니다.

여러분은 Fiber에 대해 제대로 알고있나요?

저의 경우는 리액트를 사용하여 개발한지 몇년이 된 지금도 겉핡기식으로만 알고있다는 생각이 들었고 이번 기회에 확실히 이해하고 싶었습니다.

Fiber에 대해 공부하며 느낀 아쉬운점은 좋은 포스팅과 영상들이 있긴하지만 리액트 팀 자체의 공식적인 문서는 존재하지 않는다는 점이었습니다.

Fiber 구현에 참여했던 Dan Abramov의 Fiber Deep Dive 영상도 훌륭한 내용이지만 언어적인 한계로 완벽히 이해하기는 힘들었습니다.

깃허브를 통해 fiber의 구현체를 보던 와중, fiber에 관련된 첫 PR은 무엇일까 궁금했고 찾아본 결과, 이 레포지토리에는 저의 갈증을 해결하고 Fiber에 대한 이해를 도와줄 수 있는 유의미한 대화들이 많았다는 것을 발견했습니다.

리액트 팀은 깃허브를 통해 컨트리뷰터들의 참여를 위한 설계 과정, 개발 과정을 공개하면서 Fiber를 개발했는데요. 이 과정을 살펴보는 것으로부터 Fiber에 대한 공부를 해보면 어떨까 싶은 생각에 정리를 하며 글로 남기게 되었습니다.

저는 Fiber에 대한 전문적인 지식을 가지고 있지 않으며, 이 글도 개인적 학습을 위한 정리글에 불과하지만 히스토리와 실제 구현 코드 기반으로 최대한 정확한 정보를 전달하도록 노력해보겠습니다.

기본적인 리액트에 대한 지식과 컴포넌트를 렌더링하는 것이 무엇을 의미하는지, 재조정(Reconciliation)이란 무엇인지 정도에 대한 가벼운 지식만 있으시다면 글을 읽는데에 어려움이 없을 것으로 생각합니다.

다만 원문 해석 과정 중 제 주관이 약간 포함될 수 있기 때문에 원문은 직접 찾아보실 수 있도록 링크는 전부 남기도록 하겠습니다.

그러면 2016년 5월 11일에 올라온 이 PR을 시작으로 멋진 사람들이 걸었던 발자취를 따라가보며 Fiber에 대해 같이 알아봅시다.


새로운 재조정 아키텍처인 Fiber, 왜 필요했을까?

Fiber의 등장 배경과 해결하고자 했던 바는 Fiber 작업의 첫번째 PR: New Reconciler Infra에서 찾아볼 수 있습니다.

해당 PR에 달린 "새로운 재조장자(New Reconciler)가 무엇을 위한 작업인지 설명이 필요하다"는 질문에

The goals are simple: change React from deep recursive rendering on every changeto some kind of scheduling.

Dan Abramov는 "매 변경마다 수행되는 기존 리액트의 깊은 재귀적 렌더링 방식을 스케줄링 같은 방식으로 변경하는 것이 목표입니다." 라고 답했습니다.

그렇다면 재귀적 렌더링 대신 왜 스케줄링을 이용하고자 했을까요?

이는 이어지는 답변에서 찾아볼 수 있습니다.

For scheduling to work, we need some kind of priority system, or otherwise there is no way for React to know which updates should be processed first. ...(생략)... For example, we might want to schedule high priority updates for something is interacting with (e.g. pressing a Like button) because UI should be responsive to feedback. On the other hand, rendering loading data might be a lower priority (i.e. it’s better to render a new story in the feed with a small delay than cause an animation happening at the same time to drop frames).

"스케줄링이 동작하기 위해서는 어떤 형태의 우선순위 시스템이 필요합니다. 그렇지 않으면 React가 어떤 업데이트를 먼저 처리해야 할지 알 수 없기 때문입니다. 즉각 반응해야하는 사용자 상호작용(예: 좋아요 버튼 클릭)과 관련된 UI 렌더링하는 것은 높은 우선순위를 가질 수 있는 반면에 데이터 로딩을 화면에 렌더링하는 것은 더 낮은 우선순위를 가질 수 있습니다. 애니메이션이 동시에 실행되고 있을 때는 프레임이 끊기게 될 수 있는데, 이것보다는 우선순위를 두어 낮은 우선순위 작업은 약간의 지연과 함께 렌더링하도록 하는 편이 좋습니다."

이 말에 따르면 기존의 재조정 아키텍처에서는 즉각적으로 업데이트 되어야하는 작업과 상대적으로 우선순위가 덜한 작업을 구분할 수 없었고 모든 작업을 재귀적으로 처리하기 때문에 프레임이 끊어지고 사용자 경험이 저하될 수 있었고 이를 우선순위를 가지는 스케줄링 방식을 통해 해결하고자 했음 알 수 있습니다.

새로운 재조정자를 만들기보다는 기존 재조정자에 우선순위에 대한 개념을 적용하면 되지 않았을까요?

아래 답변에서 기존 재조정자대신 새로운 아키텍처가 필요했던 이유를 찾을 수 있습니다.

The existing reconciler code is deeply OO and relies on recursive dynamic dispatches (e.g. mountComponent() of any composite component causes mountComponent() of whatever it renders to) so it’s hard to shove priorities into the existing system.

"기존의 재조정자(reconciler) 코드는 깊은 객체 지향적 구조를 가지고 있고 재귀적 동적 디스패치에 의존합니다. (예: 어떤 복합 컴포넌트의 mountComponent() 호출이 그것이 렌더링하는 무엇이든 간에 mountComponent()를 호출하게 됩니다) 따라서 기존 시스템에 우선순위를 넣는 것은 어렵습니다."

Fiber라는 새로운 재조정 아키텍처가 필요했던 이유는 아래처럼 정리할 수 있을 것 같습니다.

💡 기존 재조정 아키텍처에서 우선순위에 따라 작업을 구별할 수 없어 프레임 드롭이 발생하는 문제가 발생했고 이를 해결하기 위해 스케줄링 방식을 도입하고 싶었지만, 재귀적 렌더링 구조 때문에 우선순위를 도입하기 어려워 새로운 아키텍처를 만들게 되었다.

추가로 이 답변에서 "Fiber"라는 단어는 Fiber (computer science)에서 가져왔음도 확인할 수 있는데, 해당 위키에 언급되는 cooperative multitasking도 이러한 맥락과 일치하는 것 같습니다.

스케줄링 방식, 어떻게 도입했나?

Fiber 구현에 참여헀던 Andrew Clark의 게시글에서 스케줄링 방식의 도입을 위해 어떤 것들이 필요했었는지 알 수 있는데,

We've established that a primary goal of Fiber is to enable React to take advantage of scheduling. Specifically, we need to be able to
- pause work and come back to it later.
- assign priority to different types of work.
- reuse previously completed work.
- abort work if it's no longer needed.

"우리는 Fiber의 주요 목표가 React가 스케줄링을 활용할 수 있게 하는 것이라고 확립했습니다. 우리는 다음과 같은 것들이 필요합니다.

  • 작업을 일시 중지하고 나중에 다시 돌아올 수 있어야 합니다.
  • 서로 다른 유형의 작업에 우선순위를 할당할 수 있어야 합니다.
  • 이전에 완료된 작업을 재사용할 수 있어야 합니다.
  • 더 이상 필요하지 않은 작업을 중단할 수 있어야 합니다."

여기서 기존 재조정 아키텍처의 재귀적 렌더링 구조를 스케줄링 시스템으로 변경하는 것에 있어 큰 허들은 JS는 싱글 스레드 언어였다는 것인데요.

리액트 팀에서 JS의 싱글 쓰레드 위에서 효율적으로 스케줄링 방식을 도입하기 위해 초기에 고민했던 사항들은 Fiber 원리를 소개하는 Issue: Fiber Principles: Contributing To Fiber에서 찾아볼 수 있습니다.

이 이슈 내용에 따르면 먼저 다음과 같은 접근들을 시도해보았다고 합니다.

  • Web Workers를 통한 병렬 처리
  • Parallel JS를 통한 동시성 처리
  • 언어 레벨의 shared immutable persistent data structures 제안
  • VM(자바스크립트 엔진) 커스텀 수정
  • 스레드 기반 접근
  • Generator 함수
  • OCaml's algebraic effects

하지만 이러한 접근들은 무의미하진 않았지만 근본적인 한계에 부딪혔다고합니다.

해당 Issue에 각 접근 방식의 구체적인 한계점들이 상세하게 설명되어있고 많은 인사이트들을 포함하고있어서 이 글에 포함하기보다는 직접 읽어보시는 것을 추천드립니다.

이러한 시행착오 끝에 리액트 팀은 재귀를 사용하지 않고 재조정에 사용되는 트리를 순회할 수 있는 단일 연결 리스트 트리 순회 알고리즘을 기반으로 한 새로운 아키텍처를 고안하게 됩니다.

스케줄링 도입을 위한 Fiber의 아키텍처 살펴보기

해당 이슈에서 소개하는 알고리즘 예시는 아래와 같습니다.

let root = fiber;
let node = fiber;
while (true) {
  // Do something with node
  if (node.child) {
    node = node.child;
    continue;
  }
  if (node === root) {
    return;
  }
  while (!node.sibling) {
    if (!node.return || node.return === root) {
      return;
    }
    node = node.return;
  }
  node = node.sibling;
}

Fiber의 트리 순회는 먼저 child 노드가 있다면 child 노드로 이동하면서 깊이 우선 탐색을 합니다. child이 없다면 sibling 노드로 이동하는데, sibling 노드도 없는 경우 부모(return)로 돌아가서 부모의 sibling를 탐색합니다. 이런 식으로 모든 노드를 순회하다가 다시 루트로 돌아오거나, 더 이상 올라갈 부모가 없을 때 순회를 종료합니다.

이 알고리즘은 재귀 호출 없이도 트리 전체를 순회할 수 있습니다. 재귀를 사용하지 않는다는 것은 작업을 원하는 시점에 중단하고 재개할 수 있다는 것을 의미하는데요. 이는 스케줄링 방식을 구현하기 위한 적합한 방식임을 의미하기도 합니다.

Sebastian Markbåge은 이에 대해 다음과 같이 설명합니다.

We can use the normal JS stack for this but any time we yield in a requestIdleCallback we would have to rebuild the stack when we continue. Since this only lasts for about 50ms when idle, we would spend some time unwinding and rebuilding the stack each time.

즉, 일반적인 JS 스택을 사용할 경우 requestIdleCallback에서 작업을 양보할 때마다 스택을 다시 구축해야 하는 문제가 있었지만, 이 알고리즘을 통해 그런 문제를 해결할 수 있게 되었다는 것입니다.

이러한 접근은 우선순위가 높은 업데이트(예: 사용자 입력에 대한 반응)를 즉시 처리하고, 우선순위가 낮은 업데이트(예: 데이터 로딩 표시)는 나중에 처리할 수 있게 해주었고, 이는 Fiber의 핵심 목표였던 스케줄링 방식 도입 가능하게 했습니다.

그렇다면 Fiber는 어떻게 동작할까요?

Fiber는 어떻게 동작할까?

Fiber의 렌더 트리를 구성하는 노드

먼저 렌더 트리에서 사용되는 노드는 어떻게 구성되어있는지 살펴보고 이를 바탕으로 Fiber 아키텍처의 동작 방식을 살펴보려합니다.

아래는 코드는 ReactFiber 구현부에 있는 FiberNode의 생성자 함수 입니다. FiberNode로 생성되는 인스턴스가 트리를 구성하는 노드입니다.

// 글 작성 시점은 2024/12/26일 기준이므로 읽는 시점에 따라 구현부가 업데이트되었을 가능성이 있습니다.

function FiberNode(
  this: $FlowFixMe,
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  // Fiber
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  this.ref = null;
  this.refCleanup = null;

  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;

  this.mode = mode;

  // Effects
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags;
  this.deletions = null;

  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  this.alternate = null;
}

FiberNode는 트리 순회를 위한 정보React 엘리먼트에 대응되는 정보를 가지고있습니다. 리액트는 이 FiberNode 정보들을 바탕으로 스케줄링, 비교(diffing), 상태 관리 등 다양한 기능을 수행합니다. Fiber의 렌더링

  1. tag

    • 노드의 타입(예: FunctionComponent, ClassComponent, HostComponent 등)을 나타냅니다.
    • 이 tag를 기반으로 해당 노드에서 수행해야 할 작업(렌더링 방식, 업데이트 방식 등)을 결정합니다.
  2. pendingPropsmemoizedProps

    • pendingProps: 현재 렌더링 작업에서 사용될 새로 전달된 props입니다.
    • memoizedProps: 이전 렌더링 작업에서 저장된 props입니다.
    • 두 프로퍼티를 비교하여 props에 변경 사항이 있는지 판단하고, 필요한 경우 업데이트 작업을 수행합니다.
  3. memoizedState

    • 컴포넌트의 상태(state)가 저장되는 공간입니다.
    • 이전 렌더링에서 저장된 상태를 기준으로 변경 여부를 판단하며, 새로 계산된 상태는 Work in Progress 트리에 기록됩니다.
  4. flagssubtreeFlags

    • flags: 현재 노드에서 발생한 효과(예: 추가, 삭제, 속성 변경 등)를 나타냅니다.
    • subtreeFlags: 하위 노드에서 발생한 효과를 집계하여 기록합니다.
    • Commit 단계에서 이 플래그를 기반으로 DOM 변경 작업을 실행합니다.
  5. laneschildLanes

    • lanes: 현재 노드에서 처리해야 할 작업의 우선순위를 나타냅니다.
    • childLanes: 자식 노드의 작업 우선순위를 나타냅니다.
    • lanes와 childLanes를 사용하여 작업 스케줄링과 작업 순서를 효율적으로 관리합니다.
  6. return, child, sibling

    부모, 자식, 형제 노드를 가리키며 Fiber의 트리 순회 알고리즘에 사용됩니다.

  7. alternate

    • Current 트리와 Work in Progress 트리를 연결합니다.
    • alternate 속성을 통해 현재 트리와 작업 중인 트리를 참조하며 변경 사항을 반영합니다.

Fiber의 두 개의 트리: Current 트리와 Work in Progress 트리

Fiber 아키텍처는 항상 두 개의 렌더 트리를 유지합니다.

  1. Current 트리
  • 현재 화면에 렌더링된 상태를 나타냅니다.

  • 변경 작업을 수행하기 전, 이 트리를 기준으로 Work in Progress 트리에서 변경 사항을 비교(diffing)합니다.

  1. Work in Progress 트리
  • React가 다음 렌더링을 준비하는 작업 공간입니다.

  • Current 트리를 기반으로 생성되며, 렌더링 트리거(예: 상태 업데이트, props 변경)로 인해 반영된 새로운 속성(pendingProps, memoizedState 등)을 포함합니다.

  • Current 트리와 Work in Progress 트리 간의 차이를 비교하여 변경 사항을 확인합니다.

두 트리 간의 관계

  • 각 Fiber 노드는 alternate 속성을 통해 Current 트리와 Work in Progress 트리를 연결합니다.

  • 이 구조 덕분에 변경 사항을 효율적으로 추적하고, 필요할 때 작업을 중단하거나 다시 시작할 수 있습니다.

Fiber의 렌더링 동작 방식: Render Phase와 Commit Phase

1. Render Phase

React가 변경 사항을 탐지하고 Work in Progress 트리를 생성하는 단계입니다. 이 단계는 메모리에서 작업이 이루어지며, 실제 DOM에는 영향을 미치지 않습니다. 또한, 비동기적으로 실행되며 우선순위에 따라 작업을 중단하거나 다시 시작할 수 있습니다.

  1. Work in Progress 트리 생성
  • Current 트리를 기반으로 새로운 Work in Progress 트리를 생성합니다.
  • 이 과정에서 트리거(상태 변화, props 업데이트)로 인해 변경된 값(pendingProps, memoizedState)이 새로운 트리에 반영됩니다.
  • alternate 속성을 통해 두 트리가 연결됩니다.
  1. 트리 탐색과 diffing
  • Work in Progress 트리를 탐색하며 변경 사항을 확인합니다.
  • pendingProps와 memoizedProps, memoizedState를 비교하고, 필요한 경우 Current 트리와 비교하여 변경 사항을 추가로 탐지합니다.
  • 변경이 필요한 경우 해당 노드의 flags(Effect Tag)에 작업을 기록합니다.
  1. 우선순위에 따른 스케줄링
  • 각 노드의 작업은 lanes 및 childLanes 속성을 기반으로 우선순위가 결정됩니다.
  • React의 Scheduler를 사용해 높은 우선순위 작업부터 실행되도록 스케줄링합니다.
  1. 변경 사항 반영
  • 모든 변경 사항이 반영된 Work in Progress 트리가 메모리에서 완성됩니다.

2. Commit Phase

Work in Progress 트리에 기록된 변경 사항을 실제 DOM에 반영하는 단계입니다. 이 단계는 동기적으로 실행되며, 작업이 중단되지 않습니다.

  1. Effect 목록 순회
  • Work in Progress 트리의 flags 속성에 기록된 작업 목록(Effect)을 순회합니다.
  1. DOM 변경 적용
  • Effect 목록에 따라 DOM을 업데이트합니다.
  • 이벤트 리스너 추가/제거, Ref 업데이트 등도 이 단계에서 처리됩니다.
  1. 트리 전환
  • 작업 완료 후, Work in Progress 트리가 Current 트리로 전환됩니다.
  • 기존의 Current 트리는 폐기되고, 새로운 Work in Progress 트리가 생성될 준비를 합니다.

Fiber의 우선순위 스케줄링

Fiber는 작업의 우선순위를 Lane 시스템으로 세분화하여 관리합니다.

각 Lane은 비트 플래그로 표현되며, 작업의 중요도를 나타냅니다. 이를 통해 작업을 효율적으로 스케줄링할 수 있습니다.

// https://github.com/facebook/react/blob/fc8a898dd126198305fce458edd084c5d9c4b67a/packages/react-reconciler/src/ReactFiberLane.js

export const TotalLanes = 31;

export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;

export const SyncHydrationLane: Lane = /*               */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000010;
export const SyncLaneIndex: number = 1;

31개의 Lane을 Scheduler 통해 5개의 우선순위로 그룹화하여 관리합니다.

React Scheduler의 5가지 우선순위

  1. ImmediatePriority (즉각 실행) SyncLane에 해당하며, 사용자 입력 처리와 같은 즉각적으로 실행되어야 하는 작업. 예: 버튼 클릭, 텍스트 입력.

  2. UserBlockingPriority (사용자 차단 방지) InputDiscreteLanes 및 InputContinuousLanes에 해당. 예: 입력 필드 업데이트, 스크롤 이벤트.

  3. NormalPriority (일반 작업) DefaultLanes에 해당. 예: 일반적인 상태 업데이트, 화면 전환.

  4. LowPriority (낮은 우선순위 작업) 비동기 작업이나 사용자와 관련 없는 작업. 예: 전환 상태의 백그라운드 업데이트.

  5. IdlePriority (유휴 작업) IdleLanes에 해당하며, 유휴 상태에서 처리하는 작업. 예: 로그 저장, 분석 작업.

Render Phase에서 Lane 결정

  • 상태 업데이트(setState)나 이벤트 발생 시 해당 작업에 적합한 Lane을 할당합니다. (예: 버튼 클릭 → SyncLane 할당)
  • Render Phase에서 Fiber 노드는 각 작업의 Lane과 관련된 속성을 기반으로 작업을 스케줄링합니다. 작업은 항상 높은 우선순위(Lane)부터 처리됩니다.

Scheduler의 동작 방식

Scheduler는 각 작업의 우선순위를 기준으로 실행 순서를 결정하며, 브라우저가 유휴 상태일 때 작업을 연기하거나 중단할 수 있습니다.

기본적으로 브라우저의 이벤트 루프는 프레임 단위(대개 16ms)로 작업을 처리하기 때문에 측정한 시간을 바탕으로 브라우저가 작업을 처리할 수 있는 유후 상태인지 판단할 수 있습니다.

따라서 Schedule는 performance.now 혹은 Date.now를 사용하여 작업이 시작된 시점과 현재 시점 간의 경과 시간을 측정합니다.

MessageChannel을 이용한 연기

MessageChannel API는 브라우저 이벤트 루프를 차단하지 않고, 포트 간 메시지 교환을 통해 작업을 비동기적으로 예약하거나 재개할 수 있는 API입니다.

리액트 팀은 이 MessageChannel API를 사용하여 메시지 기반으로 작업을 연기하고 중단, 실행 시키도록 설계했습니다. 다만 브라우저 호환을 위해 setImmediate와 setTimeout을 폴백으로 두어 활용합니다.

// https://github.com/facebook/react/blob/fc8a898dd126198305fce458edd084c5d9c4b67a/packages/scheduler/src/forks/Scheduler.js#L516-L547

let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  // There's a few reasons for why we prefer setImmediate.
  //
  // Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
  // (Even though this is a DOM fork of the Scheduler, you could get here
  // with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
  // https://github.com/facebook/react/issues/20756
  //
  // But also, it runs earlier which is the semantic we want.
  // If other browsers ever implement it, it's better to use it.
  // Although both of these would be inferior to native scheduling.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    // $FlowFixMe[not-a-function] nullable value
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

Fiber와 Scheduler는 통해 작업을 작은 단위로 분할하고, 아래와 같은 과정을 반복하며 작업을 처리합니다.

  1. 작업 시작: workLoop에서 작업 큐를 처리하고, 현재 작업을 실행합니다.

  2. 시간 초과 확인: shouldYieldToHost로 시간이 초과되었는지 확인합니다.

  3. 작업 중단 및 예약: 초과된 작업은 schedulePerformWorkUntilDeadline을 통해 다음 사이클로 연기됩니다.

  4. 작업 재개: MessageChannel이 호출되어 performWorkUntilDeadline으로 연기된 작업을 다시 실행합니다.

마무리

며칠 동안 글을 작성하며 후반부로 갈 수록 집중력이 조금 흐려진 것 같아 아쉬움도 남지만, 이번 기회를 통해 Fiber에 대해 궁금했던 점들을 이해하고 개념을 잡을 수 있어서 뿌듯합니다.

단순한 호기심에서 시작한 글이었지만, 이렇게 복잡하고 정교한 시스템을 설계하고 구현한 과정을 따라가며 개발자들의 노력과 통찰력에 경외감을 느끼기도 했습니다. 지금은 따라가기에도 벅차지만 저도 앞으로 더 성장해서 이런 생태계에 기여할 수 있는 개발자가 되고 싶습니다.

긴 글 읽어주셔서 감사합니다. 😊

참고

Profile picture

온승찬 | Frontend Developer