[React] Fiber 아키텍처로 알아보는 React 렌더링

또이·2024년 11월 5일

React가 VDOM을 사용해서 DOM 접근을 최소화하기 때문에 렌더링을 최적화해준다는 사실을 우리는 이미 알고 있습니다. 그렇다면 어떻게 그렇게 동작된다는 것인지 Fiber 아키텍처로 React 렌더링 과정에 대해 알아보겠습니다.

기존의 Stack Reconciler

원래 React는 Stack Reconciler 방식으로 렌더링했습니다.

  1. Render Tree를 생성하고
  2. 하위 컴포넌트로 내려가면서 재귀적으로 모든 노드를 렌더링하고
  3. 동기적으로 작업을 진행했습니다.

그런데 기존 렌더링 방식에는 문제가 있었습니다. VDOM은 콜 스택에서 동기적으로 실행되기 때문에 중간에 중단할 수 없었고, VDOM의 깊이가 깊어질수록 성능 문제가 발생할 수 있었습니다.

// 엄청 큰 데이터를 처리한다고 가정합니다.
function BigComponent() {
  // 이 작업이 시작되면 끝날 때까지 다른 작업을 못하게 됩니다.
  return <>{/* 엄청 많은 데이터 */}</>
}

이처럼 렌더링 작업이 길어지면 메인 스레드가 잠기면서 반응성이 떨어지고, 이벤트 루프가 추가적으로 들어온 작업을 실행할 수 없게 됩니다.

이러한 문제를 해결하기 위해 React 16에서 Fiber라는 새로운 방식이 도입되었습니다.

Fiber란?

React 렌더링 작업을 효율적으로 관리해주는 Fiber는 컴포넌트의 상태와 입력/출력 정보들을 갖고 있는 Javascript 객체입니다.

Fiber에는 두 가지 중요한 의미가 있습니다.

  1. Reconciliation 알고리즘을 나타냅니다.
    Fiber는 어떤 부분을 어떻게 업데이트해야 할지를 결정하는 과정입니다.
  2. Rendering Work Unit을 의미합니다.
    Fiber는 작업을 더 작은 단위로 나누어 관리하며, 필요할 때 중단하거나 재개할 수 있게 합니다.

Fiber의 주요 목표는 렌더링 작업을 잘게 쪼개서, 우선순위를 부여하고 비동기적으로 실행하는 것입니다. 예를 들어, 애니메이션 업데이트가 데이터 스토어 업데이트보다 더 높은 우선순위를 부여받아 먼저 처리될 수 있도록 합니다.

💡 어떻게 비동기적으로 실행하나요?
Fiber는 기존의 React가 Pull 방식으로 접근한 것과 다르게 Push 기반 접근을 사용하기 때문입니다.
Pull 방식에서는 프레임워크가 스케줄링을 결정하는 반면, Push 방식에서는 프로그래머가 스케줄링 결정을 할 수 있습니다.

렌더링 프로세스

Fiber 아키텍처는 렌더링 과정을 렌더 단계(Render Phase)와 커밋 단계(Commit Phase)로 2단계로 나눠서 진행하며 DOM 수정을 최소화합니다.

  • Render Phase
    • 컴포넌트(변경사항)을 계산하고 업데이트 사항을 파악하는 단계
    • 컴포넌트를 호출해서 ReactElement 객체들을 모아 Virtual DOM 생성
    • 비동기식
    • 우선순위가 낮으면 중단 가능
  • Commit Phase
    • 변경사항을 실제 DOM에 반영하는 단계
    • 중단 없이 동기적으로 실행되어 UI의 일관성 유지

만약 업데이트가 발생한다면, 렌더 단계부터 다시 시작하여 DOM의 변경점을 계산하고 Reconciliation을 통해 최소한의 DOM 수정으로 렌더링 성능을 최적화합니다.

단일 연결 리스트 구조의 Fiber

virtual dom에서 createElement 로 반환한 UI 값들은 Fiber Node로 변환이 되고,
React는 Fiber Node를 단일 연결 리스트 형태로 구성하여, 렌더링 작업을 순차적으로 이어갈 수 있습니다.

