지난 글을 마무리 하면서 Batching에 대한 내용을 정리하다보니 내부 구현이 궁금해져서 딥다이브 하겠다!는 예고를 남겼습니다.
이번 글에서는 React의 소스 코드를 분석하며 Batching이 구현된 원리를 살펴보겠습니다.
특히, Batching이 동작하는 대표적인 세 가지 경우를 중심으로 살펴보겠습니다.
1️⃣ 상태 업데이트 Batching – useEffect 또는 생명주기 메서드 내부에서 여러 개의 setState가 호출될 때, React는 이를 어떻게 하나의 렌더링으로 묶을까?
2️⃣ 이벤트 핸들러 내부 Batching – 클릭, 입력 등의 이벤트 핸들러 내부에서 발생하는 여러 개의 상태 업데이트는 어떻게 Batching되는가?
3️⃣ Automatic Batching (React 18 이후) – 기존에는 Batching되지 않던 setTimeout, fetch 같은 비동기 코드 내부에서도 Batching이 동작하는 원리는?
추가적으로, React는 단순히 여러 개의 setState를 묶어서 처리하는 것뿐만 아니라, 업데이트의 중요도(우선순위)에 따라 처리 순서를 정하는 Render Lane 시스템도 사용합니다. 하지만 이 글에서는 Batching의 동작 원리에 집중하고, Render Lane의 개념은 이후 별도의 글에서 자세히 다룰 예정입니다.React에서는 setState가 호출될 때마다 즉시 렌더링을 수행하는 것이 아니라, 여러 개의 상태 업데이트를 모아 한 번의 렌더링으로 처리(Batching) 합니다.
이를 통해 불필요한 렌더링을 방지하고 성능을 최적화할 수 있습니다.
이 과정에서 React Fiber(Reconciler)와 Scheduler가 함께 동작하며, 내부적으로 executionContext와 updateQueue를 활용하여 여러 상태 업데이트를 한 번에 처리할지 여부를 결정합니다.
React의 Batching이 어떻게 동작하는지 살펴보려면, 두 가지 핵심 개념을 이해해야 합니다.
executionContext – 현재 실행 중인 컨텍스트를 관리하여 Batching 여부를 결정updateQueue – 여러 개의 상태 업데이트를 임시로 저장하여 한 번에 처리React가 상태 업데이트를 어떤 과정을 거쳐 Batching 하는지 파악하기 위해 아래 함수들이 실행되는 과정을 차근차근 따라가보겠습니다.
React에서 setState()가 호출되면, 즉시 렌더링을 수행하지 않고 먼저 업데이트 객체(Update Object)를
생성한 후, updateQueue(업데이트 대기열)에 저장합니다. 이를 통해 여러 개의 상태 변경 요청을 한꺼번에 처리하여 불필요한 렌더링을 방지하고 성능을 최적화할 수 있습니다.
순차적으로 살펴보자면, setState()가 호출되면 내부적으로 다음과 같은 순서로 내부 함수들이 실행됩니다.
dispatchSetState() 실행 (상위 레벨 상태 업데이트 요청)setState()가 호출되면 dispatchSetState() 실행dispatchSetStateInternal()을 호출하여 실제 업데이트 처리를 위임dispatchSetStateInternal() 실행 (업데이트 객체 생성 및 큐에 추가)📂 파일 위치: ReactFiberHooks.js
1function dispatchSetStateInternal(fiber, queue, action, lane) {
2 const update = {
3 lane,
4 revertLane: NoLane,
5 action,
6 hasEagerState: false,
7 eagerState: null,
8 next: null,
9 };
10
11 ...
12
13 const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
14
15 if (root !== null) {
16 scheduleUpdateOnFiber(root, fiber, lane);
17 entangleTransitionUpdate(root, queue, lane);
18 return true;
19 }
20
21 ...
22
23 }update) 생성enqueueConcurrentHookUpdate() 실행 (업데이트 대기열에 추가)1export function enqueueConcurrentHookUpdate<S, A>(
2 fiber: Fiber,
3 queue: HookQueue<S, A>,
4 update: HookUpdate<S, A>,
5 lane: Lane,
6): FiberRoot | null {
7 const concurrentQueue: ConcurrentQueue = (queue: any);
8 const concurrentUpdate: ConcurrentUpdate = (update: any);
9
10 enqueueUpdate(fiber, concurrentQueue, concurrentUpdate, lane);
11 // 내부에서 enqueueUpdate 호출
12
13 return getRootForUpdatedFiber(fiber); // 업데이트된 Fiber의 최상위 Root 찾기
14}enqueueUpdate()에 전달getRootForUpdatedFiber())enqueueUpdate에 위임📂 파일 위치: ReactFiberConcurrentUpdates.js
1function enqueueUpdate(
2 fiber: Fiber,
3 queue: ConcurrentQueue | null,
4 update: ConcurrentUpdate | null,
5 lane: Lane,
6) {
7 // 현재 업데이트 정보를 global queue (concurrentQueues)에 저장
8 concurrentQueues[concurrentQueuesIndex++] = fiber;
9 concurrentQueues[concurrentQueuesIndex++] = queue;
10 concurrentQueues[concurrentQueuesIndex++] = update;
11 concurrentQueues[concurrentQueuesIndex++] = lane;
12
13 // 병렬 업데이트를 위한 Lane 병합
14 concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
15
16 // Fiber 트리에서 업데이트를 추적할 수 있도록 Lane 정보 갱신
17 fiber.lanes = mergeLanes(fiber.lanes, lane);
18 const alternate = fiber.alternate;
19 if (alternate !== null) {
20 alternate.lanes = mergeLanes(alternate.lanes, lane);
21 }
22}
23concurrentQueues[]라는 글로벌 큐에 저장setState() 호출을 병렬적으로 관리할 수 있도록 Lane을 병합[중간 정리]
setState()가 호출되면, React는 즉시 렌더링하지 않고 먼저 업데이트를 저장합니다.enqueueConcurrentHookUpdate()는 내부적으로 enqueueUpdate()를 호출하여 업데이트를 전역 큐concurrentQueues[]에 추가합니다.enqueueUpdate()는 여러 개의 setState()를 효율적으로 배치하기 위해 Lane을 병합하고, Fiber의 업데이트 상태를 동기화합니다.enqueueConcurrentHookUpdate 내부에서 finishQueueingConcurrentUpdates()를 호출하면서 여러 setState, 전역 큐(concurrentQueues[])에 저장된 업데이트들을 한 번에 처리하고 각 업데이트를 udpateQueue에 추가합니다. 이를 통해 여러 상태 업데이트를 한꺼번에 처리하여 성능을 최적화할 수 있습니다. 그 다음 scheduleUpdateOnFiber()가 실행되어 최적의 타이밍에 업데이트를 실행하도록 예약됩니다.이제 scheduleUpdateOnFiber()가 업데이트를 실행하도록 예약을 걸기 위해 최적의 타이밍을 찾아가는 과정을 살펴보겠습니다.
초반에 살펴본 함수인 dispatchSetStateInternal()이 실행되면, React는 업데이트된 Fiber의 최상위 Root를 찾고 (getRootForUpdatedFiber()), 이를 기반으로 scheduleUpdateOnFiber()를 호출합니다.
dispatchSetStateInternal() 내부에서 scheduleUpdateOnFiber() 실행📂 파일 위치: ReactFiberHooks.js
1
2if (root !== null) {
3 scheduleUpdateOnFiber(root, fiber, lane);
4 entangleTransitionUpdate(root, queue, lane);
5 return true;
6}dispatchSetStateInternal()이 실행되면, React는 업데이트된 Fiber의 Root를 찾고 (getRootForUpdatedFiber()), 이를 기반으로 scheduleUpdateOnFiber()를 호출scheduleUpdateOnFiber() 내부 동작📂 파일 위치: ReactFiberWorkLoop.js
1export function scheduleUpdateOnFiber(root: FiberRoot, fiber: Fiber, lane: Lane) {
2 if (isRendering) {
3 return; // 이미 렌더링 중이면 Batching하여 처리
4 }
5
6 // 1️⃣ 현재 실행 중인 Context를 확인하여 Batching할지 결정
7 if (executionContext & (RenderContext | CommitContext) !== NoContext) {
8 ensureRootIsScheduled(root);
9 return;
10 }
11
12 // 2️⃣ Batching을 위해 실행 컨텍스트 설정
13 const prevExecutionContext = executionContext;
14 executionContext |= BatchedContext;
15
16 try {
17 // 3️⃣ 최적의 스케줄링을 설정하여 업데이트 예약
18 ensureRootIsScheduled(root);
19 } finally {
20 executionContext = prevExecutionContext;
21 }
22}executionContext)를 확인하여 Batching할지 결정BatchedContext) 활성화 → 여러 개의 setState()를 한 번에 실행 가능ensureRootIsScheduled()를 호출하여 업데이트를 최적의 시점에 실행하도록 예약ensureRootIsScheduled() 업데이트를 예약하는 함수📂 파일 위치: ReactFiberRootScheduler.js
1function ensureRootIsScheduled(root) {
2 const existingCallbackNode = root.callbackNode;
3
4 // 현재 작업을 Scheduler에 예약
5 const newCallbackNode = scheduleCallback(performConcurrentWorkOnRoot);
6
7 root.callbackNode = newCallbackNode;
8}scheduleCallback(performConcurrentWorkOnRoot)을 호출하여 다음 렌더링을 예약processUpdateQueue()와 commitRoot() 실행📂 파일 위치: ReactFiberWorkLoop.js
1function processUpdateQueue(workInProgress, props, instance) {
2 let queue = workInProgress.updateQueue;
3 let newState = workInProgress.memoizedState;
4
5 while (queue.length > 0) {
6 const update = queue.shift(); // 큐에서 업데이트 하나씩 가져옴
7 newState = processUpdate(newState, update); // 새로운 상태 계산
8 }
9
10 workInProgress.memoizedState = newState;
11}
12
13function commitRoot(root) {
14 commitBeforeMutationEffects(root);
15 commitMutationEffects(root);
16 commitLayoutEffects(root);
17}
18processUpdateQueue()가 실행되어 Batching된 업데이트를 한 번에 적용 (updateQueue 에 저장된 상태 변경 사항을 가져와서 memoizedState에 반영)commitRoot() 실행 → 최종적으로 DOM에 반영commitRoot()가 실행되면서, executionContext |= CommitContext;가 설정되어 현재 커밋 중임을 표시함executionContext는 다시 원래 상태로 복구됨| 단계 | 실행되는 함수 | 역할 |
|---|---|---|
1️⃣ setState() 호출 | dispatchSetState() | 상태 업데이트 요청 |
| 2️⃣ 업데이트 큐에 추가 | enqueueConcurrentHookUpdate() | concurrentQueues[](전역 큐)에 추가 |
3️⃣ 전역 큐 → updateQueue로 이동 | finishQueueingConcurrentUpdates() | 전역 큐의 업데이트를 updateQueue로 이동 |
| 4️⃣ 업데이트 예약 | scheduleUpdateOnFiber() | 배칭 여부 결정 후 업데이트 예약 |
| 5️⃣ 스케줄링 실행 | ensureRootIsScheduled() | 업데이트를 최적의 타이밍에 실행 |
| 6️⃣ 최적의 타이밍에 렌더링 실행 | performConcurrentWorkOnRoot() | 배칭된 업데이트를 한 번에 렌더링 |