파이버는 리액트의 작업 단위이자 컴포넌트의 상태를 보관하는 객체로, 렌더링을 중단·재개 가능하게 만들기 위해 도입된 자료구조이다.
파이버가 등장한 배경
-
리액트 16 이전에는 스택 재조정자(
Stack Reconciler)라는 방식으로 렌더링을 수행했다. -
이 방식은 컴포넌트 트리를 재귀 호출로 순회하면서 변경 사항을 한 번에 처리했다.
-
재귀 호출은 호출 스택 안에 현재 작업 위치가 암묵적으로 저장되기 때문에 한 번 시작되면 중간에 멈출 수 없다.
-
트리가 크거나 계산이 무거운 컴포넌트가 있으면 메인 스레드가 오래 점유된다.
-
60fps 환경에서는 한 프레임이 약 16ms이며, 이 시간 안에 브라우저가 JS 실행, 스타일 계산, 레이아웃, 페인트, 합성을 모두 끝내야 한다.
-
따라서 렌더링이 메인 스레드를 오래 점유하면 입력, 애니메이션, 페인트가 지연되어 화면이 끊겨 보인다.
-
결국 렌더링 작업을 잘게 쪼개고, 메인 스레드에 주기적으로 제어권을 양보하면서, 우선순위에 따라 중단·재개할 수 있는 새로운 구조가 필요해졌다.
파이버의 정체
-
파이버는 두 가지 의미를 동시에 가진다.
-
하나는 리액트 16에서 도입된 새로운 재조정 알고리즘의 이름이다.
-
다른 하나는 그 알고리즘이 다루는 작업 단위, 즉 하나의 컴포넌트(또는 DOM 요소)에 대응하는 객체이다.
-
이번 글에서 다루는 "컴포넌트 안의 정보를 저장하는 객체"라는 관점은 후자의 의미에 가깝다.
-
즉 파이버 노드 하나는 컴포넌트 인스턴스 하나에 대한 메타데이터를 담는 자바스크립트 객체이다.
-
리액트는 컴포넌트 트리 전체를 파이버 노드의 연결 구조로 표현한 파이버 트리를 가지고 동작한다.
-
기존 재귀 기반 트리는 호출 스택 안에 현재 작업 위치가 암묵적으로 저장되지만, 파이버는 각 노드가 부모·자식·형제를 직접 가리키도록 만들어 현재 작업 위치를 객체 그래프로 명시적으로 저장한다.
-
이 덕분에 렌더링을 중간에 멈췄다가 같은 위치부터 다시 이어서 실행할 수 있다.
파이버 노드의 구조
-
파이버 노드는 자신의 위치, 자신이 어떤 컴포넌트인지, 그리고 어떤 상태를 가졌는지를 모두 담는다.
-
단순화하면 다음과 같은 필드들로 이루어져 있다.
const fiber = { type, // 함수/클래스 컴포넌트 또는 'div' 같은 태그 key, // 리스트 식별자 stateNode, // 실제 DOM 노드 또는 클래스 인스턴스 return, // 부모 파이버 child, // 첫 번째 자식 파이버 sibling, // 다음 형제 파이버 alternate, // current ↔ workInProgress 짝 pendingProps, // 새로 들어온 props memoizedProps, // 이전 렌더에서 사용한 props memoizedState, // 현재 파이버가 보관하는 상태 정보, 함수 컴포넌트에서는 훅 리스트의 시작점 updateQueue, // 처리 대기 중인 업데이트들 flags, // 수행해야 할 작업 표시 (추가/삭제/갱신 등) lanes, // 우선순위 비트마스크 }; -
return,child,sibling포인터를 사용해 순회 상태를 파이버 객체 자체에 저장하므로, 호출 스택에 의존하지 않고도 작업을 중단·재개할 수 있다. -
리액트는 이 포인터들을 따라 깊이 우선 탐색(
DFS) 방식으로 트리를 순회한다. -
자식이 있으면 자식 방향으로 끝까지 내려가고, 더 내려갈 자식이 없으면 형제로 이동하며, 형제도 없으면 부모로 거슬러 올라가 다음 형제를 찾는다.
-
alternate필드는 더블 버퍼링을 위한 장치이다. -
리액트는 화면에 반영된 현재 트리(
current)와 작업 중인 트리(workInProgress)를 동시에 두고, 두 트리의 같은 위치 노드를alternate로 서로 가리키게 한다. -
작업이 완료되면 두 트리의 역할을 통째로 교체하기 때문에 변경을 원자적으로 반영할 수 있다.
-
lanes는 이 파이버에 걸린 업데이트의 우선순위를 표현하는 32비트 비트마스크이다. -
리액트 16 초기에는 단일 숫자 기반의
ExpirationTime방식이었지만, 17 이후로는 여러 종류의 업데이트를 독립된 차선(Lane)에 할당하는Lanes시스템으로 바뀌었다. -
이 구조 덕분에 사용자 입력 같은 우선순위 높은 작업이 백그라운드 렌더링 같은 낮은 작업을 인터럽트하고 먼저 처리될 수 있다.
파이버와 훅의 관계
-
커스텀 훅을 공부하다 보면 파이버 개념을 마주하게 된다.
-
useState,useEffect같은 훅이 호출될 때 그 상태가 저장되는 장소가 바로 현재 렌더링 중인 컴포넌트의 파이버 노드이다. -
정확히는 함수 컴포넌트의 파이버에서
memoizedState필드가 첫 번째 훅 객체를 가리키고, 각 훅 객체가next포인터로 다음 훅을 가리키는 단방향 연결 리스트를 이룬다. -
개념적으로 표현하면 다음과 같다.
// fiber.memoizedState → 첫 번째 훅 객체 const hook1 = { memoizedState: 0, // useState의 현재 값 queue: { ... }, // 처리 대기 중인 업데이트 큐 next: hook2, // 다음 훅을 가리키는 포인터 }; const hook2 = { memoizedState: { create, deps }, // useEffect의 정보 next: null, }; -
훅은 이름이나 키로 식별되지 않고 호출 순서만으로 식별된다.
-
매 렌더마다 리액트는 파이버에 매달린 훅 리스트를 처음부터 순서대로 따라가며 각 훅 호출과 매칭한다.
-
그래서 훅을 조건문이나 반복문 안에서 호출하면 순서가 어긋나 다른 훅의 상태를 가져오는 문제가 발생한다.
-
커스텀 훅도 결국 내부에서 기본 훅을 호출하는 함수일 뿐이며, 호출되는 순간 그 호출이 일어난 컴포넌트의 파이버 리스트에 그대로 이어 붙는다.
-
즉 커스텀 훅이 "상태를 가진다"고 느껴지지만 실제로 상태는 그 훅을 호출한 컴포넌트의 파이버에 저장된다.
파이버의 작업 흐름
-
파이버 기반 렌더링은 크게 렌더 페이즈와 커밋 페이즈로 나뉜다.
-
렌더 페이즈는 기본적으로 중단 가능한 구조로 설계되어 있으며, 동시성 렌더링에서는 작업의 우선순위(
lane)에 따라 중단·재개될 수 있다. -
다만 동기 차선(
Sync lane)에 들어간 업데이트는 사실상 끊기지 않고 끝까지 실행된다. -
이 단계의 중심은 작업 루프이며, 한 번에 하나의 파이버를 처리하고 다음 파이버로 넘어간다.
-
개념적으로는 다음과 같은 구조이다.
function workLoop() { while (workInProgress !== null && !shouldYield()) { workInProgress = performUnitOfWork(workInProgress); } if (workInProgress !== null) { scheduleCallback(workLoop); // 다음 기회에 이어서 작업 } else { commitRoot(); // 모든 작업이 끝나면 커밋 } } -
각 파이버는
beginWork에서 자식 파이버를 생성하거나 갱신하며 트리를 내려가고, 더 내려갈 자식이 없으면completeWork로 위로 거슬러 올라간다. -
리액트 스케줄러는 현재 프레임의 실행 예산을 초과했다고 판단되면
shouldYield를 통해 작업을 멈추고 브라우저에 제어권을 양보한다. -
이후 다시 기회가 오면 멈춘 파이버부터 이어서 작업을 재개한다.
-
모든 파이버에 대한 계산이 끝나면 커밋 페이즈로 넘어간다.
-
커밋 페이즈는 중단 불가능한 단계로,
flags에 표시된 변경을 실제 DOM에 한 번에 적용한다. -
이 시점에
current와workInProgress의 역할이 교체되어 새 트리가 현재 트리가 된다. -
useEffect로 등록된 부수효과는 커밋 페이즈 내부가 아니라, 브라우저가 화면을 실제로 그린(Paint) 이후에 비동기적으로 실행된다. -
만약
useEffect가 동기적으로 실행되면 효과 함수의 무거운 작업이 페인트를 지연시켜 파이버 도입 목적인 60fps 유지가 흔들리기 때문이다. -
반면
useLayoutEffect는 DOM 반영 직후, 브라우저가 화면을 그리기 전에 동기적으로 실행된다.
파이버의 의의
-
파이버 덕분에 리액트는 렌더링을 잘게 쪼개어 우선순위에 따라 처리할 수 있게 되었다.
-
이를 기반으로
Suspense,useTransition, 동시성 렌더링 같은 기능이 가능해졌다. -
또한 훅이라는 패러다임 자체도 파이버가 있었기에 등장할 수 있었는데, 함수 컴포넌트가 매번 다시 호출되어도 파이버가 상태를 외부에서 안정적으로 보관해 주기 때문이다.
-
결국 파이버는 "리액트가 어떻게 렌더링하는가"와 "리액트가 상태를 어디에 보관하는가"라는 두 질문에 동시에 답하는 자료구조이다.
호스트 컴포넌트와 합성 컴포넌트
-
리액트 트리의 모든 노드는 자기만의 파이버를 가진다.
-
함수 컴포넌트, 클래스 컴포넌트뿐 아니라
div,p같은 HTML 태그도 각각 하나의 파이버 노드로 표현된다. -
리액트 내부에서는 이 둘을 구분하기 위해 합성 컴포넌트(
composite component)와 호스트 컴포넌트(host component)라는 용어를 쓴다. -
합성 컴포넌트는 함수나 클래스로 정의된 사용자 컴포넌트로, 다른 컴포넌트나 호스트 컴포넌트를 자식으로 반환한다.
-
호스트 컴포넌트는 렌더링 환경의 말단 노드이며, 브라우저에서는
div,span,p같은 HTML 태그가 이에 해당한다. -
파이버에서 둘을 구분하는 기준은
type필드의 값이다. -
합성 컴포넌트의
type은 함수나 클래스 자체이고, 호스트 컴포넌트의type은'div','p'같은 문자열이다. -
즉
<App><div><p /></div></App>같은 트리에서는App,div,p모두 각자의 파이버 노드를 가지며, 부모-자식 관계는child·return포인터로 연결된다. -
실제 DOM 변경이 발생하는 지점은 오직 호스트 컴포넌트의 파이버이며, 합성 컴포넌트의 파이버는 어떤 호스트 컴포넌트를 만들지 계산하는 역할만 한다.
리액트 엘리먼트와 파이버의 차이
-
JSX로 작성한 코드는 빌드 시React.createElement()또는_jsx()호출로 변환되어 리액트 엘리먼트라는 일반 자바스크립트 객체를 만든다. -
엘리먼트는 대략 다음과 같은 형태의 단순한 객체이다.
// <div className="box">Hello</div> 가 만드는 엘리먼트 const element = { type: 'div', props: { className: 'box', children: 'Hello' }, key: null, ref: null, }; -
엘리먼트는 "무엇을 그려야 하는가"를 설명하는 불변 객체이며, 매 렌더마다 새로 생성되고 버려진다.
-
반면 파이버는 "그 엘리먼트를 언제, 어떻게 처리할 것인가"를 추적하는 가변 객체이며, 컴포넌트 인스턴스 단위로 살아남아 재사용된다.
-
처음 마운트할 때 리액트는 각 엘리먼트로부터 대응되는 파이버를 생성하고, 엘리먼트의
type과key를 그대로 파이버에 복사한다. -
이후 재렌더가 일어나면 새로 만들어진 엘리먼트를 같은 자리의 기존 파이버와 비교하고,
type과key가 같으면 파이버를 재사용하면서pendingProps만 갱신한다. -
정리하면 엘리먼트는 매번 새로 만들어지는 일회용 설명서이고, 파이버는 그 설명서들을 받아 작업 상태를 누적 관리하는 영속적인 작업 단위이다.