각 Fiber Node는 child, sibling, return 속성을 통해 재귀적인 트리 구조를 형성하며,

child → sibling → return 순서로 탐색합니다.

따라서 아래 코드는

<A1>
	<B1>
		<C1 />
		<C2 />
	</B1>
	<B2>
		<C3 />
		<C4 />
	</B2>
</A1>

아래 그림처럼 구성되어있습니다.

A1 탐색 > B1 탐색 > C1 탐색 > C1 완료 > C2 탐색 > C2 완료 > B1 완료 > B2 탐색 > C3 탐색 > C3 완료 > C4 탐색 > C4 완료 > B2 완료 > A1 완료

root Fiber가 완성되고 commitWork()가 호출되어 변경 사항이 DOM에 반영됩니다. 재귀적 호출이 아닌 while 루프(work loop)를 통해 하나의 sibling, 하나의 return을 사용하는 트리 아키텍처로 작업이 처리되기 때문에 메모리 사용을 줄일 수 있습니다.

  • Work Loop?
    		// 모든 Reconcile 과정은 workLoopConcurrent에서 수행
    function workLoopConcurrent() {
      // 작업할 fiber node가 남아있고 scheduler가 yeild를 요청하지 않는한 호출 
      while (workInProgress !== null && !shouldYield()) {
        performUnitOfWork(workInProgress);
      }
    }
    여기서 shouldYield()는 다음과 같은 조건을 확인합니다.
    • 현재 시간이 데드라인을 넘었는지
    • 우선순위가 높은 작업이 들어왔는지
    • 브라우저가 메인 스레드를 필요로 하는지

Effects

Fiber의 렌더링 프로레스에서는 Effects라는 개념이 중요한 역할을 합니다.

Effects는 특정 변화가 발생한 Fiber 노드들을 모아서 한꺼번에 처리할 수 있게 하는 구조입니다.

React는 Fiber 아키텍처를 통해 Effect List를 구성하여 변경 사항이 발생한 Fiber 노드들을 모으고, Commit Phase에서 이를 한 번에 DOM에 적용합니다. 이로 인해 여러 번 DOM에 접근하지 않고도 필요한 DOM 업데이트만 수행할 수 있어 성능이 향상됩니다.

Effect는 createElement로부터 시작해 setState와 같은 함수로 상태가 변경될 때 생성됩니다. 변경된 노드들은 Effect List에 추가되며, Commit Phase에서 이 리스트를 순회하면서 DOM에 반영됩니다.

Fiber 구조와 재사용

interface FiberNode {
  // 1. 인스턴스 관련
  tag: WorkTag;                 // 컴포넌트 유형 (함수형, 클래스형 등)
  key: null | string;          // React element의 key
  elementType: any;            // 컴포넌트의 실제 타입
  type: any;                   // 실행될 함수 또는 클래스
  stateNode: any;              // 컴포넌트 인스턴스/DOM 노드

  // 2. Fiber 트리 구조
  return: Fiber | null;        // 부모 Fiber
  child: Fiber | null;         // 첫 번째 자식 Fiber
  sibling: Fiber | null;       // 다음 형제 Fiber
  index: number;               // 형제들 사이에서의 인덱스

  // 3. 작업 관련
  pendingProps: any;           // 새로 들어온 props
  memoizedProps: any;          // 이전 props
  memoizedState: any;          // 이전 state
  updateQueue: UpdateQueue<any> | null; // 상태 업데이트 큐

