React Fiber 이해하기: 최신 리액트 성능을 뒷받침하는 아키텍처
Suspense, transition, concurrent rendering 등 현대 리액트의 핵심 기능들은 모두 React Fiber 위에서 동작합니다. Fiber가 등장하기 전 동기적 렌더링의 한계부터, lanes 기반 우선순위 시스템, 그리고 Render/Commit 단계의 동작 방식까지 살펴봅니다.
이 글은 Understanding React Fiber: The Architecture Behind Modern React Performance를 번역한 글입니다.
리액트는 초기와 비교했을 때 많은 발전을 해왔습니다. Suspense, transition, concurrent rendering 그리고 부드러운 UI 반응성은 이제 당연한 것처럼 느껴집니다. 하지만 이들 중 그 어떤 것도 리액트 fiber가 없었다면 존재할 수 없었을 것입니다.
🌃 Fiber가 개발되기 전: 동기적이고 중단 불가능한 렌더링
리액트 15와 그 이전 버전에서 렌더링은 간단한 방식으로 동작했습니다.
- 리액트는 전체 가상 DOM을 위에서 아래로 순회합니다.
- 이전 트리와 비교합니다.
- 변경사항들을 리스트로 만듭니다.
- 변경사항들을 실제 DOM에 적용합니다.
이 방식은 단순하지만 치명적인 문제가 있습니다.
렌더링은 동기적인 작업입니다. 일단 시작되면, 멈출 수 없습니다.
만약 컴포넌트 트리가 커지면 리액트는
- 메인 스레드를 점유합니다.
- 인풋 처리에 지연이 생깁니다.
- 애니메이션이 멈춥니다.
- UI의 반응이 느려집니다.
인풋에 글자를 입력했는데 화면에 나타나는 데 시간이 걸린다고 상상해 보세요. 이 점이 예전 버전의 리액트가 가지고 있던 문제였습니다.
리액트는 좀 더 나은 시스템이 필요했습니다. 빠른 렌더링이 아닌 중지할 수 있는 시스템이 필요했습니다.
이 문제를 해결하기 위해 react fiber가 개발되었습니다. 리액트 fiber는 협력적 스케줄링 기반의 렌더링 엔진입니다.
섹션 요약
- 리액트 15 이전에는 렌더링이 동기적으로 동작하여 한번 시작되면 멈출 수 없었다
- 컴포넌트 트리가 커지면 메인 스레드를 점유해 UI 반응성이 떨어졌다
- 이를 해결하기 위해 중단 가능한 렌더링 엔진인 React Fiber가 개발되었다
🌃 스케줄러 + 가상 DOM 구조는 중단할 수 있는 렌더링을 위해 고안된 아키텍처입니다.
전체 트리를 하나의 거대한 콜스택에서 렌더링하는 대신, fiber는
- 작업을 작은 단위들로 나눕니다. (이 단위를 Fibers라고 부릅니다.)
- 작은 작업들을 하나씩 처리합니다.
- 이 작업들이 잠깐 멈춰야 하는지, 아예 중단되어야 하는지 계속되어야 하는지 확인합니다.
모든 fiber 노드는 아래의 정보를 가지고 있습니다.
- 컴포넌트 타입
- props
- pending state
- 부모, 자식, 형제를 가리키는 포인터
- 우선순위에 대한 메타 정보
리액트는 두 개의 fiber 트리를 가지고 있습니다.
🎆 Current tree
현재 DOM에 보이는 UI를 나타내는 트리입니다.
🎆 Work-in-progress tree
리액트가 렌더링 과정에서 만드는 "초안(draft)" 트리입니다.
이를 통해 리액트는 언제든지 진행 중인 작업을 일시 중지하거나 종료할 수 있습니다.
예를 들어 만약 사용자가 렌더링 과정 중에 인풋에 글자를 입력한다면, 리액트는 현재 작업을 멈추고 키보드 입력을 처리한 후에 작업을 재개하거나 필요에 따라 처음부터 다시 시작합니다.
리액트 fiber 덕분에 렌더링 과정은 중단할 수 있고, 우선순위가 높은 작업을 먼저 처리할 수 있으며 UI의 반응성을 유지할 수 있습니다.
섹션 요약
- Fiber는 작업을 작은 단위(Fibers)로 나누어 하나씩 처리하며, 중지/재시작이 가능하다
- 각 fiber 노드는 컴포넌트 타입, props, state, 포인터, 우선순위 정보를 가진다
- Current tree와 Work-in-progress tree 두 개의 트리를 활용해 언제든 작업을 중단하거나 재개할 수 있다
🌃 Lanes: 리액트 내부의 우선순위 시스템
작업의 우선순위를 판별하기 위해 리액트는 lanes라는 비트마스크로 구현된 분류체계를 사용합니다.
개발자 관점에서 이들은 두 개의 큰 범주로 나뉩니다.
🎆 우선순위가 높은 업데이트 - 반드시 즉시 실행되어야 하는 업데이트
이 업데이트는 사용자의 직접적인 인터랙션에서 발생합니다.
- 타이핑
- 클릭
- 스크롤
- 드래그
이 액션들은 UI의 반응성을 위해 최대한 빠르게 처리되어야 합니다.
🎆 Transition 업데이트 - 중요하지만 급하지는 않은 업데이트
이 업데이트는 시간이 오래 걸리고, 비용도 많이 듭니다.
- 큰 리스트 필터링
- 복잡한 차트 업데이트
- UI의 큰 부분 교체
- 중요하지 않은 애니메이션 재생
업데이트의 우선순위를 낮추려면 아래와 같이 작성하면 됩니다.
startTransition(() => {
setState(...)
})
이렇게 하면 리액트는 유저 인풋을 먼저 처리하고 transition을 나중에 처리합니다.
섹션 요약
- 리액트는 비트마스크 기반의 lanes를 사용해 작업의 우선순위를 분류한다
- 타이핑, 클릭 등 사용자 인터랙션은 높은 우선순위로 즉시 처리된다
startTransition을 사용하면 업데이트의 우선순위를 낮춰 나중에 처리되도록 할 수 있다
🌃 리액트에 있는 6가지 Lane들
리액트에는 여섯 가지 lane이 있습니다.
🎆 SyncLane
가장 높은 우선순위를 가지고 있어 즉시 처리됩니다. 동기적으로 처리되며 작업이 모두 끝날 때까지 메인 스레드를 점유합니다. flushSync를 사용하면 SyncLane으로 업데이트를 스케줄링할 수 있습니다.
🎆 InputContinuousLane
클릭, 키 입력, 스크롤, 드래그와 같은 사용자의 인터랙션을 처리하며 지연 없이 처리되어야 합니다.
🎆 DefaultLane
일반적인 렌더링 작업이 DefaultLane으로 스케줄링되며, 대부분의 업데이트가 여기에 해당합니다.
🎆 TransitionLane
startTransition을 사용하여 명시적으로 급하지 않다고 표시된 작업이 스케줄링됩니다.
🎆 RetryLane
Suspense 등으로 인해 이전에 실패했던 작업이 스케줄링됩니다.
🎆 IdleLane
브라우저가 현재 작업이 없을 때에만 처리할 작업들이 스케줄링됩니다.
Prefetching이나 offscreen 렌더링, 로그 전송과 같은 작업이 여기에 해당됩니다.
섹션 요약
- SyncLane(즉시), InputContinuousLane(사용자 인터랙션), DefaultLane(일반 렌더링)은 높은 우선순위를 가진다
- TransitionLane(급하지 않은 작업), RetryLane(실패 재시도), IdleLane(유휴 시 작업)은 낮은 우선순위를 가진다
🌃 Render vs Commit: 리액트 렌더링의 두 가지 단계
리액트 fiber는 렌더링을 두 가지 주요 단계로 나눕니다.
🎆 Render 단계 (중단 가능)
이 단계에서 리액트는 fiber들을 처리하며, 가상 DOM의 변경점을 계산하고 work-in-progress 트리를 만듭니다.
리액트는 작업을 중지, 무시, 재시작할 수 있으며 우선순위가 높은 작업을 먼저 처리할 수도 있습니다.
이를 통해 싱글 스레드 환경에서도 모든 작업을 "동시에" 처리하는 것처럼 보일 수 있습니다.
이 단계에서는 아직 실제 DOM에 변경이 반영되지 않습니다.
🎆 Commit 단계 (중단 불가능)
일단 최종 결과물이 만들어지면, 리액트는 이 변경 사항들을 DOM에 반영합니다.
Commit 단계는 항상 동기적으로 실행됩니다. 중간에 멈추면 UI 상태가 깨질 수 있기 때문입니다.
Commit 단계는 네 가지 하위 단계로 나뉩니다.
1. Snapshot Phase
리액트는 변경을 적용하기 전에 레이아웃 정보를 읽습니다. 이 단계에서 DOM에 쓰기는 발생하지 않고 읽기만 발생합니다.
2. Mutation Phase
리액트는 실제 DOM에 삽입, 수정, 제거, ref 업데이트와 같은 작업을 수행하며 이 단계는 멈출 수 없습니다.
3. Layout Phase
DOM이 업데이트되었지만 아직 그려지지(paint)는 않았습니다. 이 단계에서 useLayoutEffect가 실행됩니다.
이 훅은 스크롤 복원이나 레이아웃 크기를 가져올 때 주로 사용합니다. 한 가지 주의할 점은 이 단계가 브라우저의 paint를 막기 때문에 useLayoutEffect를 신중하게 사용해야 한다는 것입니다.
4. Passive Effects
브라우저가 painting을 완료하면 리액트는 useEffect를 실행합니다. 이 단계는 비동기적으로 실행되며 UI 업데이트를 막지 않습니다.
섹션 요약
- Render 단계는 중단 가능하며, 가상 DOM의 변경점을 계산하고 work-in-progress 트리를 만든다
- Commit 단계는 중단 불가능하며, Snapshot → Mutation → Layout → Passive Effects 순서로 실행된다
useLayoutEffect는 paint 전에,useEffect는 paint 후에 비동기적으로 실행된다
🌃 요약
리액트 fiber는 리액트를 빠르게 만들었을 뿐만 아니라 현대적인 리액트를 가능하게 했습니다.
리액트 fiber가 없었다면, 'concurrent rendering', 'Suspense', 'transition', '선택적 hydration', '부드러운 비동기 UI', '우선순위 제어'와 같은 것들은 불가능했을 것입니다.
Fiber는 리액트 역사상 가장 대규모의 내부 업데이트입니다.
컴포넌트 작성 방식은 그대로 두면서 내부적으로 모든 것이 변경되었습니다.