React Fiber Reconciliation
React를 쓰면서 "Virtual DOM이 빠르다"는 말은 수도 없이 들었을 것이다. 그런데 정작 어떻게 빠른 건지, 그 내부에서 무슨 일이 벌어지는지 제대로 설명할 수 있는 사람은 많지 않다. 이 글에서는 React 16부터 도입된 Fiber 아키텍처와 Reconciliation(재조정) 과정을 처음부터 끝까지 파헤쳐본다.
1. 왜 Fiber가 필요했는가 — Stack Reconciler의 한계
React 15까지의 렌더링 엔진을 Stack Reconciler라고 부른다. 이름 그대로 JavaScript의 콜 스택(call stack)에 의존한 재귀적 깊이 우선 탐색(DFS)으로 컴포넌트 트리를 순회했다. mountComponent()와 updateComponent()를 재귀 호출하면서, 하나의 tick 안에 전체 트리의 render 함수를 죄다 실행하는 구조였다.
세 가지가 치명적이었다.
첫째, 렌더링을 중간에 멈출 수 없었다. 렌더링이 시작되면 전체 서브트리가 완료될 때까지 브라우저는 꼼짝할 수 없다. 브라우저는 60fps 유지를 위해 프레임당 약 16ms의 시간 예산을 갖는데, 대규모 컴포넌트 트리의 reconciliation이 이를 초과하면? 프레임 드롭 즉, UI 버벅임이 발생한다.
둘째, 우선순위 개념이 없었다. 애니메이션, 사용자 입력 같은 "지금 당장 처리해야 하는 것"과 네트워크 데이터 렌더링 같은 "잠깐 늦어도 되는 것"이 동일한 긴급도로 순차 처리됐다. 텍스트 입력 중에 백그라운드 대규모 리렌더링이 발생하면? 타이핑이 멈춘다.
셋째, 메인 스레드 독점. JavaScript는 싱글 스레드다. 동기 렌더링 중에는 사용자 입력, 애니메이션, 브라우저 레이아웃/페인트가 전부 대기해야 했다.
2. Fiber란 무엇인가 — "React 전용 콜 스택"
React 팀의 Andrew Clark은 Fiber를 이렇게 정의했다:
"Fiber is a reimplementation of the stack, specialized for React components. You can think of a single fiber as a virtual stack frame."
핵심은 이거다. JavaScript의 콜 스택은 한번 시작하면 중간에 멈출 수 없다. 그래서 React 팀은 자체 스택을 직접 구현해버렸다. 이 자체 스택은 JavaScript 객체(Fiber Node)의 연결 리스트로 이루어져 있기 때문에, 메모리에 스택 프레임을 저장해두고 원하는 시점에 다시 꺼내서 실행할 수 있다.
이걸 통해 Fiber가 달성하고자 한 핵심 목표 네 가지:
작업 중단 및 나중에 재개
작업 종류별 우선순위 부여
이전에 완료한 작업 재사용
더 이상 필요 없는 작업 폐기
React 16(2017년 9월)이 Fiber 위에 구축된 첫 번째 릴리즈다. 다만 초기에는 기존과 동일한 동기 모드(compatibility mode)로 출시됐고, 비동기 스케줄링은 React 18(2022년)에서 Concurrent Features로 본격 활성화됐다.
3. Fiber Node 해부하기
Fiber Node는 React의 가장 작은 작업 단위(unit of work)이다. 컴포넌트 하나당 Fiber Node 하나(정확히는 두 개, 이유는 뒤에서 설명)가 대응된다. React 소스코드(packages/react-reconciler/src/ReactFiber.js)에서 실제 구조를 뜯어보자.
function FiberNode(tag, pendingProps, key, mode) {
// === Instance (이 Fiber가 뭔지) ===
this.tag = tag; // WorkTag: 컴포넌트 종류 (FunctionComponent=0, ClassComponent=1, HostComponent=5 등)
this.key = key; // 우리가 JSX에서 넣는 그 key
this.elementType = null; // React element의 type (React.lazy에서 구분됨)
this.type = null; // 실제 타입 (함수 컴포넌트면 함수 자체, div면 "div" 문자열)
this.stateNode = null; // 실체 참조 (DOM 노드 or 클래스 인스턴스)
// === Fiber Tree 구조 (Linked List) ===
this.return = null; // 부모 fiber ("return"인 이유: stack frame의 return address에서 유래)
this.child = null; // 첫 번째 자식 fiber
this.sibling = null; // 다음 형제 fiber
this.index = 0; // 형제들 사이에서의 순서
this.ref = null;
// === Props & State ===
this.pendingProps = pendingProps; // 아직 처리 안 된 새 props
this.memoizedProps = null; // 이전 렌더링에서 확정된 props
this.updateQueue = null; // 상태 업데이트 대기열
this.memoizedState = null; // 이전 렌더링의 state (hooks의 linked list가 여기 저장!)
this.dependencies = null; // Context 의존성
this.mode = mode;
// === Effects (부수 효과) ===
this.flags = NoFlags; // side-effect 비트필드 (Placement, Update, Deletion 등)
this.subtreeFlags = NoFlags; // 서브트리 전체의 effect 합산
this.deletions = null; // 삭제할 자식 목록
// === Priority (우선순위) ===
this.lanes = NoLanes; // 이 fiber에 보류 중인 업데이트 우선순위
this.childLanes = NoLanes; // 자식 트리에 보류 중인 업데이트 우선순위
// === Double Buffering ===
this.alternate = null; // 반대편 트리의 대응 fiber (핵심!)
}이게 진짜 소스코드에 있는 구조다. 하나씩 중요한 것만 짚어보자.
트리 구조 — child / sibling / return
일반적인 트리를 생각하면 부모가 자식 배열을 가지고 있는 형태를 떠올린다. 하지만 Fiber는 다르다. Singly-linked list 형태로 트리를 표현한다.
App (HostRoot)
│ child
div
│ child
Header ──sibling──→ Main ──sibling──→ Footer
│ child ↑
Logo ──sibling──→ Nav return (부모 참조)child는 첫 번째 자식만 가리킨다. 나머지 자식들은 sibling으로 연결된다. return은 부모를 가리킨다.
왜 이런 구조를 택했을까? Sebastian Markbåge(Fiber 핵심 설계자)가 밝힌 이유: 재귀 없이 while 루프만으로 트리를 순회할 수 있고, 현재 처리 중인 fiber 노드 하나만 기억하면 언제든 작업을 중단하고 같은 지점에서 재개할 수 있기 때문이다.
stateNode — 실체와의 연결
Host component (
div,span등): 실제 DOM 노드(HTMLElement)Class component:
this인스턴스 객체Function component:
null(인스턴스가 없으니까)HostRoot: FiberRoot 객체
memoizedState — Hook이 숨어있는 곳
Function component에서 useState, useEffect 등을 호출하면, 그 Hook들의 상태는 어디에 저장될까? 바로 fiber.memoizedState에 linked list 형태로 저장된다.
// useState(0) → useRef(false) → useMemo(...) → useEffect(...)
fiber.memoizedState = {
memoizedState: 0, // useState의 값
queue: { pending: null },
next: { // useRef
memoizedState: { current: false },
next: { // useMemo
memoizedState: [computedValue, [deps]],
next: { // useEffect
memoizedState: { tag: 9, create: fn, destroy: fn, deps: [] },
next: null
}
}
}
};Hook이 호출 순서대로 linked list에 삽입되기 때문에, 조건문 안에서 Hook을 호출하면 리스트 순서가 깨진다. 이것이 Rules of Hooks(조건문/반복문 안에서 Hook 호출 금지)의 근본적인 이유다. "규칙이니까 지켜라"가 아니라, 데이터 구조 자체가 순서에 의존하기 때문이다.
alternate — Double Buffering의 열쇠
각 컴포넌트에는 최대 2개의 Fiber Node가 존재한다. current.alternate === workInProgress이고 workInProgress.alternate === current. 이게 뭔 소리인지는 7장에서 자세히 다룬다.
4. Reconciliation 과정 — Render Phase와 Commit Phase
React는 업데이트를 두 단계로 나눠서 처리한다. 이 분리가 Fiber 아키텍처의 핵심이다.
Render Phase | Commit Phase | |
|---|---|---|
하는 일 | 새로운 Fiber 트리 구성, diff 계산 | 실제 DOM 변경 |
중단 가능? | ✅ 가능 (핵심!) | ❌ 불가능 |
DOM 접근? | ❌ 안 함 | ✅ 직접 변경 |
사용자에게 보임? | ❌ 보이지 않음 | ✅ 즉시 반영 |
Render Phase — beginWork()와 completeWork()
Render Phase에서는 두 함수가 핑퐁하면서 트리를 순회한다.
beginWork(current, workInProgress, renderLanes) — 트리를 아래로 내려간다.
workInProgress.tag로 컴포넌트 종류 판별 (FunctionComponent? ClassComponent? HostComponent?)해당 컴포넌트의 render 함수를 호출하여 새 children을 얻음
reconcileChildren()으로 old/new children을 diff자식 fiber를 생성·갱신·삭제
첫 번째 자식 fiber를 반환 (계속 아래로) 또는
null반환 (leaf 도달)
여기서 핵심 최적화가 bailout(탈출)이다:
// beginWork 내부의 bailout 판단
if (oldProps === newProps && workInProgress.type === current.type) {
if (!checkScheduledUpdateOrContext(current, renderLanes)) {
// 이 서브트리 전체를 건너뛴다!
return attemptEarlyBailoutIfNoScheduledUpdate(...);
}
}pendingProps와 memoizedProps가 참조 동일(===)하고, Lane에 업데이트가 없으면 서브트리 전체를 스킵한다. 이 bailout 메커니즘이 React 성능의 핵심이다. React.memo, useMemo, useCallback은 전부 이 bailout 조건을 충족시키기 위한 도구라고 보면 된다.
completeWork(current, workInProgress, renderLanes) — beginWork가 null을 반환하면(leaf 노드 도달) 호출되며, 트리를 위로 올라간다.
마운트 시:
createInstance()로 실제 DOM 노드 생성 →appendAllChildren()으로 자식 붙이기 →finalizeInitialChildren()으로 속성 설정. 이 DOM 트리는 메모리에만 존재하며, 아직 브라우저 화면에는 안 보인다.업데이트 시: old/new props의 diff를 계산하고, 변경 payload를
updateQueue에 저장하며Updateflag를 마킹.
completeWork는 자식의 subtreeFlags를 부모로 버블업해서, commit phase에서 어떤 서브트리를 방문해야 하는지 빠르게 판별할 수 있게 한다.
Commit Phase — 세 단계의 DOM 적용
Render Phase가 끝나면 새 Fiber 트리가 완성된다. 이제 실제 DOM에 적용하는 Commit Phase가 동기적으로(중단 불가) 실행된다.
1. commitBeforeMutationEffects → getSnapshotBeforeUpdate() 호출
2. commitMutationEffects → 실제 DOM 삽입/갱신/삭제
──── root.current = finishedWork ──── (여기서 트리 교체!)
3. commitLayoutEffects → componentDidMount/Update, useLayoutEffect 실행root.current = finishedWork — 이 한 줄이 workInProgress 트리를 current 트리로 교체하는 포인터 스왑이다. mutation과 layout 사이에 위치하는 이유가 있다: componentWillUnmount(mutation 중)는 이전 DOM을, componentDidMount(layout 중)는 새 DOM을 참조해야 하기 때문이다.
useEffect는? 이건 Passive Effect라고 부르며, commit phase가 끝난 후 비동기로 실행된다. 정확히는 scheduleCallback(NormalSchedulerPriority, flushPassiveEffects)로 스케줄되어 브라우저가 페인트한 후 실행된다.
5. workLoop
여기서 Fiber의 마법이 일어난다. Render Phase의 핵심 루프를 보자:
// 동기 모드: 중단 없이 전체 실행
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// 동시성 모드(Concurrent): 5ms마다 양보 체크
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}차이가 보이는가? workLoopConcurrent에는 shouldYield() 체크가 추가되어 있다.
shouldYield()는 getCurrentTime() - startTime < 5ms를 확인한다. 5ms를 초과하면 true를 반환하여 루프를 중단하고 브라우저에 제어권을 넘긴다. 브라우저가 사용자 입력 처리, 애니메이션, 페인트를 할 수 있는 숨쉴 틈을 주는 것이다.
중단된 작업은 어떻게 되냐고? performConcurrentWorkOnRoot.bind(null, root)를 continuation callback으로 반환하여, 다음 이벤트 루프에서 중단된 바로 그 fiber부터 재개한다.
requestIdleCallback 대신 MessageChannel을 쓰는 이유
"브라우저가 한가할 때 실행"하는 requestIdleCallback이 있는데 안쓰는 이유는 React도 초기에는 이걸 사용했지만 세 가지 한계 때문에 전환했다:
브라우저가 바쁘면 무한 지연될 수 있다
최대 ~20fps로 호출 빈도가 제한된다 (60fps에 턱없이 부족)
Safari가 제대로 지원하지 않는다
W3C 스펙상 "potentially unbounded amount of time" 동안 지연 가능
그래서 최종 구현은 MessageChannel 기반 매크로태스크다:
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
// 다음 이벤트 루프에서 작업 실행 예약
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};port.postMessage(null) → onmessage 핸들러에서 작업 수행 → 더 있으면 다시 postMessage. 이 방식은 다음 이벤트 루프에서 확실하게 CPU 시간을 할당받으며, setTimeout의 4ms 최소 지연도 회피한다.
6. Diffing Algorithm — O(n)의 비밀
일반적인 트리 비교 알고리즘은 O(n³)이다. 노드가 1000개면 10억 번 비교. 당연히 쓸 수 없다. React는 두 가지 발상의 전환(heuristic)으로 O(n)을 달성한다:
다른 타입의 요소는 다른 트리를 생성한다 —
<div>→<span>이면 비교할 것도 없이 서브트리 전체를 파괴하고 새로 구축keyprop으로 안정적인 자식을 식별한다 — 비싼 LCS 알고리즘 없이 O(1) 매칭
같은 타입 비교
DOM 요소 타입이 같으면(<div> → <div>) 동일 노드를 유지하고 변경된 속성만 갱신한다. 컴포넌트 요소가 같으면 인스턴스를 유지하고 새 props로 render()를 재호출한다.
타입이 다르면? 이전 서브트리를 완전히 언마운트(componentWillUnmount)하고 새 서브트리를 처음부터 마운트한다. 그래서 <div>로 감싸던 걸 <section>으로 바꾸면 하위 컴포넌트 전체가 리마운트되는 거다.
리스트 재조정 — reconcileChildrenArray의 4단계 알고리즘
Fiber의 singly-linked list 구조 때문에 양 끝에서 동시 탐색이 불가능하다. React 소스코드 주석에도 이렇게 적혀있다:
"This algorithm can't optimize by searching from both ends since we don't have backpointers on fibers."
그래서 React는 4단계 알고리즘을 사용한다:
Step 1 (Forward scan): old fiber와 new element를 앞에서부터 순차 비교
→ key가 일치하면 재사용, key 불일치 시 즉시 중단
Step 2 (Simple cases): new가 모두 소진 → 남은 old 삭제
old가 모두 소진 → 남은 new 생성
Step 3 (Map 구축): 남은 old fiber들을 Map<key|index, Fiber>로 변환 → O(1) 조회
Step 4 (Map 매칭): 남은 new element를 Map에서 찾아 매칭
→ placeChild()의 lastPlacedIndex 알고리즘으로 이동 필요 여부 결정placeChild()는 재사용된 fiber의 old index가 lastPlacedIndex보다 작으면 Placement flag를 마킹(이동 필요)하고, 크거나 같으면 lastPlacedIndex를 갱신한다. 이 forward-only 방식 때문에 리스트를 역순 정렬하면 최적이 아닌 이동이 발생할 수 있다.
7. Double Buffering — current와 workInProgress 트리
그래픽스에서 화면 깜박임을 방지하기 위해 사용하는 더블 버퍼링 기법을 React도 사용한다.
React는 항상 최대 두 개의 Fiber 트리를 유지한다:
current tree: 현재 화면에 렌더링된 상태.
FiberRootNode.current가 가리킨다.workInProgress (WIP) tree: 업데이트 중인 off-screen 트리. 아직 화면에 안 보인다.
FiberRootNode
│
┌────┴────┐
│ │
current (생성 중...)
│ │
[App] ←→ [App'] ← alternate로 서로 연결
│ │
[div] ←→ [div']
│ │
[H1] ←→ [H1']createWorkInProgress()는 current.alternate가 없으면 새 fiber를 생성하고, 이미 있으면 재사용(clone)한다.
Commit 완료 시 root.current = finishedWork로 포인터만 교체하면 WIP가 새 current가 되고, 이전 current의 fiber들은 다음 WIP 트리의 재활용 풀이 된다. 이 pooling 기법 덕분에 매 렌더마다 새 객체를 할당하는 비용과 GC 압력이 크게 줄어든다.
왜 이게 필요한가?
일관성: 사용자가 "반쯤 업데이트된 UI"를 절대 보지 않는다
중단 가능: WIP 트리를 언제든 폐기할 수 있다 (더 급한 업데이트가 오면)
원자적 업데이트: 포인터 스왑이 사실상 atomic operation
8. Lane 모델과 우선순위 스케줄링
expirationTime → Lane: 왜 바꿨는가
React 16의 expirationTime 모델은 시간 기반 단일 숫자로 우선순위를 표현했는데, 우선순위(prioritization)와 배칭(batching)이 결합되어 분리할 수 없었다. 예를 들어, Suspense에서 고우선순위 지연 작업이 저우선순위 일반 작업을 차단하는 문제가 있었다.
Andrew Clark이 2020년 5월 PR #18796에서 도입한 Lane 모델은 31비트 비트마스크로 이를 해결했다. 비트 연산으로 O(1) 병합·필터링이 가능하고, 특정 Lane만 선택적으로 추가·제거할 수 있다.
Lane 종류와 우선순위
SyncLane = 0b0000000000000000000000000000010 (가장 높음)
InputContinuousLane = 0b0000000000000000000000000001000
DefaultLane = 0b0000000000000000000000000100000
TransitionLanes (16개) = 0b0000000001111111111111110000000
RetryLanes (4개) = 0b0000011110000000000000000000000
IdleLane = 0b0010000000000000000000000000000 (가장 낮음)
OffscreenLane = 0b0100000000000000000000000000000이벤트가 어떤 Lane에 매핑되는지가 실무적으로 중요하다:
이벤트 | Lane | 설명 |
|---|---|---|
click, keydown | SyncLane | 즉시 처리 |
mousemove, scroll | InputContinuousLane | 연속 입력 |
setTimeout 내 setState | DefaultLane | 기본 우선순위 |
startTransition 내 setState | TransitionLane | 낮은 우선순위 |
Scheduler — React의 미니 OS
React의 Scheduler 패키지(packages/scheduler/)는 min-heap 기반 두 개의 우선순위 큐를 운영한다:
taskQueue: 실행 준비된 작업 (deadline으로 정렬)
timerQueue: 지연된 작업 (시작 시간으로 정렬)
Priority | Timeout | 용도 |
|---|---|---|
Immediate (1) | -1ms | 즉시 만료 |
UserBlocking (2) | 250ms | 사용자 인터랙션 |
Normal (3) | 5,000ms | 기본 업데이트 |
Low (4) | 10,000ms | 저우선순위 |
Idle (5) | ~12.4일 | 사실상 무제한 |
높은 우선순위 작업이 도착하면? shouldYield()에서 현재 작업이 중단되고 → 높은 우선순위 렌더링이 먼저 실행 → 완료 후 낮은 우선순위 작업이 재시작된다. 오래 대기한 Lane은 기아 방지(starvation prevention) 로직에 의해 자동으로 만료되어 SyncLane에 병합된다.
useTransition의 동작 원리
const [isPending, startTransition] = useTransition();
startTransition(() => {
setSearchResults(filterHugeList(input)); // 이건 TransitionLane (낮은 우선순위)
});
// isPending = true 표시는 SyncLane (즉시 반영)startTransition() 호출 시 내부적으로 두 개의 setState가 발생한다. setPending(true)는 SyncLane(즉시 반영), 콜백 내의 setState는 TransitionLane(나중에 처리). 이게 "isPending이 즉시 true가 되고, 무거운 업데이트는 나중에 처리되는" 마법의 비밀이다.
9. 실무에서 이걸 왜 알아야 하는가
key prop — 그냥 warning 없애려고 넣는 게 아니다
reconcileChildrenArray에서 key는 old fiber와 new element를 매칭하는 유일한 수단이다. index를 key로 쓰면:
// index를 key로 사용
{items.map((item, index) => <Input key={index} defaultValue={item.name} />)}항목을 추가/삭제/재정렬하면 index가 변경되어 기존 Fiber가 잘못된 데이터와 매칭된다. 이전 memoizedState(입력값)가 새 데이터에 적용되어 입력 필드 값이 뒤섞이는 버그가 발생한다.
// 안정적인 고유 식별자 사용
{items.map((item) => <Input key={item.id} defaultValue={item.name} />)}bailout을 이해하면 최적화가 보인다
핵심은 oldProps !== newProps 비교가 참조 동일성(===)이라는 것이다. shallow equal이 아니다. 부모가 리렌더되면 자식의 pendingProps는 항상 새 객체가 되어 bailout이 실패한다.
// Parent가 리렌더되면 Child도 항상 리렌더
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<Child /> {/* Parent 리렌더 → Child의 props 참조가 바뀜 → bailout 실패 */}
</div>
);
}
// children prop 패턴 — Child의 props 참조가 유지됨
function Parent({ children }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{children} {/* 부모(Parent의 부모)가 안 바뀌면 children 참조 유지 → bailout! */}
</div>
);
}
// 사용: <Parent><Child /></Parent>React.memo — shallow equal로 bailout 업그레이드
React.memo()로 감싸면 === 대신 shallow equal(얕은 비교)로 전환된다. props의 1-depth 프로퍼티들이 같으면 bailout한다.
const MemoizedChild = React.memo(Child);
// 이제 Parent가 리렌더되어도, Child의 props 값이 같으면 건너뜀단, Context가 변경되면 React.memo를 뚫는다. fiber.dependencies의 context 값 비교가 memo의 props 비교보다 먼저 실행되기 때문이다. 고빈도로 변하는 상태에 Context를 사용하는 건 성능적으로 불리하다.
참고로 React 19의 React Compiler는 이런 memo 최적화를 자동으로 적용하는 방향으로 가고 있다.
useMemo / useCallback — Fiber에 캐싱되는 원리
// useMemo: [계산된_값, [의존성_배열]]을 memoizedState에 저장
// useCallback: [콜백_함수, [의존성_배열]]을 memoizedState에 저장업데이트 시 deps 배열을 Object.is()로 하나씩 비교 → 변경되면 재계산, 아니면 캐시된 값 반환. 이것도 결국 Fiber의 memoizedState linked list에 저장되는 것이다.
결론
Fiber 내부를 이해하면 "왜 이 패턴이 성능에 좋은지", "왜 이 규칙을 지켜야 하는지"가 명확해진다. "그냥 공식 문서에서 시키니까"가 아니라, 데이터 구조와 알고리즘 레벨에서 납득하게 되는 것이다.