[DIP] React ErrorBoundary

react

React Internals Deep Dive 스터디중 ErrorBoundary에 대한 강의 내부 코드를 살펴보았다. 프로젝트 내에서 에러 처리를 고민했던 부분에 대해 좀 더 깊이 있게 알게 된 부분과 ErrorBoundary 동작을 정리해 두고자 한다.

리액트로 새로운 프로젝트를 진행하더라도 에러에 대한 처리를 대부분 비슷하게 가져가며 익숙한 로직을 별다른 고민 없이 사용했던 것 같다.
스터디를 진행하며 프로젝트 내 에러 처리 로직을 다시 돌아보게 되었고 혹시나 이 글을 읽으시는 분이 계신다면 조금이나마 리액트 에러 처리나 학습에 도움이 되길 바라며 정리해 보겠다.

*
React v18.3.1 버전 기준으로 정리되었음을 참고해주세요.

react-errorboundary-1.png



ErrorBoundary 내부 로직

ErrorBoundary 내부 로직은 디버깅하며 따라간 CallStack 기준으로 진행하되 중간중간 조건에 따른 호출 함수들을 끼워 넣어 정리해 보겠다.


renderRootSync / renderRootConcurrent

렌더링이 시작되고 workLoopSync를 통해 렌더링이 진행되는 과정에서 에러가 발생하는 경우 throw된 에러 정보가 handleError를 통해 에러에 대한 처리가 시작된다.

tsx
function renderRootSync(root: FiberRoot, lanes: Lanes) { ... do { try { workLoopSync(); break; } catch (thrownValue) { handleError(root, thrownValue); } } while (true); ... }


handleError

handleError 내부에서는 throwException을 통해 실질적인 에러 상태에 대한 처리를 진행한다.

tsx
function handleError(root, thrownValue): void { do { ... throwException( root, erroredWork.return, erroredWork, thrownValue, workInProgressRootRenderLanes, ); completeUnitOfWork(erroredWork); } while(true); ... }


throwException

throwException에서는 에러에 대한 업데이트를 생성하고 진행 중인 렌더 큐에 추가하는 작업을 수행하게 된다. 이후 에러 업데이트를 바로 렌더링 처리 진행할 completeUnitWork를 호출하여 진행 중인 렌더링 사이클에 함께 진행되도록 한다.

이후 진행될 렌더링 사이클에서 Fiber 노드가 완료되지 않았음을 나타내는 flag(Incomplete) 설정 또한 이곳에서 이루어진다.

아래 로직 중 중요하다고 생각되는 부분은 에러에 대한 업데이트를 생성하는 createRootErrorUpdate 부분과 생성된 업데이트를 큐에 추가 처리하는 enqueueCapturedUpdate 부분이다.

tsx
function throwException( root: FiberRoot, returnFiber: Fiber, sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, ) { ... const update = createRootErrorUpdate(workInProgress, errorInfo, lane); enqueueCapturedUpdate(workInProgress, update); ... }


createRootErrorUpdate

렌더링 큐에 추가할 업데이트가 생성되고 업데이트에는 callback, payload가 설정된다. 이는 ErrorBoundaray Class 컴포넌트 라이브사이클에서 정의되는 getDerivedStateFromErrorcomponentDidCatch가 호출 처리되도록 구성된다.

tsx
function createClassErrorUpdate( fiber: Fiber, errorInfo: CapturedValue<mixed>, lane: Lane, ): Update<mixed> { ... update.payload = () => { return getDerivedStateFromError(error); }; ... update.callback = function callback() { this.componentDidCatch(error, { componentStack: stack !== null ? stack : '', }); ... } ... }


enqueueCapturedUpdate

생성된 업데이트는 workInProgress update 렌더링 큐에 추가되고 completeUnitOfWork를 호출하여 현재 진행 중인 렌더링 사이클에 함께 진행되어 처리된다.

