[번역] React는 어떻게 동작하는가 (Part 2) —- React가 자체 실행 엔진을 구축해야 했던 이유
Soshy·

원문: How React Works (Part 2)? Why React Had to Build Its Own Execution Engine
지난 내용 돌아보기
1편에서는 React를 완전히 재작성해야 했던 이유를 한 문장으로 정리했습니다.
React는 진행 중인 작업을 중단하고, 나중에 재개할 수 있어야 합니다.
Time Slicing을 구현하려면 브라우저의 16ms 프레임 예산 안에서 제어권을 양보할 수 있어야 했고, Suspense를 구현하려면 비동기 데이터를 기다리는 동안 작업을 일시 중지할 수 있어야 했습니다.
요구사항 자체는 단순합니다. 하지만 문제가 하나 있습니다. JavaScript가 이를 허용하지 않는다는 것입니다.
이 글은 그 이유, React가 어떻게 이 문제를 해결했는지, 그리고 완전히 커스텀 실행 모델을 구축하는 그 결정이 왜 현대의 React를 가능하게 했는지를 다룹니다.
문제: JavaScript는 끝까지 실행됩니다
JavaScript는 함수 실행을 시작하면 반드시 끝까지 완료합니다. "여기서 잠깐 멈추고, 다른 일을 처리한 다음, 다시 돌아와라"라고 지시할 수 있는 외부 API가 없습니다. 실행 중인 프로그램을 중간에 멈추고, 브라우저에 제어권을 넘긴 뒤, 정확히 같은 지점에서 재개하는 방법이 없습니다.
이는 JavaScript가 실행을 콜 스택(call stack)으로 관리하기 때문입니다. 콜 스택은 프로그램이 현재 무엇을 실행 중인지 추적하는 자료구조입니다.
함수가 호출될 때마다 JavaScript는 스택 프레임(stack frame)을 생성합니다. 스택 프레임은 해당 함수의 매개변수, 지역 변수, 그리고 함수가 완료된 후 돌아갈 위치를 기록한 작은 데이터 덩어리입니다. 함수가 다른 함수를 호출할수록 프레임이 쌓이고, 함수가 반환될 때마다 꺼내집니다.