  // 4. 이펙트 관련
  flags: Flags;                // 수행해야 할 작업 표시
  subtreeFlags: Flags;         // 자식들의 작업 표시
  alternate: Fiber | null;     // 다른 트리의 대응되는 Fiber
}
  • type과 key
    • type: 호출이 스택 프레임에 의해 추적되는 함수. 함수 호출을 추적하며 Fiber Node의 종류
    • key: Fiber Node가 재사용될 수 있는지 확인하는 데 사용
  • pendingProps와 memoizedProps
    • pendingProps: 함수 실행 초기의 props 값
    • memoizedProps: 함수 실행 마지막의 props 값
    • 두 값이 같다면 Fiber는 이전 결과를 재사용해 불필요한 작업을 방지
  • pendingWorkPriority
    • fiber의 우선순위
    • 0을 갖는 NoWork의 경우를 제외하고, 높은 숫자는 낮은 우선순위
      export const priorities = {
        ImmediatePriority: 1,// 즉시 실행 필요
        UserBlockingPriority: 2,// 사용자 인터랙션
        NormalPriority: 3,// 일반적인 업데이트
        LowPriority: 4,// 지연 가능한 작업
        IdlePriority: 5,// 여유 있을 때 실행
      };

Fiber Tree와 Alternate

Fiber 아키텍처에서는 2가지의 Fiber Tree가 있습니다.

  • current
    • 현재에 화면에 렌더링된 Fiber 트리
    • 변경없이 그대로 유지되며 기존 상태를 반영하고 있어 비교의 기준점
  • work-in-progress
    • 현재 업데이트 작업이 진행 중인 트리
    • 새로운 상태나 업데이트 내용이 반영되는 Fiber 노드 포함
    • 완성이 되면 기존 current 트리를 대체하여 새 current 트리가 됨

이렇게 두 개의 Fiber Tree(current와 work-in-progress)를 교차해 사용하면서 React는 DOM 접근을 최소화하고, 필요한 부분만 효율적으로 업데이트할 수 있습니다.

이 두 트리는 alternate 속성을 통해 서로 연결되어 있습니다.
Fiber는 alternate 속성을 통해 필요한 경우 기존 노드를 재사용할 수 있게 해줍니다.

기존 Fiber를 cloneFiber 함수를 사용해 복제하여 메모리 할당을 최소화하고 성능을
최적화합니다. 작업이 완료되면 work-in-progress는 새 current가 되어 화면에 표시되며, 이전 current는 다음 업데이트 작업에서 work-in-progress로 전환될 준비를 합니다.

React 어플리케이션에서 많은 current, workInProgress 쌍이 교차하며 작업하는데,

alternate 속성 덕분에 이 쌍의 두 Fiber가 서로 대체 역할을 수행하며 효율적인 렌더링을 유지할 수 있습니다.

function finishSync(root) {
  const previous = root.current;
  root.current = root.current.alternate; // 교체 작업
  root.current.alternate = previous;     // 이전 트리를 alternate로 연결
}

Fiber 렌더링 동작 방식

Render Phase

  1. beginWork
function beginWork(current, workInProgress, renderLanes) {
  // current: 기존 Fiber 노드
  // workInProgress: 업데이트 작업 중인 Fiber 노드
  switch (workInProgress.tag) { // 컴포넌트 유형 구분
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);
    case FunctionComponent:
      return updateFunctionComponent(current, workInProgress, renderLanes);
    // 다른 컴포넌트 타입 처리
    default:
      return null;
  }
}

beginWork는 각 Fiber Node를 순회하며 컴포넌트의 업데이트 여부를 판단합니다. 변경이 필요한 경우 새로운 Fiber 노드를 생성하거나, 기존 노드를 재사용할지 결정합니다.

여기서 HostRoot는 최상위 트리(root)의 Fiber 노드이고, FunctionComponent는 함수형 컴포넌트를 처리하는 노드입니다. beginWork는 각 노드를 순차적으로 확인하면서, 자식 노드가 있으면 재귀적으로 탐색하고, 변경 사항이 발생한 노드만 work-in-progress 트리에 추가합니다.

  1. completeWork

completeWorkbeginWork가 끝난 후 호출되며, 작업이 완료된 Fiber 노드를 처리하는 역할을 합니다. 이 함수는 각 Fiber 노드를 완료 상태로 표시하고, 부모 노드로 올라가면서 작업을 마무리합니다.

function completeWork(current, workInProgress, renderLanes) {
  if (workInProgress.tag === HostComponent) {
    // DOM 노드를 생성하고, 새로운 상태를 반영
    createInstance(workInProgress);
  }
  // 부모로 돌아가면서 상태 업데이트
  return workInProgress.return;
}

