React Fiber 아키텍처: 선언적 UI의 마법을 가능하게 하는 엔진
최근 동료들과 「전문가를 위한 리액트」를 스터디하면서 Fiber 아키텍처 챕터를 읽게 되었습니다. 처음엔 단순히 React의 내부 구현 정도로만 생각했는데, 깊이 파고들수록 이것이 React의 핵심이자 우리가 당연하게 사용하는 많은 기능들의 근간임을 깨달았습니다.
Fiber를 제대로 이해하고 나니, 그동안 “그냥 그런가보다” 했던 React의 동작들이 명확해졌고, 더 나은 React 코드를 작성하는 방법도 보이기 시작했습니다. 이 글은 제가 Fiber를 공부하면서 얻은 인사이트들을 정리한 것입니다.
특히 “우리가 선언적으로 코드를 작성할 수 있는 것은 Fiber라는 강력한 엔진이 뒤에서 모든 복잡한 일을 처리해주기 때문”이라는 깨달음을 여러분과 공유하고 싶습니다.
Stack Reconciler의 한계
Fiber를 알아보기 전에 Fiber가 나오기 전 쓰이던 Stack Reconciler에 대해 알아봐야 합니다. 문제가 있었기에 Fiber로 대체된 것일테니깐요. Stack Reconciler는 React 16 이전까지 있었습니다. React 16 이전의 Stack Reconciler가 가진 문제를 이해하려면, 먼저 브라우저가 어떻게 화면을 그리는지 알아야 합니다.
일반적인 60Hz 모니터는 1초에 60번 화면을 갱신합니다. 즉, 한 프레임당 약 16.67ms의 시간이 주어지죠. 이 시간 안에 JavaScript 실행, 스타일 계산, 레이아웃, 페인팅 등 모든 작업을 끝내야 부드러운 화면을 보여줄 수 있습니다.
Stack Reconciler의 문제는 한 번 시작한 작업을 중간에 멈출 수 없다는 것이었습니다. 마치 엘리베이터 없는 20층 건물을 한 번에 올라가야 하는 것과 같죠. 중간에 숨이 차도 쉴 수 없습니다.
// React 15 시절의 악몽 같은 시나리오
function Dashboard() {
const [searchTerm, setSearchTerm] = useState('');
const [data, setData] = useState(generateThousandsOfItems());
// 검색어가 바뀔 때마다 수천 개의 아이템을 필터링
const filteredData = data.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.description.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
<SearchInput
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
{filteredData.map(item => (
<ComplexItemComponent key={item.id} {...item} />
))}
</div>
);
}사용자가 검색창에 “React”라고 타이핑한다고 상상해보세요. 각 글자를 입력할 때마다:
- “R” 입력 → 5000개 아이템 필터링 및 렌더링 시작
- 아직 처리 중인데 “e” 입력 → 하지만 이전 작업이 끝날 때까지 대기
- 사용자는 입력이 먹통인 것처럼 느낌
- 첫 번째 작업이 끝나면 “e”에 대한 작업 시작
- 이 과정이 반복되면서 점점 더 뒤처짐
이것이 바로 제가 실무에서 겪었던 문제였습니다. Stack Reconciler는 동기적으로 작동하기 때문에, 한 번 시작한 재조정 작업을 끝까지 완료해야만 했습니다. 작업이 16ms를 넘어가면? 프레임 드롭이 발생하고 사용자는 버벅거림을 느끼게 됩니다.
Fiber란 무엇인가
Fiber는 이 문제를 근본적으로 다른 방식으로 접근했습니다. 만약 20층 건물을 한 번에 올라가는 것이 힘들다면, 각 층마다 쉴 수 있는 휴게실을 만들면 어떨까요? 더 나아가, 급한 사람이 있으면 잠시 비켜주고 나중에 다시 올라갈 수도 있겠죠.
Fiber의 핵심 아이디어는 정확히 이것입니다:
- 작업을 작은 단위로 쪼개기: 전체 컴포넌트 트리를 한 번에 처리하는 대신, 각 컴포넌트를 하나의 작업 단위로 처리
- 중단과 재개: 더 중요한 작업이 있으면 현재 작업을 중단하고, 나중에 중단된 지점부터 재개
- 우선순위 부여: 사용자 입력같은 긴급한 작업을 먼저 처리
이를 가능하게 하는 것이 바로 Fiber Node라는 자료구조입니다.
Fiber Node 구조
Fiber Node를 이해하는 가장 좋은 방법은 실제 예시를 통해 살펴보는 것입니다. 다음과 같은 간단한 컴포넌트가 있다고 해봅시다:
function App() {
return (
<div>
<h1>Hello</h1>
<button>Click me</button>
</div>
);
}React는 이 컴포넌트를 다음과 같은 Fiber Node 트리로 변환합니다:
App (Fiber Node)
|
div (Fiber Node)
|
h1 (Fiber Node) —— sibling ——> button (Fiber Node)
| |
"Hello" (text) "Click me" (text)각 Fiber Node는 단순한 JavaScript 객체입니다. 하지만 이 객체 안에는 React가 효율적으로 작업하기 위한 모든 정보가 담겨 있습니다:
1. 컴포넌트 정보
Fiber Node는 먼저 자신이 어떤 컴포넌트인지에 대한 정보를 가지고 있습니다. HTML 엘리먼트인지, 함수 컴포넌트인지, 클래스 컴포넌트인지를 type 필드에 저장합니다. 또한 해당 컴포넌트가 받은 props와 현재 state도 함께 저장되어 있죠.
예를 들어, <button onClick={handleClick}>Click me</button>의 Fiber Node는 type으로 “button”을, props로 { onClick: handleClick, children: "Click me" }를 가지고 있을 것입니다.
2. 트리 구조 - 연결 리스트의 마법
여기서 정말 영리한 부분이 나옵니다. 일반적인 트리 구조와 달리, Fiber는 연결 리스트 형태로 트리를 표현합니다. 각 노드는 세 가지 포인터를 가집니다:
- child: 첫 번째 자식을 가리킴
- sibling: 다음 형제를 가리킴
- return: 부모를 가리킴 (parent가 아닌 return인 이유는 작업 완료 후 돌아갈 곳이기 때문)
왜 이런 구조를 사용할까요? 재귀 호출을 사용하는 일반적인 트리 순회와 달리, 연결 리스트는 반복문으로 순회할 수 있습니다. 이는 언제든지 중단하고 정확히 그 지점부터 다시 시작할 수 있다는 뜻입니다!
3. 더블 버퍼링 - alternate의 비밀
게임 개발을 해보신 분들은 더블 버퍼링이라는 개념을 아실 겁니다. 화면에 그리는 동안 깜빡임을 방지하기 위해 두 개의 버퍼를 번갈아 사용하는 기법이죠.
Fiber도 비슷한 전략을 사용합니다. 각 Fiber Node는 alternate라는 필드를 가지고 있는데, 이는 현재 화면에 표시된 버전의 Fiber Node를 가리킵니다. React가 새로운 업데이트를 처리할 때:
- 현재 화면에 표시된 Fiber 트리(current)는 그대로 유지
- 새로운 Fiber 트리(work-in-progress)를 별도로 구성
- 모든 작업이 완료되면 포인터만 바꿔서 work-in-progress를 current로 만듦
이렇게 하면 사용자는 항상 완전한 UI를 보게 되고, 중간 상태를 보는 일이 없습니다.
4. 작업 추적과 우선순위
각 Fiber Node는 자신에게 어떤 작업이 필요한지, 그 작업의 우선순위는 어떤지를 기록합니다. effectTag는 이 노드에 대해 수행해야 할 작업(배치, 업데이트, 삭제 등)을 나타내고, expirationTime은 이 작업을 언제까지 완료해야 하는지를 나타냅니다.
마치 할 일 목록에 “긴급”, “중요”, “나중에” 같은 태그를 붙이는 것과 같습니다. React는 이 정보를 보고 어떤 작업을 먼저 처리할지 결정합니다.
작업 루프의 마법
이제 Fiber가 실제로 어떻게 작동하는지 살펴봅시다. Fiber의 작업 루프는 브라우저와 협력하여 사용자 경험을 최적화합니다.
브라우저는 각 프레임마다 다음과 같은 작업을 수행해야 합니다:
- 사용자 입력 처리
- JavaScript 실행
- 스타일 계산
- 레이아웃
- 페인팅
Fiber는 이 중 “JavaScript 실행” 단계에서 영리하게 시간을 나눠 씁니다. requestIdleCallback이라는 브라우저 API를 사용하여, 브라우저가 다른 중요한 작업을 모두 끝내고 여유가 있을 때만 React의 작업을 수행합니다.
작업 루프의 흐름을 일상적인 비유로 설명하면:
- 작업 선택: 할 일 목록에서 가장 우선순위가 높은 작업을 선택
- 시간 확인: 아직 이번 프레임에 시간이 남아있는지 확인
- 작업 수행: 하나의 Fiber Node를 처리 (한 컴포넌트만!)
- 중단 결정: 시간이 부족하거나 더 긴급한 일이 생기면 일단 멈춤
- 재개 또는 완료: 나중에 여유가 생기면 멈춘 곳부터 계속
이 과정에서 가장 중요한 것은 각 단계가 원자적(atomic)이라는 점입니다. 즉, 하나의 Fiber Node 처리는 중간에 끊기지 않습니다. 덕분에 일관성을 유지하면서도 유연하게 작업을 관리할 수 있죠.
재조정의 두 단계
Fiber가 작업을 안전하게 중단할 수 있는 비밀은 재조정 과정을 두 개의 명확한 단계로 분리한 것입니다. 이는 마치 요리를 할 때 준비 단계와 조리 단계를 구분하는 것과 같습니다.
Render Phase (준비 단계)
이 단계는 “무엇을 변경해야 하는지” 파악하는 단계입니다. 새로운 props나 state를 기반으로 컴포넌트를 호출하고, 이전 버전과 비교하여 변경사항을 찾아냅니다.
중요한 점은 이 단계에서는 실제로 아무것도 변경하지 않는다는 것입니다. 단지 “이 텍스트를 바꿔야 해”, “이 엘리먼트를 추가해야 해” 같은 작업 목록만 만들 뿐입니다. 실제 DOM은 전혀 건드리지 않죠.
이것이 왜 중요할까요? 부작용(side effect)이 없기 때문에 언제든지 작업을 중단하거나 버릴 수 있습니다. 마치 장보기 목록을 작성하다가 전화가 와서 중단했다가 나중에 다시 작성하는 것과 같습니다. 아직 실제로 산 것이 없으니 아무 문제가 없죠.
Commit Phase (실행 단계)
모든 준비가 끝나면 이제 실제로 변경사항을 적용할 차례입니다. 이 단계는 절대 중단할 수 없습니다. 왜냐하면 DOM을 일부만 업데이트하면 사용자가 깨진 UI를 보게 되기 때문입니다.
Commit Phase는 다시 여러 하위 단계로 나뉩니다:
- Before Mutation: DOM을 변경하기 직전, 현재 상태를 캡처 (getSnapshotBeforeUpdate가 여기서 실행됨)
- Mutation: 실제 DOM 변경 (노드 추가, 삭제, 속성 변경 등)
- Layout: DOM 변경 직후, 레이아웃 관련 작업 (useLayoutEffect가 여기서 실행됨)
- Passive: 모든 것이 끝난 후, 비동기적으로 실행 (useEffect가 여기서 실행됨)
이러한 단계 분리 덕분에 React는 일관성을 유지하면서도 유연하게 작업을 스케줄링할 수 있습니다.
Diffing 알고리즘의 심층 분석
React의 재조정 알고리즘, 즉 Diffing은 두 트리를 비교하여 최소한의 변경으로 UI를 업데이트하는 방법입니다. 일반적으로 두 트리를 완벽하게 비교하려면 O(n³)의 시간이 걸립니다. 1000개의 엘리먼트가 있다면 10억 번의 연산이 필요하죠!
React는 두 가지 현실적인 가정을 통해 이를 O(n)으로 줄였습니다:
가정 1: 다른 타입은 다른 트리
React는 엘리먼트의 타입이 바뀌면 완전히 다른 내용이 될 거라고 가정합니다. 예를 들어:
// 변경 전
<div>
<Counter count={3} />
</div>
// 변경 후
<span>
<Counter count={3} />
</span>div가 span으로 바뀌었습니다. 사람이 보기엔 별거 아닌 것 같지만, React는 전체 서브트리를 버리고 새로 만듭니다. Counter 컴포넌트도 언마운트되었다가 다시 마운트되죠. 내부 상태도 모두 초기화됩니다.
이게 비효율적으로 보일 수 있지만, 실제로는 매우 합리적입니다. 타입이 바뀌는 경우는 정말로 큰 변화가 있을 때가 대부분이고, 이런 휴리스틱 덕분에 복잡한 비교 없이 빠르게 결정할 수 있습니다.
가정 2: 개발자는 key로 힌트를 준다
리스트의 아이템들을 비교할 때, React는 순서대로 비교합니다. 하지만 이것만으로는 부족합니다:
// 변경 전
<ul>
<li>사과</li>
<li>바나나</li>
</ul>
// 변경 후 - 맨 앞에 추가
<ul>
<li>오렌지</li> {/* React는 이걸 "사과"가 "오렌지"로 바뀐 것으로 봄 */}
<li>사과</li> {/* "바나나"가 "사과"로 바뀐 것으로 봄 */}
<li>바나나</li> {/* 새로 추가된 것으로 봄 */}
</ul>이런 비효율을 막기 위해 key가 필요합니다. key는 React에게 “이 엘리먼트는 저 엘리먼트와 같은 거야”라고 알려주는 이름표 같은 것입니다.
Diffing 과정의 실제
React가 두 트리를 비교하는 과정을 단계별로 살펴보면:
- 루트부터 시작: 두 트리의 루트 엘리먼트부터 비교 시작
- 타입 확인: 타입이 같으면 속성만 업데이트, 다르면 전체 교체
- 자식들 비교: 자식이 하나면 직접 비교, 여러 개면 특별한 알고리즘 사용
- 재귀적 적용: 각 자식에 대해 같은 과정 반복
특히 자식이 여러 개인 경우의 알고리즘이 흥미롭습니다. React는 최대한 효율적으로 비교하기 위해 여러 단계를 거칩니다.
Key의 내부 작동 원리 심층 분석
Key가 실제로 어떻게 작동하는지 이해하려면, React가 리스트를 재조정하는 과정을 자세히 들여다봐야 합니다.
Phase 1: 순서가 유지되는 경우 최적화
React는 먼저 “혹시 순서가 그대로 유지되고 있나?”를 확인합니다. 많은 경우 리스트의 끝에 아이템을 추가하거나, 중간 아이템의 내용만 바꾸는 경우가 많기 때문입니다.
// 예: 끝에 추가
['A', 'B', 'C'] → ['A', 'B', 'C', 'D']
// React의 처리:// 1. A의 key 같음 → 재사용// 2. B의 key 같음 → 재사용// 3. C의 key 같음 → 재사용// 4. D는 새로운 아이템 → 생성이 경우 매우 효율적으로 처리됩니다. 기존 엘리먼트들은 그대로 두고 새 엘리먼트만 추가하면 되니까요.
Phase 2: 순서가 바뀐 경우
순서가 바뀌면 조금 복잡해집니다. React는 남은 기존 자식들을 Map 자료구조에 넣어 빠르게 찾을 수 있게 합니다:
// 예: 순서 변경
['A', 'B', 'C'] → ['C', 'A', 'B']
// React의 처리:// 1. Map 생성: { 'A': FiberA, 'B': FiberB, 'C': FiberC }// 2. 새 리스트 순회:// - 'C' 찾기 → Map에 있음! FiberC 재사용// - 'A' 찾기 → Map에 있음! FiberA 재사용// - 'B' 찾기 → Map에 있음! FiberB 재사용Key가 없을 때의 문제
Key가 없으면 React는 인덱스로 비교할 수밖에 없습니다:
// key가 없는 경우
<ul>
{items.map((item, index) => (
<li>{item}</li>// React는 index를 암시적 key로 사용
))}
</ul>이것이 왜 문제가 될까요? 실제 예시를 봅시다:
// 각 아이템이 내부 상태를 가진 경우
function TodoItem({ text }) {
const [isChecked, setIsChecked] = useState(false);
return (
<li>
<input
type="checkbox"
checked={isChecked}
onChange={e => setIsChecked(e.target.checked)}
/>
{text}
</li>
);
}
// 문제 상황: 맨 앞 아이템 삭제
// 변경 전: ['할일1(체크됨)', '할일2', '할일3']
// 변경 후: ['할일2', '할일3']
// key가 없으면:
// - index 0의 '할일1(체크됨)'이 '할일2'로 바뀜
// - 하지만 체크 상태는 그대로!
// - 결과: '할일2'가 체크된 상태로 표시됨 😱Key를 활용한 고급 패턴
Key는 단순히 리스트의 성능을 위한 것만이 아닙니다. React의 재조정 메커니즘을 직접 제어할 수 있는 강력한 도구입니다.
1. 컴포넌트 강제 리셋
때로는 props가 바뀌었을 때 컴포넌트의 모든 내부 상태를 초기화하고 싶을 때가 있습니다:
// 사용자 프로필 편집 폼
function EditProfileForm({ userId }) {
const [formData, setFormData] = useState({
name: '',
email: '',
bio: ''
});
// 문제: userId가 바뀌어도 formData는 그대로!
return <form>...</form>;
}
// 해결: key로 컴포넌트 identity 제어
function ProfilePage({ userId }) {
return (
<EditProfileForm
key={userId}// userId가 바뀌면 폼 완전 초기화
userId={userId}
/>
);
}2. 애니메이션 제어
CSS 애니메이션이나 트랜지션을 다시 시작하고 싶을 때도 key를 활용할 수 있습니다:
// 숫자가 바뀔 때마다 바운스 애니메이션
function AnimatedNumber({ value }) {
return (
<div
key={value}
className="number bounce-in"
// key가 바뀌면 DOM 노드가 새로 생성되어// CSS 애니메이션이 처음부터 다시 시작
>
{value}
</div>
);
}3. 조건부 상태 보존
서로 다른 뷰 모드에서 상태를 보존하거나 리셋하고 싶을 때:
function DataView({ viewMode, data }) {
// 그리드/리스트 뷰는 상태 공유, 차트 뷰는 독립적
const viewKey = viewMode === 'chart' ? 'chart' : 'table';
return (
<div key={viewKey}>
{viewMode === 'grid' && <GridView data={data} />}
{viewMode === 'list' && <ListView data={data} />}
{viewMode === 'chart' && <ChartView data={data} />}
</div>
);
}스케줄러와 동시성 렌더링의 심층 분석
React의 스케줄러는 마치 유능한 비서와 같습니다. 여러 가지 일을 우선순위에 따라 정리하고, 급한 일이 들어오면 일정을 재조정하며, 모든 일이 제시간에 처리되도록 관리합니다.
우선순위 시스템의 철학
React의 우선순위 시스템은 사용자 경험 연구에 기반을 두고 있습니다:
즉각적인 우선순위 (Immediate Priority)
- 사용자가 버튼을 클릭하거나 텍스트를 입력할 때
- 인간은 100ms 이내의 반응을 “즉각적”이라고 느낍니다
- React는 이런 작업을 30ms 이내에 처리하려고 합니다
사용자 차단 우선순위 (User Blocking Priority)
- 스크롤, 드래그, 애니메이션 같은 연속적인 상호작용
- 250ms 이내에 처리되어야 부드럽게 느껴집니다
- 이보다 늦으면 “뭔가 느리다”고 인지하기 시작합니다
일반 우선순위 (Normal Priority)
- 데이터 페칭, 일반적인 상태 업데이트
- 5초 정도는 기다릴 수 있는 작업들
- 대부분의 비동기 작업이 여기에 해당합니다
낮은 우선순위 (Low Priority)
- 분석 데이터 전송, 백그라운드 동기화
- 10초 이내에만 처리되면 되는 작업들
- 사용자가 직접 인지하지 못하는 작업들입니다
시간 슬라이싱의 마법
시간 슬라이싱(Time Slicing)은 Fiber의 가장 혁신적인 기능 중 하나입니다. 이를 일상적인 비유로 설명하면:
여러분이 긴 책을 읽고 있다고 상상해보세요. 전통적인 방식(Stack Reconciler)은 책을 한 번 잡으면 끝까지 다 읽어야 합니다. 중간에 전화가 와도, 택배가 와도 무시하고 계속 읽어야 하죠.
Fiber의 방식은 다릅니다. 한 챕터를 읽고 나서 “혹시 급한 일이 있나?” 확인합니다. 전화가 왔다면 받고, 택배가 왔다면 받은 다음, 다시 책갈피를 꽂아둔 곳부터 읽기를 재개합니다.
// 실제 사용 예시: 대용량 리스트 렌더링
function HugeList({ items }) {
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
// 5000개 아이템 필터링 - 무거운 작업
const filteredItems = useMemo(() => {
if (!filter) return items;
// startTransition으로 낮은 우선순위 부여
return items.filter(item =>
item.title.toLowerCase().includes(filter.toLowerCase()) ||
item.content.toLowerCase().includes(filter.toLowerCase())
);
}, [items, filter]);
return (
<div>
<SearchInput
value={filter}
onChange={e => {
// 입력은 즉시 반영 (높은 우선순위)
setFilter(e.target.value);
// 필터링은 천천히 (낮은 우선순위)
startTransition(() => {
// 여기서 무거운 작업 트리거
});
}}
/>
<div style={{ opacity: isPending ? 0.6 : 1 }}>
{filteredItems.map(item => (
<ItemCard key={item.id} {...item} />
))}
</div>
</div>
);
}브라우저와의 협력
React는 브라우저의 여러 API를 활용하여 최적의 성능을 냅니다:
requestIdleCallback의 활용
브라우저가 “나 지금 한가해!”라고 알려주면 React가 작업을 수행합니다. 하지만 모든 브라우저가 이를 지원하지 않아서, React는 자체적인 폴리필을 구현했습니다.
MessageChannel을 통한 스케줄링
React는 MessageChannel API를 사용하여 매크로태스크를 예약합니다. 이는 마이크로태스크(Promise)보다 낮은 우선순위를 가지므로, 브라우저의 다른 중요한 작업을 방해하지 않습니다.
프레임 경계 인식
React는 각 프레임의 시작 시간을 기록하고, 5ms 정도를 자신의 작업에 할당합니다. 시간이 초과되면 즉시 브라우저에게 제어권을 돌려줍니다.
Lane 모델: 더 세밀한 우선순위 관리
React 18에서는 우선순위 시스템이 Lane이라는 개념으로 진화했습니다. Lane은 32비트 비트마스크로, 더 세밀한 우선순위 관리를 가능하게 합니다.
Lane을 고속도로의 차선에 비유할 수 있습니다:
- 각 차선(Lane)은 특정 종류의 작업을 위한 것
- 여러 차선을 동시에 사용할 수 있음 (비트 OR 연산)
- 가장 왼쪽 차선(낮은 비트)이 가장 높은 우선순위
이 시스템의 장점은 여러 업데이트를 효율적으로 배치(batch)할 수 있다는 것입니다. 같은 우선순위의 여러 업데이트를 하나로 합쳐서 처리할 수 있죠.
동시성 기능의 실무 활용
useTransition - 사용자 경험 향상의 핵심
useTransition은 “이 업데이트는 좀 기다려도 돼”라고 React에게 알려주는 방법입니다:
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = async (searchQuery) => {
// 검색어는 즉시 업데이트
setQuery(searchQuery);
// 결과 업데이트는 transition으로
startTransition(async () => {
const data = await fetchSearchResults(searchQuery);
setResults(data);
});
};
return (
<div>
<SearchBar
value={query}
onChange={handleSearch}
// 로딩 상태를 시각적으로 표시
className={isPending ? 'loading' : ''}
/>
{/* isPending일 때 이전 결과를 흐리게 표시 */}
<ResultsList
results={results}
style={{ opacity: isPending ? 0.6 : 1 }}
/>
</div>
);
}useDeferredValue - 지연된 값의 활용
useDeferredValue는 “급하지 않은” 버전의 값을 만듭니다:
function ExpensiveChart({ data }) {
// data가 자주 바뀌어도 차트는 천천히 업데이트
const deferredData = useDeferredValue(data);
const chartConfig = useMemo(() => {
// 복잡한 차트 설정 계산
return calculateChartConfig(deferredData);
}, [deferredData]);
// data !== deferredData일 때는// 새 데이터를 처리 중임을 표시
const isStale = data !== deferredData;
return (
<div className={isStale ? 'updating' : ''}>
<Chart config={chartConfig} />
{isStale && <LoadingOverlay />}
</div>
);
}자동 배칭 (Automatic Batching)
React 18의 가장 실용적인 개선 중 하나는 자동 배칭입니다. 이전에는 이벤트 핸들러 내부에서만 배칭이 되었지만, 이제는 어디서든 작동합니다:
// React 17에서는 3번 리렌더링
// React 18에서는 1번만 리렌더링!
async function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
const data = await fetchData();
setData(data);// 이전엔 별도 리렌더링
setLoading(false);// 이전엔 또 별도 리렌더링
setTimeout(() => {
setMessage('완료!');// 이전엔 또 별도 리렌더링
}, 1000);
}이는 개발자가 성능을 위해 인위적으로 상태를 합치거나 복잡한 패턴을 사용할 필요가 없다는 뜻입니다. React가 알아서 최적화해주니까요!
동시성 모드의 주의사항
동시성 기능을 사용할 때 알아야 할 중요한 점들이 있습니다:
1. 컴포넌트는 순수해야 합니다
Render Phase가 여러 번 실행되거나 중단될 수 있으므로, 부작용이 없어야 합니다:
// ❌ 나쁜 예
function BadComponent({ userId }) {
// 렌더링 중 부작용 - 여러 번 실행될 수 있음!
analytics.track('component_rendered', { userId });
return <div>...</div>;
}
// ✅ 좋은 예
function GoodComponent({ userId }) {
// Effect에서 부작용 처리
useEffect(() => {
analytics.track('component_rendered', { userId });
}, [userId]);
return <div>...</div>;
}2. 외부 상태 관리 라이브러리 호환성
Redux, MobX 같은 외부 상태 관리 라이브러리를 사용한다면, 동시성 기능과 호환되는지 확인해야 합니다. 최신 버전들은 대부분 useSyncExternalStore를 사용하여 호환성을 보장합니다.
3. StrictMode에서의 이중 호출
개발 모드의 StrictMode에서는 동시성 안전성을 검증하기 위해 일부 함수를 두 번 호출합니다. 이는 프로덕션에서는 발생하지 않지만, 개발 중에는 당황스러울 수 있습니다.
성능 최적화 실전 가이드
이제 Fiber의 모든 것을 이해했으니, 실무에서 어떻게 활용할지 알아봅시다.
1단계: 올바른 컴포넌트 구조 설계
Fiber를 제대로 활용하는 첫 번째 단계는 컴포넌트를 올바르게 설계하는 것입니다. 이는 마치 건물을 지을 때 기초를 튼튼히 하는 것과 같습니다.
단일 책임 원칙
각 컴포넌트는 하나의 책임만 가져야 합니다. 이렇게 하면 Fiber가 효율적으로 작업을 분할할 수 있습니다:
// ❌ 너무 많은 책임
function UserDashboard() {
const [user, setUser] = useState();
const [posts, setPosts] = useState([]);
const [notifications, setNotifications] = useState([]);
const [analytics, setAnalytics] = useState();
useEffect(() => {
// 모든 데이터를 한 번에 가져옴
fetchAllData().then(data => {
setUser(data.user);
setPosts(data.posts);
setNotifications(data.notifications);
setAnalytics(data.analytics);
});
}, []);
return (
// 하나가 바뀌면 전체가 리렌더링
<div>...</div>
);
}
// ✅ 책임 분리
function UserDashboard() {
return (
<div>
<UserProfile /> {/* 독립적으로 데이터 관리 */}
<PostList /> {/* 독립적으로 데이터 관리 */}
<NotificationBell /> {/* 독립적으로 데이터 관리 */}
<AnalyticsWidget /> {/* 독립적으로 데이터 관리 */}
</div>
);
}상태의 콜로케이션
상태는 사용되는 곳에 최대한 가깝게 위치해야 합니다. 이는 불필요한 리렌더링을 방지하는 가장 효과적인 방법입니다:
// ❌ 상태가 너무 위에 있음
function App() {
const [searchTerm, setSearchTerm] = useState('');
return (
<div>
<Header />
<MainContent>
<Sidebar />
<SearchableList
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
</MainContent>
<Footer />
</div>
);
}
// ✅ 상태를 사용하는 곳에 위치
function App() {
return (
<div>
<Header />
<MainContent>
<Sidebar />
<SearchableList /> {/* 내부에서 searchTerm 관리 */}
</MainContent>
<Footer />
</div>
);
}2단계: 측정을 통한 문제 파악
최적화의 첫 번째 규칙은 “추측하지 말고 측정하라”입니다. React DevTools Profiler는 이를 위한 강력한 도구입니다.
무엇을 측정해야 하나?
- 렌더링 시간: 각 컴포넌트가 렌더링하는 데 걸리는 시간
- 렌더링 빈도: 컴포넌트가 얼마나 자주 리렌더링되는지
- 렌더링 이유: 왜 리렌더링이 발생했는지 (props, state, parent)
측정 결과 해석하기
- 16ms 이상: 프레임 드롭 가능성, 최적화 필요
- 자주 렌더링되지만 변화가 없는 컴포넌트: memo 후보
- 부모 때문에 렌더링되는 컴포넌트: 구조 개선 필요
3단계: 전략적 메모이제이션
측정을 통해 문제를 파악했다면, 이제 적절한 최적화를 적용할 차례입니다:
// Profiler로 확인한 결과:
// - ExpensiveList는 렌더링에 50ms 소요
// - items가 바뀌지 않아도 부모 때문에 자주 리렌더링
const ExpensiveList = React.memo(({ items, onItemClick }) => {
// 복잡한 계산이나 많은 DOM 노드 생성
const processedItems = useMemo(() => {
return items
.filter(item => item.visible)
.sort((a, b) => b.priority - a.priority)
.map(item => ({
...item,
displayName: formatDisplayName(item)
}));
}, [items]);
return (
<VirtualList
items={processedItems}
onItemClick={onItemClick}
/>
);
}, (prevProps, nextProps) => {
// 커스텀 비교 로직
return (
prevProps.items === nextProps.items &&
prevProps.onItemClick === nextProps.onItemClick
);
});4단계: 동시성 기능 활용
마지막으로, 사용자 경험을 더욱 향상시키기 위해 동시성 기능을 활용합니다:
function RealTimeDataDashboard() {
const [filter, setFilter] = useState('');
const [timeRange, setTimeRange] = useState('1h');
const [isPending, startTransition] = useTransition();
// 실시간 데이터
const data = useRealTimeData(timeRange);
// 필터링된 데이터 (무거운 연산)
const filteredData = useMemo(() => {
if (!filter) return data;
// 수천 개의 데이터 포인트 필터링
return data.filter(point =>
matchesComplexFilter(point, filter)
);
}, [data, filter]);
return (
<div>
<ControlPanel>
<TimeRangeSelector
value={timeRange}
onChange={setTimeRange}// 즉시 반영
/>
<FilterInput
value={filter}
onChange={value => {
setFilter(value);// 입력은 즉시
// 차트 업데이트는 낮은 우선순위
startTransition(() => {
// 여기서 차트 리렌더링 트리거
});
}}
/>
</ControlPanel>
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<ComplexChart data={filteredData} />
</div>
</div>
);
}마치며: 실무에서의 Fiber
이제 우리는 Fiber가 단순한 “React의 내부 구현”이 아니라, 우리가 매일 작성하는 코드의 성능과 사용자 경험에 직접적인 영향을 미치는 핵심 기술임을 알게 되었습니다.
Fiber를 이해하고 나니 깨달은 가장 중요한 점은:
“좋은 컴포넌트 설계는 100개의 React.memo보다 강력하다”
왜냐하면:
- Fiber는 이미 최적화되어 있습니다: 잘 설계된 컴포넌트 구조만 있다면, Fiber가 알아서 효율적으로 작업을 처리합니다.
- 측정이 추측을 이깁니다: 실제 문제를 측정을 통해 파악하고, 그에 맞는 해결책을 적용하는 것이 무작정 최적화하는 것보다 효과적입니다.
- React를 믿으세요: React 팀은 수년간 Fiber를 개선해왔습니다. 우리가 할 일은 그들이 만든 도구를 올바르게 사용하는 것입니다.
실무에서 성능 문제를 만났을 때, 이제는 다르게 접근할 수 있습니다:
- 먼저 컴포넌트 구조를 점검하고
- 측정을 통해 실제 병목을 찾고
- 필요한 곳에만 최적화를 적용하고
- 동시성 기능으로 사용자 경험을 향상시키기
5년 동안 React를 사용하면서도 몰랐던 이 깊이 있는 지식이, 이제는 더 나은 애플리케이션을 만드는 든든한 기반이 되었습니다.
여러분도 Fiber를 이해함으로써, React가 제공하는 강력한 기능들을 제대로 활용하고, 사용자에게 더 나은 경험을 제공할 수 있기를 바랍니다.
측정하고, 이해하고, 그리고 React를 믿으세요.
참고:
https://cekrem.github.io/posts/react-reconciliation-deep-dive/