여기서 콜 스택의 결정적인 특성이 드러납니다. 콜 스택은 JavaScript 엔진이 완전히 관리합니다. 개발자는 스택을 읽거나, 중간에 멈추거나, 임의의 위치로 이동할 수 없습니다. 완전한 블랙박스입니다. 함수 호출 체인이 한 번 시작되면, 완료될 때까지 쉬지 않고 실행됩니다. 그 동안 브라우저는 화면을 그릴 수 없고, 사용자는 어떤 상호작용도 할 수 없습니다. 스택이 완전히 비워질 때까지는 아무것도 일어나지 않습니다.
구버전 React에서는 이 방식으로도 충분했습니다. React는 단 하나의 긴 함수 호출 체인으로 전체 컴포넌트 트리를 렌더링했습니다. 브라우저는 그저 기다렸고, 앱의 규모가 충분히 작았기 때문에 아무도 문제를 느끼지 못했습니다.
하지만 Time Slicing은 다릅니다. React가 렌더링 도중 멈추고, 브라우저가 화면을 그리도록 양보한 뒤, 다시 재개해야 합니다. 이 모델에서 콜 스택은 절대로 넘을 수 없는 벽이 됩니다. React 소스 코드에서 이 문제를 해결한 순간을 딱 두 줄로 확인할 수 있습니다.
// 구버전 — 모든 컴포넌트가 완료될 때까지 빠져나올 방법이 없음
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// 신버전 — 컴포넌트 하나가 끝날 때마다 양보 여부를 확인
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
!shouldYield(), 이 조건 하나가 추가된 것이 전부입니다. 컴포넌트 하나를 처리할 때마다 React는 "브라우저가 기다리고 있나? 더 높은 우선순위의 작업이 생겼나?"를 확인합니다. 그렇다면 루프를 중단합니다. 그런데 루프를 중간에 끊어도 안전하려면, 즉 지금까지 한 작업을 잃지 않으려면, React는 진행 상황을 저장할 공간이 필요합니다. 그 저장소가 바로 Fiber입니다.
Sam Galson은 자신의 발표 "Magic in the web of it"에서 이를 정확하게 짚었습니다.
"스택 프레임과 달리, Fiber는 현재 활성화되어 있지 않더라도 state 같은 로컬 데이터를 계속 유지할 수 있습니다. 스택 프레임은 스택에서 꺼내지는 순간, 그 안에 저장된 모든 로컬 정보가 사라집니다. 하지만 Fiber는 그렇지 않습니다."
핵심 아이디어: React가 자체 스택을 갖는다면?
모든 것을 바꾼 아이디어입니다.
JavaScript의 네이티브 콜 스택이 제약이 되는 이유는 단 하나, React가 그것을 제어하지 못하기 때문입니다. 그렇다면 React가 직접 제어할 수 있는 자체 버전을 만들면 어떨까요?
JavaScript의 스택을 대체하는 것이 아닙니다. JavaScript는 여전히 정상적으로 실행됩니다. 다만 React가 렌더링 작업을 자신이 직접 관리하는 자료구조로 추적하는 것입니다. 언제든지 일시 중지하고, 내부를 들여다보고, 우선순위를 조정하고, 재개할 수 있는 구조 말입니다.
이것이 바로 React Fiber입니다. JavaScript의 일반 실행 위에, 순수한 JavaScript 객체로 구축된 React만의 커스텀 실행 모델입니다.
Sam Galson은 그 배경이 되는 CS 개념을 이렇게 설명했습니다.
"Fiber는 각각의 개별 작업 단위가 서로 협력하며 동작하는 방식으로 프로그램 실행을 모델링하는 범용적인 방법입니다. 어떤 Fiber도 프로그램 전체를 독점하려 하지 않습니다."
React가 이 아이디어를 처음 발명한 것은 아닙니다. Fiber는 운영체제에 이미 존재하던 개념입니다. Microsoft Windows가 이를 사용하고, OCaml은 Fiber 위에 동시성 모델 전체를 구축했습니다. 오래된 개념을 React가 브라우저로 가져온 것입니다.
Fiber란 실제로 무엇인가
JavaScript의 콜 스택에서 스택 프레임이 담고 있는 정보는 다음과 같습니다.
- 현재 실행 중인 함수
- 함수가 호출될 때 받은 매개변수
- 함수가 보유한 지역 변수
- 함수가 완료된 후 돌아갈 위치
Fiber는 정확히 동일한 정보를 담습니다. 단, 대상이 React 컴포넌트입니다.
| 스택 프레임 | Fiber에서의 대응 |
|---|---|
| 함수 | 컴포넌트 (App, Button 등) |
| 매개변수 | 전달받은 props |
| 지역 변수 | state와 hooks |
| 반환 주소 | 부모 컴포넌트 |
이 모든 것을 가능하게 하는 결정적인 차이는 스택 프레임은 JavaScript 엔진 내부에 존재해서 건드릴 수 없지만, Fiber는 그저 평범한 JavaScript 객체라는 점입니다. React가 직접 생성하고, 읽고, 멈추고, 재개합니다. 완전히 React의 통제 하에 있습니다.
실제 런타임에서 <Button> 컴포넌트의 Fiber는 이렇게 생겼습니다.
{
type: Button, // 컴포넌트 함수
pendingProps: { label: "click me" }, // 전달받은 props
memoizedState: { count: 0 }, // state가 여기에 저장됨
return: ParentFiber, // 부모 — 반환 주소와 동일한 역할
child: null, // 첫 번째 자식 컴포넌트
sibling: null, // 다음 형제 컴포넌트
alternate: null, // 이 Fiber의 다른 버전
}
그게 전부입니다. 평범한 JavaScript 객체입니다. 특별한 마법은 없습니다. 그리고 단순히 객체라는 사실이 일시 중지를 안전하게 만듭니다. React는 언제든 멈출 수 있고, Fiber는 멈춘 그 상태 그대로 메모리에 남아 있습니다.
객체이기 때문에 담을 수 있는 정보도 스택 프레임보다 풍부합니다. 각 Fiber는 부모(반환 주소 역할)뿐만 아니라 첫 번째 자식과 다음 형제도 참조합니다. 덕분에 React는 현재 위치를 잃지 않고 컴포넌트 트리 전체를 앞뒤, 옆으로 자유롭게 탐색할 수 있습니다.
브라우저가 화면을 그려야 할 때, React는 workInProgress(현재 작업 중인 Fiber를 가리키는 포인터)의 업데이트를 멈추고 제어권을 양보합니다. 나중에 작업 루프로 돌아왔을 때 Fiber는 떠난 그 상태 그대로 메모리에 남아 있습니다. 아무것도 잃지 않습니다.
항상 두 개의 트리
이 과정을 안전하게 만드는 또 하나의 장치가 있습니다.
React는 항상 메모리에 두 개의 컴포넌트 트리를 유지합니다.
- 현재 트리(current tree): 지금 화면에 실제로 표시되고 있는 트리
- 작업 중인 트리(work-in-progress tree): React가 다음 렌더링을 위해 구축하고 있는 트리
모든 렌더링 작업은 작업 중인 트리에서만 이루어집니다. 렌더링 도중에 현재 트리는 절대 건드리지 않습니다. 따라서 렌더링이 중간에 중단되더라도 화면이 깜빡이거나 깨지지 않습니다. 현재 트리는 그대로 살아있기 때문입니다.
새 트리가 완전히 완성되면, React는 단 하나의 원자적 연산(atomic operation)으로 둘을 교체합니다. 작업 중이던 트리가 현재 트리가 되고, 현재 트리는 다음 렌더링에서 재사용될 작업 중인 트리로 전환됩니다.

