[번역] React는 어떻게 작동하는가 (Part 1) - React Fiber를 만든 이유: Time Slicing & Suspense
Soshy·

원문: How React Works (Part 1) — Motivation Behind React Fiber: Time Slicing & Suspense
들어가며
React를 사용하다 보면 누구나 한 번쯤 이런 답답함을 느껴보셨을 것입니다. 버벅이는 입력창, 0.5초간 멈추는 페이지, 어딘가 어색한 애니메이션. 그런데 React 팀이 왜 내부 엔진 전체를 완전히 새로 작성해야 했는지, 깊이 생각해 보신 적이 있으신가요?
2017년, React 팀은 React Fiber를 발표했습니다. React 내부를 처음부터 다시 작성한 것이었습니다. API가 바뀐 것은 아닙니다. JSX와 컴포넌트는 그대로였습니다. 하지만 그 아래에서 작업을 어떻게, 언제 처리할지 결정하는 엔진은 완전히 폐기되고 새로 만들어졌습니다.
Fiber가 왜 단순히 유용한 수준을 넘어 반드시 필요했는지를 이해하려면, React 팀이 출발했던 지점에서 시작해야 합니다. 이 글 전체는 Dan Abramov의 JSConf Iceland 2018 발표를 기반으로 하고 있습니다. 글과 함께 영상을 보고 싶으시다면 여기서 확인하실 수 있습니다.
🎬 Dan Abramov — Beyond React 16 | JSConf Iceland 2018
이제 본격적으로 시작하겠습니다.
⚡ 핵심 질문
"컴퓨팅 파워와 네트워크 속도가 천차만별인 환경에서, 모든 사용자에게 최선의 경험을 제공하려면 어떻게 해야 할까?"
이것은 단순한 도입부가 아닙니다. React 팀이 실제로 풀어야 했던 과제 그 자체입니다. 여러분의 앱을 사용하는 사람들을 떠올려 보십시오.
- 광섬유 인터넷에 연결된 MacBook Pro를 쓰는 개발자
- 3G 연결에 3년 된 안드로이드 폰을 쓰는 동남아시아 농촌 지역 사용자
- 사람 많은 카페 WiFi에서 중간 사양 노트북을 쓰는 사람
React는 이 모든 환경에서 실행됩니다. 그리고 2017년 당시, React는 어떤 환경에도 제대로 적응하지 못하고 있었습니다. 문제는 크게 두 가지 범주로 나뉘었습니다.

🖥️ CPU 문제 - 렌더링 비용
무거운 연산으로 인해 발생하는 문제입니다. React가 UI를 업데이트할 때는 실제 작업이 발생합니다. DOM 노드 생성, render 함수 호출, 이전 트리와 새 트리의 재조정(reconciliation), DOM에 변경 사항 적용 등이 그것입니다. 고성능 기기에서는 5ms가 걸릴 수 있지만, 저전력 모바일 기기에서는 정확히 같은 작업이 50ms 이상 걸릴 수 있습니다.
CPU 문제의 대표적인 증상은 다음과 같습니다.
- 타이핑 중에 복잡한 차트가 리렌더링되는 경우
- 초기 로드 시 대규모 컴포넌트 트리를 마운트하는 경우
- 항목 하나가 변경될 때 긴 목록 전체가 리렌더링되는 경우
🌐 IO 문제 - 기다림의 비용
데이터가 도착하기를 기다리는 시간에서 비롯되는 문제입니다. API 응답, CDN의 JavaScript 번들, 이미지 등이 해당됩니다. 코드도 괜찮고 기기도 괜찮습니다. 그저 네트워크를 기다리고 있을 뿐입니다.
IO 문제의 대표적인 증상은 다음과 같습니다.
- 데이터 페칭 워터폴 — A를 받고, 그 다음 B, 그 다음 C를 받으며 각 단계가 다음을 블로킹하는 경우
- 코드 스플리팅 — 페이지가 렌더링되기 전에 번들을 먼저 로드해야 하는 경우
- 데이터가 예상보다 빠르거나 느리게 도착해서 잘못된 로딩 상태가 표시되는 경우
두 문제 모두 새로운 해결책이 필요했습니다. 그리고 곧 살펴보겠지만, 두 해결책 모두 React 엔진에 정확히 동일한 능력을 요구했습니다. 먼저 이 문제를 직접 눈으로 확인해 보겠습니다.
🎬 모든 것을 바꾼 데모
이 문제를 가장 잘 느끼는 방법은 직접 보는 것입니다. Dan의 발표 2:57로 이동해 보십시오. 데모가 시작되는 순간입니다.
앱의 구성은 간단합니다. 상단에 입력창이 있고 아래에 차트가 있습니다. 규칙은 하나입니다. 타이핑하는 글자 수가 많을수록 차트가 더 세밀해집니다. 입력창 업데이트와 차트 업데이트, 이 두 가지가 동시에 일어납니다.