tsx
export function enqueueCapturedUpdate<State>( workInProgress: Fiber, capturedUpdate: Update<State>, ) { ... queue = { baseState: currentQueue.baseState, firstBaseUpdate: newFirst, lastBaseUpdate: newLast, shared: currentQueue.shared, effects: currentQueue.effects, }; workInProgress.updateQueue = queue; return; }


ErrorBoundary 핵심 전략?

ErrorBoundary를 내부 로직을 살펴보며 핵심적인 로직 혹은 전략이라고 생각했던 부분들이 있었다. 하나는 unwinding이였고 그 안에서 처리되는 Flag 기반 상태 관리가 인상 깊어 별도로 정리해 보고자 한다.


Unwinding

렌더링 진행 중 에러가 발생하게 되는 경우 에러가 발생한 지점(Node)부터 가장 가까운 ErrorBoundary까지 역방향으로 올라가 해당 지점부터 다시 렌더링을 시도 하는 과정을 unwinding이라고 한다.

내부 로직에서 다루었던 throwException에서 Fiber 노드가 작업을 완료하지 못했다는 상태인 Incomplete 플래그를 설정하게 된다. Incomplete 플래그는 부모 노드로 전파되며 가장 가까운 ErrorBoundary를 만날 때까지 모든 조상 노드에 Incomplete 플래그가 설정된다.

