React19 Internals 3: Bailout


React를 사용하다 보면 마주치는 신기한 순간이 있습니다. 부모 컴포넌트의 상태가 변경되었는데, 자식 컴포넌트 전체가 아닌 특정 부분만 깜빡이며 업데이트됩니다. 어떻게 이런 일이 가능할까요?
이 현상의 중심에는 React의 성능 최적화 전략인 "Bailout" 이 있습니다. 말 그대로, React가 "이 컴포넌트는 변경점이 없으니, 렌더링 과정을 건너뛰자!"라고 결정하는 스마트한 메커니즘입니다.
이번 글에서는 이 Bailout이 어떤 원리로 동작하는지, 실제 React 소스 코드와 다이어그램을 통해 파헤쳐 보겠습니다.
수수께끼: 왜 일부만 리렌더링될까?
다음과 같은 컴포넌트 구조를 상상해 보세요.
// 우리의 컴포넌트 구조
<A>
<B>
<C>
<button/>
<D/>
</C>
</B>
<E>
<F/>
</E>
</A>
여기서 컴포넌트 C 안의 버튼을 클릭해 상태를 변경하면, 놀랍게도 C와 D만 다시 렌더링됩니다. A, B, E, F는 아무런 변화가 없습니다. React는 어떻게 이 사실을 알고 최소한의 작업만 수행했을까요?
모든 것의 시작: setState
호출 시퀀스
사용자가 버튼을 클릭하여 setState
를 호출하면 어떤 일이 벌어질까요? 렌더링이 일어나기까지의 과정을 Sequence Diagram으로 먼저 살펴보겠습니다.
sequenceDiagram
participant User
participant Component
participant ReactScheduler as "React 스케줄러"
participant ReactReconciler as "React 조정자 (Reconciler)"
User->>Component: "버튼 클릭 (이벤트 발생)"
Component->>Component: "setCount() 호출"
Component->>ReactScheduler: "scheduleUpdateOnFiber() (업데이트 예약)"
Note right of ReactScheduler: "업데이트에 'lane'을 할당하고<br/>루트까지 전파<br/>(markUpdateLaneFromFiberToRoot)"
ReactScheduler->>ReactReconciler: "performUnitOfWork() (작업 단위 수행)"
loop "각 파이버(Fiber)에 대해"
ReactReconciler->>ReactReconciler: "beginWork(fiber)"
Note left of ReactReconciler: "Bailout 여부 결정"
alt "Bailout 성공 (변경 없음)"
ReactReconciler-->>ReactReconciler: "작업 중단 (null 반환)"
else "Bailout 실패 (변경 있음)"
ReactReconciler-->>ReactReconciler: "자식 파이버 생성/업데이트<br/>(reconcileChildren)"
end
end
이 다이어그램은 업데이트 요청이 어떻게 스케줄러를 통해 조정자(Reconciler)에게 전달되고, beginWork
함수가 각 컴포넌트(파이버)의 운명을 결정하는 핵심 역할을 하는지 보여줍니다.
Deep Dive 1: 업데이트의 흔적, lanes와 Fiber 노드
React는 내부적으로 컴포넌트 트리를 파이버(Fiber) 노드 트리로 관리합니다. 각 파이버는 컴포넌트의 작업 단위이자 상태를 저장하는 장소입니다. setState
가 호출되면 React는 이 파이버 노드에 흔적을 남깁니다.
lanes
: 상태 변경이 직접 일어난 파이버에 꽂히는 깃발(flag)입니다.- lane왈: "나 자신이 업데이트되어야 해!"
childLanes
: 상태 변경이 일어난 파이버의 모든 부모에게 꽂히는 깃발입니다.- childLane왈: "내 자식들 중에 업데이트가 필요한 녀석이 있어!"
이 속성들은 파이버 노드 객체 안에 존재합니다. 파이버 노드의 구조를 간단히 살펴보겠습니다.
Deep Dive 2: Bailout의 관문, beginWork
소스 코드 분석
이제 하이라이트인 beginWork
함수의 실제 코드를 살펴보겠습니다. 이 함수가 어떻게 props
와 lanes
를 사용해 Bailout을 결정하는지 직접 확인해보겠습니다.
// source: react-reconciler/src/ReactFiberBeginWork.js (일부 단순화)
function beginWork(current, workInProgress, renderLanes) {
// 'current'는 이전 렌더링의 파이버입니다. null이 아니면 업데이트 상황입니다.
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
// 1. props나 context가 변경되었는가? (참조 비교)
if (oldProps !== newProps || hasContextChanged()) {
// 변경되었으면 무조건 리렌더링 진행
didReceiveUpdate = true;
} else {
// 2. props는 같다. 그럼 스케줄된 업데이트(lanes)가 있는가?
const hasScheduledUpdate = checkScheduledUpdateOrContext(current, renderLanes);
if (!hasScheduledUpdate) {
// 3. 스케줄된 업데이트도 없다! 최종 Bailout 시도.
// 이 함수 내부에서 childLanes를 확인하고 최종 결정을 내립니다.
return attemptEarlyBailoutIfNoScheduledUpdate(current, workInProgress, renderLanes);
}
// ...
}
}
// ...
// Bailout 되지 않았다면, 컴포넌트 타입에 따라 렌더링 함수를 실행합니다.
return updateFunctionComponent(current, workInProgress, ...);
}
이 코드의 논리를 Flowchart로 시각화하면 더욱 명확해집니다.
flowchart TD
subgraph "beginWork 함수 로직"
direction LR
A["시작"] --> B{"current !== null?<br/>(current: 이전 렌더링 파이버)"};
B -->|No| R["새로운 컴포넌트 렌더링"];
subgraph "업데이트 경로"
direction TB
B -->|Yes| C{"oldProps !== newProps?"};
C -->|No| D{context 변경?};
D -->|No| E{"lanes에 업데이트 존재?"};
E -->|No| G{"childLanes에 업데이트 존재?"};
end
C -->|Yes| Q["didReceiveUpdate = true<br>리렌더링 진행"];
D -->|Yes| Q;
E -->|Yes| Q;
G -->|No| H["최종 Bailout!<br>작업 중단 (null 반환)"];
G -->|Yes| I["자신은 건너뛰고<br>자식 파이버로 계속 진행"];
end
Q --> R;
I --> R;
R --> S["완료"];
H --> S;
숨겨진 범인: 왜 아무 상관 없는 <D/>
는 리렌더링될까?
이제 D
가 리렌더링되는 이유를 코드 레벨에서 추적할 수 있습니다. C
는 Bailout에 실패했으므로 updateFunctionComponent
가 호출됩니다. 이 함수는 C
의 렌더링 함수를 실행하여 return <div...><D/></div>
를 통해 새로운 <D/>
엘리먼트 객체를 만듭니다.
이 새 엘리먼트는 D
파이버의 pendingProps
가 됩니다. 다음 차례에 D
의 beginWork
가 실행될 때, oldProps !== newProps
비교에서 두 객체의 메모리 주소가 다르므로 true
를 반환하고, 결국 리렌더링으로 이어지는 것입니다.
아무 상관없는 컴포넌트 해결책: 참조를 동일하게 유지하라
이 문제를 해결하기 위해 개발자들은 props의 참조를 동일하게 유지하려는 다양한 메모이제이션(memoization) 노력을 해왔습니다. 단순히 자식 컴포넌트 자체를 React.memo
로 감싸는 것뿐만 아니라, 자식에게 전달하는 props가 렌더링마다 새로 생성되지 않도록 막는 것이 핵심입니다.
위와 같은 기법들의 공통 목표는 "렌더링 시 불필요한 참조 생성을 막아 React의 Bailout 메커니즘이 효율적으로 동작하도록 돕는 것" 입니다.
미래 엿보기: React 19와 React Compiler
React 19부터 선택적으로 도입되는 React Compiler는 이 모든 과정을 자동화하는 것을 목표로 합니다. 컴파일러가 코드를 미리 분석하여
<D />
와 같이 불필요하게 재생성되는 부분을 찾아내고, 자동으로React.memo
로 감싼 것과 같은 효과를 내도록 코드를 변환해줍니다. 아직은 실험적인 기능이지만, React의 개발 경험이 어떻게 진화할지 보여주는 흥미로운 방향입니다.
결론
React의 "Bailout"은 props
의 참조 동일성과 내부적인 lanes
시스템을 기반으로 동작하는 정교한 최적화 메커니즘입니다. beginWork
라는 관문을 통해 각 컴포넌트의 운명을 결정하며, 이 과정을 이해하는 것은 React의 성능을 최대로 끌어올리는 데 큰 도움이 됩니다.
우리가 작성하는 코드가 화면 뒤에서 어떻게 동작하는지 알 때, 우리는 더 나은 코드를 작성하고 예측 불가능한 버그를 더 쉽게 해결할 수 있기 때문입니다. 부족한 글 읽어주셔서 감사합니다.
Subscribe to my newsletter
Read articles from Ted Lee directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Ted Lee
Ted Lee
Software engineer for web tech. Interested in sustainable growth as software engineer.