입력창을 보십시오. 키를 누르고 글자가 나타나기까지 명확한 지연이 있습니다. 브라우저가 차트를 계산하느라 너무 바빠서 키 입력을 제때 처리하지 못하는 것입니다. 렌더링이 진행되는 동안 스레드가 완전히 블로킹되는데, 이 시간이 50ms, 80ms, 심지어 100ms에 달할 수 있고, 사용자는 그 매 밀리초를 고스란히 체감하게 됩니다.
Dan은 근본적인 문제를 이렇게 정확하게 표현했습니다.
"근본적인 문제는 업데이트가 크고 동기적이라는 것입니다. React가 렌더링을 시작하면, 멈출 수가 없습니다."
이것은 React 소스 코드에서 직접 확인할 수 있습니다. 동기 모드에서 작업 루프는 다음과 같습니다.
// React 소스 — 동기 모드
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}

모든 작업을 완료하는 것 외에는 탈출 조건이 없는 단순한 while 루프입니다. React가 시작하면 완료까지 실행되며, 외부에서 멈출 방법이 없습니다.
🩹 시도된 해결책: 디바운싱
가장 자연스러운 해결책은 디바운싱입니다. 키 입력마다 차트를 업데이트하는 대신, 사용자가 타이핑을 멈출 때까지 기다렸다가 한 번에 업데이트하는 것입니다.

나아졌을까요? 조금은 그렇습니다. 하지만 세 가지 실질적인 문제가 남아 있습니다.
첫째, 기기에 적응하지 못합니다. 디바운싱은 고정된 지연 시간을 사용합니다. 예를 들어 300ms입니다. 사용자의 기기가 즉시 렌더링할 수 있을 만큼 충분히 빠르더라도 여전히 300ms를 기다려야 합니다. 반대로 기기가 느리다면, 300ms조차 렌더링을 버벅임 없이 완료하기에 충분하지 않을 수 있습니다.
둘째, 실행될 때 여전히 잠깁니다. DevTools에서 CPU 스로틀링을 활성화해 보십시오(저사양 폰 시뮬레이션을 위해 4~6배 속도 저하). 디바운싱을 적용해도, 업데이트가 실행되는 순간 브라우저가 완전히 멈춥니다. 버벅임의 시점을 옮겼을 뿐이지, 제거한 것이 아닙니다.
셋째, 마운팅에는 도움이 되지 않습니다. 디바운싱은 업데이트를 지연시킬 수 있지만, 대규모 컴포넌트 트리를 처음 마운트할 때는 디바운싱할 것 자체가 없습니다. React는 동기적으로 한 번에 모두 마운트합니다. 그 시간 동안 페이지의 어떤 것도 인터랙션이 불가능합니다. 클릭 이벤트가 등록되지 않고, 애니메이션이 멈춥니다.
문제는 업데이트가 언제 실행되느냐가 아닙니다. 실행될 때 동기적이고 중단 불가능하다는 것이 문제입니다.
💡 React가 멈출 수 있다면?
React가 차트 업데이트 렌더링을 시작하다가, 키 입력이 들어온 것을 감지하고, 렌더링을 잠시 멈춘 다음, 키 입력을 즉시 처리하고, 멈춘 곳에서 정확히 재개할 수 있다면 어떨까요?
총 작업량은 동일합니다. 하지만 경험은 완전히 달라집니다. 무거운 작업이 백그라운드에서 조용히 완료되는 동안, 사용자는 자신의 입력에 즉각적인 피드백을 받을 수 있기 때문입니다.
이것이 왜 완전히 새로운 엔진을 필요로 하는지 이해하려면, 프레임에 대해 이야기해야 합니다.
🎞️ 16ms 프레임 예산
브라우저는 초당 60프레임 렌더링을 목표로 합니다. 계산해 보면 다음과 같습니다.
1000ms ÷ 60프레임 ≈ 16.67ms / 프레임
매 약 16ms마다, 브라우저는 JavaScript를 실행하고, 스타일을 재계산하고, 레이아웃을 잡고, 픽셀을 화면에 그려야 합니다. JavaScript가 연속으로 16ms 이상 실행되면, 브라우저는 그 프레임을 놓칩니다. 버벅임, 멈춤, 지연이 나타나는 이유입니다.