completeWork는 DOM 노드를 생성하고, 최종적으로 work-in-progress 트리를 완성합니다. Render Phase가 끝나면 이 트리는 Commit Phase로 넘어갈 준비를 마칩니다.

Commit Phase

  1. commitRoot

commitRoot는 work-in-progress 트리를 DOM에 반영하는 최종 단계입니다. Effect List를 순회하며 변경 사항을 DOM에 적용합니다.

function commitRoot(root) {
  const finishedWork = root.finishedWork;
  root.finishedWork = null; // null로 초기화하여 재사용 대비
  // Effect List 순회
  let nextEffect = finishedWork.firstEffect;
  while (nextEffect !== null) {
    commitWork(nextEffect);
    nextEffect = nextEffect.nextEffect;
  }
   // 업데이트 완료 후, current에 반영
  root.current = finishedWork;
}

commitRootfinishedWork(완료된 작업 트리)를 순회하며 모든 Effect를 DOM에 적용합니다. firstEffect는 변경 사항이 있는 첫 번째 Fiber Node를 가리키고 있으며, 이를 순회하면서 변경 사항을 일괄 적용합니다.

  1. commitWork

commitWorkcommitRoot에서 호출되며, 각 Fiber 노드의 변경 사항을 DOM에 적용합니다. 이 함수는 노드의 타입에 따라 변경 내용을 반영합니다.

function commitWork(fiber) {
  switch (fiber.tag) {
    case HostComponent:
      const domParent = fiber.return.stateNode;
      if (fiber.effectTag === Placement) {
        domParent.appendChild(fiber.stateNode); // 새 노드 추가
      } else if (fiber.effectTag === Update) {
        updateDomProperties(fiber.stateNode, fiber); // 기존 노드 갱신
      }
      break;
    // 다른 타입 처리...
  }
}

commitWork는 Fiber Node의 effectTag에 따라, DOM에 추가, 삭제, 속성 업데이트 등 필요한 작업을 수행합니다. Placement는 새 노드를 추가하고, Update는 기존 노드를 갱신하는 작업을 담당합니다.

  • placement 의 예시
// 기존 트리에 없던 새로운 컴포넌트가 추가될 때
function MyComponent() {
  return (
    <div>
      <p>Existing Element</p>
      <span>New Element</span> {/* 새로운 요소 */}
    </div>
  );
}
  • update의 예시
// 기존 요소의 내용이 바뀌는 경우
function MyComponent({ text }) {
  return <p>{text}</p>; // text prop이 변경될 때 Update 발생
}

Fiber의 재조정(Reconciliation) 프로세스

  1. reconcileChildFibers
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any
): Fiber | null {
// 1. key를 사용한 최적화
  const existingChildren = mapRemainingChildren(currentFirstChild);

// 2. 새로운 자식들과 매칭
  while (newIdx < newChildren.length) {
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx]
    );
    if (newFiber) {
	// 재사용 가능한 Fiber가 있다면 자식 위치에 맞게 plceChild를 사용해 배치
      placeChild(newFiber, lastPlacedIndex, newIdx);
    }
  }
}

key를 사용해서 기존 child와 새로운 child를 매칭하는 함수입니다.
차이점을 탐지해서 재사용 가능한 Fiber를 찾아 최적화할 수 있습니다.

  1. completeUnitOfWork
function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork; // 현재 작업이 끝난 Fiber 참조

  do {
    const current = completedWork.alternate; // 기존 Fiber의 alternate
    const returnFiber = completedWork.return; // 부모 Fiber

// 이펙트 리스트 구성
    if (returnFiber !== null) { // 부모(return)가 존재할 경우
      // 부모 노드의 첫번째 Effect
      if (returnFiber.firstEffect === null) {
        returnFiber.firstEffect = completedWork.firstEffect;
      }
      // 부모 노드의 마지막 Effect
      if (completedWork.lastEffect !== null) {
        if (returnFiber.lastEffect !== null) {
          // 이전 lastEffect의 nextEffect를 새로운 firstEffect로 설정해 연결
          returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
        }
        returnFiber.lastEffect = completedWork.lastEffect;
      }
    }
  } while ((completedWork = next) !== null); // 다음 Fiber 노드로 이동 반복
}