tsx
function throwException( root: FiberRoot, returnFiber: Fiber, sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, ) { // The source fiber did not complete. sourceFiber.flags |= Incomplete; ... }

이후 렌더링 진행 과정에서 completeUnitOfWork 함수에서 Incomplete 플래그를 확인한 후 에러 처리 로직을 수행하도록 한다. Incomplete 플래그 상태면 unwindWork 함수 처리되며 Fiber(ErrorBoundary) 플래그 ShouldCapture 플래그라면 DidCapture 플래그로 변경한다.

tsx
function completeUnitOfWork(unitOfWork: Fiber): void { ... // Check if the work completed or if something threw. if ((completedWork.flags & **Incomplete**) === NoFlags) { ... } else { // This fiber did not complete because something threw. Pop values off // the stack without entering the complete phase. If this is a boundary, // capture values if possible. const next = **unwindWork**(current, completedWork, subtreeRenderLanes); .. }

이후 반환된 Fiber(ErrorBoundary)가 리 렌더링 되며 DidCapture 플래그를 확인한 후 자식 노드를 모두 언마운트 처리하고 새로운 자식(fallback UI)으로 재조정(reconciliation) 작업을 수행한다.

tsx
function finishClassComponent( current: Fiber | null, workInProgress: Fiber, Component: any, shouldUpdate: boolean, hasContext: boolean, renderLanes: Lanes, ) { ... if (current !== null && didCaptureError) { // If we're recovering from an error, reconcile without reusing any of // the existing children. Conceptually, the normal children and the children // that are shown on error are two different sets, so we shouldn't reuse // normal children even if their identities match. forceUnmountCurrentAndReconcile( current, workInProgress, nextChildren, renderLanes, ); } else { reconcileChildren(current, workInProgress, nextChildren, renderLanes); }


플래그 기반 상태 관리

사실상 플래그 기반 상태 관리 부분은 unwinding 과정에서 사용되는 부분이 핵심적으로 사용되는 부분이다. 여기서는 단순히 각 플래그에 대한 상태가 어떤 역할을 하는지 간단히 정리해 보도록 하겠다.

Incomplete

현재 Fiber 노드가 정상적으로 렌더링 완료되지 않았음을 표시하며, 에러로 인해 렌더링이 중단되었을 경우 throwException을 통해 설정된다.

ShouldCapture

ErrorBoundary 또는 Suspense가 error/suspense를 캡처해야 한다는 상태를 표시한다.

DidCapture

ErrorBoundray 또는 Suspense가 error/Suspense를 캡처했음을 표시하며 해당 표시를 확인한 후 fallback UI 렌더링 처리를 진행한다.



Commit Phase 에러 처리

ErrorBoundary의 로직을 보면 Render Phase 구간에서 Fiber 노드에 대한 렌더 업데이트 과정에서 발생하는 예외 상황에 대해 fallback UI로 처리된다는 것을 알 수 있다. 만약 Render Phase는 문제없이 완료되었지만, Commit Phase 구간에서 예외 상황이 발생한다면 어떻게 처리가 될지 궁금하여 Commit Phase 구간에서 에러 상황에 대해 살펴보았다.


Commit Phase 에서는 commitRoot를 통해 Fiber 노드에 대한 DOM 반영과 Effect 처리를 수행한다. 이 과정에서 여러 과정에서 발생하는 예외 상황에 대해 captureCommitPhaseError를 통해 에러 업데이트에 대한 렌더링 스케쥴링을 처리한다.

tsx
export function captureCommitPhaseError( sourceFiber: Fiber, nearestMountedAncestor: Fiber | null, error: mixed, ) { ... const update = createClassErrorUpdate( fiber, errorInfo, (SyncLane: Lane), ); const root = enqueueUpdate(fiber, update, (SyncLane: Lane)); ... }

일반적 ErrorBoundary와 다르게 특이한 점은 새롭게 업데이트할 상태를 enqueueUpdate로 스케쥴링하는 부분이었다. Render Phase에서 ErrorBoundary에 대한 처리는 enqueueCapturedUpdate를 통해 현재 진행 중인 렌더링 사이클에 함께 진행되도록 queue에 업데이트를 추가한다. 하지만 Commit Phase에서의 처리는 enqueueUpdate로 업데이트를 다음 렌더링에 진행되도록 스케쥴링 처리한다.



에러 렌더링 스케쥴링?

일반적으로 ErrorBoundary에 대한 처리는 렌더링을 새롭게 스케쥴링하여 처리하는 것이 아니다.

에러에 대한 렌더링 처리는 단순하게 진행 중인 WorkInProgress Fiber Node의 queue에 에러에 대한 업데이트 정보를 추가하여 진행 중인 렌더링 사이클에 함께 진행한다.


enqueueUpdate

리액트 프로젝트에서 setState, useState, forceUpdate, render 등 일반적인 상태 업데이트의 경우 Fiber Node의 업데이트를 스케쥴링하여 렌더링을 진행한다.

tsx
const root = enqueueUpdate(fiber, update, lane); if (root !== null) { scheduleUpdateOnFiber(root, fiber, lane, eventTime); // 새로운 렌더링 스케줄링 entangleTransitions(root, fiber, lane); }


enqueueCapturedUpdate

렌더링 중 에러가 발생하는 경우 ErrorBoundary에서 에러가 캡처되며 throwException 함수에서 렌더링 진행 중인 WorkInProgress Fiber Node의 queue에 에러 업데이트를 추가한다.

tsx
function throwException( root: FiberRoot, returnFiber: Fiber, sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, ) { ... enqueueCapturedUpdate(workInProgress, update); ... }


In Conclusion

ErrorBoundary의 내부 로직을 직접 살펴보며, 이전까지 명확하지 않았던 의문들이 정리되었다. 다른 프로젝트에서는 어떻게 처리하고 있는지 모르겠지만, 리액트 컴포넌트 내부에서 발생하는 에러를 ErrorBoundary를 통한 처리와 비동기 에러 핸들링으로 나누어 대응했다. 리액트 내부에서 에러가 어디에서 어떻게 예외 처리되는지를 알고 나니, 지금껏 큰 고민 없이 사용해 왔던 패턴들이 왜 필요한지 이해할 수 있었다. 이러한 이해를 바탕으로 이제부터는 리액트를 사용할 때 더 의미 있는 코드를 작성하고, 개선점을 명확히 파악할 수 있을 것 같다.

react-errorboundary-2.png

“You can’t connect the dots looking forward; you can only connect them looking backwards.” — Steve Jobs