이처럼 두 버전을 유지하며, 교체 전에 새 버전을 안전하게 구축하는 패턴을 더블 버퍼링(double buffering)이라고 합니다. 화면 찢김(screen tearing)을 방지하기 위해 비디오 게임에서 오래전부터 사용해 온 기법과 동일합니다. React도 같은 이유로 이를 채택했습니다. 사용자에게 절대로 절반만 완성된 UI를 보여서는 안 되기 때문입니다.
Scheduler가 이를 활용하는 방식
이제 Scheduler, 즉 작업이 언제 어떤 순서로 실행될지를 결정하는 모듈은 이전에는 완전히 불가능했던 일들을 처리할 수 있게 되었습니다.
일시 중지와 재개. shouldYield()가 true를 반환하면(시간 예산이 소진되면), 작업 루프는 그 자리에서 멈춥니다. workInProgress 포인터는 정확히 그 위치를 가리킨 채로 남습니다. 다음번에 Scheduler가 제어권을 얻으면 루프는 그 Fiber부터 이어서 실행됩니다. 지금까지 한 작업도, state도 잃지 않습니다.
우선순위 처리. Fiber는 React가 직접 제어하는 객체이므로, React는 각 Fiber의 우선순위(lanes)를 확인하고 트리의 다른 부분을 먼저 처리하도록 결정할 수 있습니다. 무거운 차트를 렌더링하는 도중 키 입력이 발생했다면, React는 차트 렌더링을 멈추고, 더 높은 우선순위인 키 입력을 먼저 처리한 다음, 정확히 멈췄던 지점에서 차트 렌더링을 재개합니다.
작업 취소. 새로운 업데이트가 들어와 현재 작업 중인 트리가 쓸모없어졌다면, React는 이를 통째로 버리고 최신 데이터로 처음부터 다시 시작할 수 있습니다. 화면에 표시 중인 현재 트리는 영향을 받지 않습니다.
렌더링이 하나의 연속적인 JavaScript 함수 호출 체인이었던 시절에는 이 중 어느 것도 불가능했습니다.

전체 그림을 한 단락으로
React는 JavaScript의 콜 스택이 빠르고 단순하지만 완전히 불투명(opaque)하여, 현대 앱에서 필요한 중단 가능하고 우선순위가 있는 렌더링과 양립할 수 없다는 사실을 깨달았습니다. 그래서 대안을 직접 만들었습니다. 각 객체가 React가 제어하는 스택 프레임인, 순수한 JavaScript 객체(Fiber)로 이루어진 트리입니다. 화면에 표시 중인 트리를 건드리지 않고 다음 버전을 안전하게 구축할 수 있도록 이 트리를 두 벌 유지합니다. 그리고 다음에 무엇을 작업할지, 양보하기 전까지 얼마나 작업할지, 멈춘 곳에서 어떻게 재개할지를 결정하는 Scheduler가 있습니다.
이 시스템, Fiber와 Scheduler가 모든 useState, 모든 useTransition, 모든 <Suspense> 경계, 그리고 프레임을 떨어뜨리지 않는 모든 애니메이션의 밑바탕에서 동작하는 엔진입니다.
참고 영상
-
Sam Galson — Magic in the web of it: coroutines, continuations, fibers | React Advanced London
Fiber가 동작하는 CS적 배경을 다룹니다. CS 용어로 Fiber가 무엇인지, 스레드 및 코루틴과 어떻게 다른지, React가 이 모델을 선택한 이유를 설명합니다. 이 글의 협력적 실행(cooperative execution) 프레이밍의 출처이기도 합니다. -
Dan Abramov — Beyond React 16 | JSConf Iceland 2018
이 글이 해결하려는 문제의 최초 데모입니다. 2:57부터 보면 콜 스택이 왜 문제였는지 직접 느낄 수 있습니다. -
Matheus Albuquerque — Inside Fiber | React Summit 2022
FiberNode 자료구조를 더 깊이 파고들고 싶다면 이 영상을 추천합니다. 2:11부터 시작하세요.
Part 3에서는 Reconciler를 다룹니다. React가 UI 업데이트에 필요한 최소한의 변경 집합을 찾아내는 방법과, key props가 생각보다 훨씬 중요한 이유를 살펴봅니다. 🔧