구버전 React는 이 예산을 전혀 인식하지 못했습니다. 단일 렌더링이 100ms가 걸려도 React는 끝까지 진행했고, 단 한 번도 제어권을 양보하지 않았으며, 브라우저가 숨 쉴 틈을 전혀 주지 않았습니다.
⏱️ Time Slicing
Time Slicing은 CPU 문제에 대한 React의 해결책입니다.
핵심 아이디어는 이것입니다. 모든 렌더링을 하나의 연속된 덩어리로 처리하는 대신, React가 그 작업을 작은 조각으로 나누고, 각 조각 사이에 더 긴급한 작업이 들어왔는지 확인하는 것입니다. 동시 모드(concurrent mode)의 작업 루프가 기존과 다른 이유가 바로 여기에 있습니다.
// React 소스 — 동시 모드
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
workLoopSync와의 차이가 보이십니까? !shouldYield()입니다. 매 작업 단위가 끝날 때마다, React는 Scheduler에게 묻습니다. "지금 양보할 시간인가요?"
React Scheduler 소스에서 실제 shouldYield 로직은 다음과 같습니다.
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
return false; // 예산이 남아 있음 — 계속 작업
}
return true; // 시간 초과 — 브라우저에 양보
}
각 작업에는 약 5ms가 주어집니다. 그 시간이 다 되면 React는 멈추고, 브라우저가 사용자 입력을 처리하고 화면을 그릴 기회를 얻습니다. 그런 다음 React는 멈춘 지점에서 정확히 재개합니다.
결과는 이렇습니다.

입력창은 모든 키 입력에 즉시 반응합니다. 차트도 렌더링됩니다. 단, 하나의 큰 덩어리가 아니라 여러 프레임에 걸쳐서 렌더링될 뿐입니다. 총 작업량은 동일하지만, 경험은 하늘과 땅 차이입니다.
Dan은 JSConf Iceland에서 이 특성들을 다음과 같이 정확하게 설명했습니다.
"우리는 낮은 우선순위 업데이트가 높은 우선순위 업데이트를 막지 않도록 하는 범용적인 방법을 만들었고, 이것을 Time Slicing이라고 부릅니다. 기기가 빠르다면 거의 동기적인 것처럼 느껴지고, 기기가 느리더라도 앱은 여전히 반응적으로 느껴집니다. requestIdleCallback API 덕분에 기기에 적응합니다. 최종 상태만 표시된다는 것에 주목하십시오. 렌더링된 화면은 항상 일관적이며, 느린 렌더링으로 인한 시각적 부작용은 나타나지 않습니다."
세 가지 특성을 정리하면 다음과 같습니다.
- 빠른 기기에서는 동기적으로 느껴집니다. — 조각이 너무 작고 빨라서 전혀 알아차릴 수 없습니다.
- 느린 기기에서도 반응적으로 느껴집니다. — 높은 우선순위 작업(키 입력)은 항상 처리됩니다.
- 최종 상태만 보입니다. — 중간 렌더링 상태가 화면에 번쩍이지 않습니다.
🌿 Git 비유
Dan은 발표에서 우선순위 처리를 직관적으로 이해할 수 있는 비유를 사용했습니다.
Time Slicing이 없다면 React는 main 브랜치에서 직접 작업하는 것과 같습니다. 기능 개발 중에 긴급한 버그가 들어오면 대응할 수 없습니다. 기능을 먼저 완료해야만 합니다.
Time Slicing을 사용하면, React는 제대로 된 브랜치 워크플로우처럼 동작합니다. 낮은 우선순위의 차트 렌더링을 피처 브랜치에서 작업하던 중, 키 입력이라는 긴급한 작업이 들어옵니다. 먼저 main에서 처리한 다음, 완료되면 피처 브랜치를 그 위에 리베이스합니다.
React는 이 리베이스를 자동으로 처리합니다. 낮은 우선순위 렌더링은 멈춘 곳에서 이어지며, 가장 최신 상태 위에 적용됩니다. 개발자는 이것을 전혀 신경 쓸 필요가 없습니다.