Fiber 트리의 각 노드를 순회하면서 작업이 완료된 Fiber 노드를 처리하고, 이펙트 리스트(Effect List)를 구성합니다.

Fiber 아키텍처 도입으로 인한 성능 최적화

  1. 작업 분할과 우선순위
function ensureRootIsScheduled(root: FiberRoot) {
  const nextLanes = getNextLanes(root, NoLanes); // 현재 필요한 작업의 우선순위

  if (nextLanes === NoLanes) {
    // 우선순위 작업이 없으면 함수 종료
    return;
  }

  let newCallbackPriority = getHighestPriorityLane(nextLanes); // 가장 높은 우선순위 작업 확인
  let newCallbackNode;

  if (newCallbackPriority === SyncLane) {
    // 동기 작업을 즉시 스케줄링
    newCallbackNode = scheduleSyncCallback(
      performSyncWorkOnRoot.bind(null, root)
    );
  } else {
    // 비동기 작업을 스케줄링
    const schedulerPriorityLevel = lanePriorityToSchedulerPriority(
      newCallbackPriority
    );
    newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,
      performConcurrentWorkOnRoot.bind(null, root)
    );
  }
}

2. 메모이제이션

function bailoutOnAlreadyFinishedWork(
  current: Fiber | null,
  workInProgress: Fiber
): Fiber | null {
  if (current !== null) {
    // props와 state가 변경되지 않았다면 재사용
    workInProgress.memoizedProps = current.memoizedProps;
    workInProgress.memoizedState = current.memoizedState;
    return null;
  }
}

결론

Fiber 아키텍처는 React의 렌더링 방식에 큰 변화를 가져와, 기존의 Stack Reconciler가 가지고 있던 한계를 극복하고 React의 성능을 최적화했습니다.

Fiber 덕분에 React는 비동기적 렌더링우선순위 기반의 작업 처리가 가능해졌고, 렌더링 중 중요한 작업이 발생하면 중단과 재개를 통해 메인 스레드의 블로킹 문제를 해결했습니다. 또한, 에러 바운더리를 통해 오류가 발생한 부분만 효과적으로 복구함으로써 더 안정적인 사용자 경험을 제공합니다.

Fiber는 대규모 UI 업데이트에서도 높은 성능과 반응성을 유지하면서, UI를 매끄럽게 유지할 수 있도록 돕는 React의 핵심 구조입니다.

참고

https://medium.com/stayfolio-tech/react%EA%B0%80-0-016%EC%B4%88%EB%A7%88%EB%8B%A4-%ED%95%98%EB%8A%94-%EC%9D%BC-feat-fiber-1b9c3839675a

https://www.hanna-dev.com/posts/react-fiber-in-reconcile-phase/

https://youtu.be/0ympFIwQFJw?si=_4osDGjeln6YCPmx

profile
또이의 개발새발 개발일기

2개의 댓글

comment-user-thumbnail
2024년 11월 6일

안녕하세요 채현님 작성하신 2주차 아티클 잘 읽었습니다!!
렌더링을 효율적으로 관리해주는 Fiber에 대해서 자세히 작성해주셨는데, 깊이 있게 다뤄주신 덕분에 몰랐던 부분도 많이 알 수 있었고 적절한 사진이랑 코드 , 예시들을 사용해주신 덕분에 가독성도 좋고 어렵지 않게 이해할 수 있었어요.
고생하셨습니다~!!

답글 달기
comment-user-thumbnail
2024년 11월 6일

2주차 아티클 잘 읽었습니다!
탐색, 동작 프로세스를 아주 자세히 알려주셔서 깊게 이해할 수 있었어요. Fiber 아키텍처가 어떻게 렌더링 성능을 향상시키는지 자세하게 알게되는 계기가 되었습니다! 고생하셨습니다.

답글 달기