⏳ Suspense
Time Slicing이 CPU 문제를 해결한다면, Suspense는 IO 문제를 해결합니다.
Dan은 이렇게 소개했습니다.
"우리는 컴포넌트가 비동기 데이터를 로드하는 동안 렌더링을 중단할 수 있는 범용적인 방법을 만들었고, 이것을 Suspense라고 부릅니다. 데이터가 준비될 때까지 어떤 상태 업데이트도 멈출 수 있으며, 앱 전체에 props와 state를 끌어올리지 않고도 트리 깊은 곳에 있는 어떤 컴포넌트에든 비동기 로딩을 추가할 수 있습니다."
핵심 개념은 이것입니다. isLoading 플래그와 여기저기 흩어진 useEffect로 로딩 상태를 수동으로 관리하는 대신, 컴포넌트가 기다리고 있다는 것을 선언하면 React가 나머지를 처리합니다.
function MovieDetails({ id }) {
// 데이터가 캐시에 없으면 이 부분이 중단됩니다 — Promise를 throw합니다
const movie = movieCache.read(id);
return <div>{movie.title}</div>;
}
<Suspense fallback={<Spinner />}>
<MovieDetails id={42} />
</Suspense>
내부적으로, 컴포넌트가 중단되면 Promise를 throw합니다. React는 가장 가까운 <Suspense> 경계에서 이것을 catch하고, fallback을 보여주다가, Promise가 resolve되면 렌더링을 다시 시도합니다. 정확한 동작 원리는 — 대수적 효과(algebraic effects)와의 연결 고리를 포함해서 — Part 4에서 자세히 다루겠습니다.

강력한 점은 네트워크 상태에 따라 자연스럽게 적응한다는 것입니다.
"빠른 네트워크에서는 업데이트가 매우 유연하고 즉각적으로 나타나며, 나타났다 사라지는 스피너들의 불쾌한 연쇄가 없습니다. 느린 네트워크에서는, 코드가 작성된 방식이 아니라 사용자가 어떤 로딩 상태를 봐야 하는지, 그것이 얼마나 세밀하거나 큼지막해야 하는지를 의도적으로 설계할 수 있습니다. 앱은 내내 반응적인 상태를 유지합니다."
Dan이 마무리하며 강조한 핵심은 이것입니다.

"중요한 것은, 이것이 여러분이 알고 있는 React라는 점입니다. 여러분이 React에서 좋아하는 선언적 컴포넌트 패러다임 그대로입니다."
개발자로서의 멘탈 모델은 변하지 않습니다. 그 아래의 엔진이 바뀌는 것입니다.
🔗 공통 실마리: 중단 가능성
두 기능을 하나로 묶는 것은 무엇일까요?
Time Slicing과 Suspense는 완전히 달라 보입니다. 하나는 CPU에 관한 것이고, 다른 하나는 IO에 관한 것입니다. 하지만 두 가지는 하나의 근본적인 요구 사항을 공유합니다.
React는 하던 작업을 중단하고 나중에 재개할 수 있어야 합니다.
- Time Slicing: 16ms 프레임 예산 내에서 양보하기 위해 중단
- Suspense: 비동기 데이터를 기다리기 위해 중단
같은 능력입니다. 이것이 바로 React에 Fiber가 필요했던 이유입니다.
구버전 React 엔진은 모든 렌더링 작업을 추적하기 위해 JavaScript의 네이티브 콜 스택을 사용했습니다. 그리고 네이티브 콜 스택에는 치명적인 제한이 있습니다. 외부에서 멈출 수 없다는 것입니다. 함수 호출 스택이 실행되기 시작하면 완료까지 실행됩니다. "여기서 멈추고, 다른 것을 하고, 이 정확한 프레임에서 재개하라"고 말할 수 있는 JavaScript API는 존재하지 않습니다.
Matheus Albuquerque는 React Summit 2022 발표에서 해결책을 이렇게 설명했습니다.
"Fiber 아키텍처는 무엇을 해야 할지에 대한 스케줄링의 완전한 제어권을 주는 React 전용 콜 스택 모델이라고 생각할 수 있습니다. 그리고 Fiber 자체는 기본적으로 주어진 React 컴포넌트를 위한 스택 프레임입니다."
결정적으로, 네이티브 스택 프레임과 달리 Fiber는 그냥 평범한 JavaScript 객체입니다. React는 원할 때 언제든지 생성하고, 검사하고, 멈추고, 삭제하고, 재개할 수 있습니다. Sam Galson은 핵심 통찰을 한 문장으로 표현했습니다.
"Fiber는 React 컴포넌트에 특화된 스택의 재구현입니다. 스택을 재구현하면 스택 프레임을 메모리에 유지하고 원하는 방식으로, 원하는 때에 실행할 수 있다는 장점이 있습니다."

이것이 jser.dev가 설명한 React의 내부 파이프라인입니다.
- Trigger — 무언가가 변합니다(setState, 이벤트). React가 작업을 등록합니다.
- Schedule — 우선순위 큐인 Scheduler가 작업이 언제, 어떤 순서로 실행될지 결정합니다.
- Render — React가 Fiber 트리를 순회하며 무엇이 변했는지 파악합니다. 이 단계는 중단 가능합니다. Time Slicing과 Suspense가 여기에 있습니다.
- Commit — React가 실제 DOM에 변경 사항을 적용합니다. 동기적이며 중단할 수 없습니다.

Fiber는 Render 단계를 중단 가능하게 만들었습니다. 그 외의 모든 것은 여기서 비롯됩니다.
🗺️ 이 시리즈에서 다룰 내용
이번 글은 '왜'에 대한 이야기였습니다. 나머지 시리즈는 '어떻게'를 다루며, 각 파트는 하나의 문제와 하나의 해결책을 중심으로 구성되어 있습니다.
| 파트 | 제목 | 핵심 내용 |
|---|---|---|
| Part 2 | React가 자체 실행 엔진을 만들어야 했던 이유 | JS 콜 스택은 멈출 수 없음 → React가 Fiber + Scheduler를 만든 이유 |
| Part 3 | React가 실제로 무엇이 바뀌었는지 찾아내는 방법 | 모든 것을 다시 렌더링하기엔 너무 느림 → Reconciler + keys |
| Part 4 | Suspense를 가능하게 하는 아이디어 | 대수적 효과(algebraic effects) → Suspense와 ErrorBoundary의 실제 동작 방식 |
| Part 5 | 내부에서 본 React 생명주기 | useEffect와 useLayoutEffect가 언제, 왜 실행되는가 |
| Part 6 | 상태가 실제로 동작하는 방식 | useState, useRef, dispatch, 그리고 commit 상태 단계 |
| Part 7 | Vibe Coding useCallback의 함정 | memo와 useCallback에 손 대기 전에 리렌더링의 원인 파악하기 |
| Part 8 | Server Components & Hydration: 진짜 이야기 | React가 렌더링을 서버로 옮긴 방법 — 그리고 그 대가 |
| Part 9 | 완전한 그림: 키 입력에서 픽셀까지 | 하나의 시스템으로 함께 동작하는 React의 모든 레이어 |
🎬 참고 영상
이 글은 핵심을 압축한 것입니다. 원본 자료는 충분히 볼 가치가 있습니다.
Dan Abramov — Beyond React 16 | JSConf Iceland 2018 (33분)
이 글 전체의 기반이 되는 발표입니다. 핵심 순간: 2:57의 데모, 6:10에 공개되는 비동기 버전, 8:16의 Git 비유, 15:40부터 시작되는 Suspense 데모.
Matheus Albuquerque — Inside Fiber: the In-Depth Overview | React Summit 2022 (27분)
FiberNode 내부에 대한 가장 깊이 있는 설명입니다. 2:11부터 시작되는 Fiber-as-call-stack 설명은 이 시리즈의 Part 2와 직접 연결됩니다.
Sam Galson — Magic in the web: coroutines, continuations, fibers | React Advanced London (약 30분)
React의 접근 방식 뒤에 있는 컴퓨터 과학 — 코루틴, 컨티뉴에이션, 대수적 효과, 그리고 Fiber가 그 모양을 가지게 된 이유. Part 2와 3을 위한 필수 배경 지식입니다.
다음은 Part 2입니다. JavaScript의 콜 스택이 바로 React가 이 모든 것을 할 수 없었던 이유였습니다. React가 대신 무엇을 만들었는지 알아보겠습니다. 🔧