[번역] React ErrorBoundary는 어떻게 동작하나요?

[번역] React ErrorBoundary는 어떻게 동작하나요?

React Internals Deep Dive - ep6

·

7 min read

영문 블로그 글을 번역했습니다. 허가를 받으면 시리즈를 이어갈 예정입니다.
원문링크: https://jser.dev/2023-05-26-how-does-errorboundary-work/


ℹ️ React Internals Deep Dive 에피소드 6, 유튜브에서 제가 설명하는 것을 시청해주세요.

React@18.2.0 기준, 최신 버전에서는 구현이 변경되었을 수 있습니다.

💬 역자 주석: Jser의 코멘트는 ❗❗로 표시 해뒀습니다.
그 외 주석은 리액트 소스 코드 자체의 주석입니다.
... 은 생략된 코드입니다.

1. ErrorBoundary는 리액트 파이버 트리에 대한 선언적 try...catch 입니다.

<ErrorBoundary fallback={<p>Something went wrong</p>}>
  <Profile />
</ErrorBoundary>

Profile이 렌더링 중 에러를 throw하면 ErrorBoundary는 fallback을 렌더링 하도록 시도하는데, React가 Fiber Tree를 순회하는 방법에서 설명한 대로, 파이버 노드의 렌더링 순서는 아래와 같습니다.

static getDerivedStateFromError(error)를 구현한 모든 클래스 컴포넌트는 ErrorBoundary입니다.

2. ErrorBoundary는 내부적으로 어떻게 동작하나요?

2.1 에러가 발생하면 가장 가까운 ErrorBoundary에 플래그(ShouldCapture)로 표시되고 state 업데이트가 예약됩니다.

function renderRootSync(root: FiberRoot, lanes: Lanes) {
  ...
  do {
    try {
      workLoopSync();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue); // ❗❗ handleError
    }
  } while (true);
  ...
  return workInProgressRootExitStatus;
}
function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
  ...
  do {
    try {
      workLoopConcurrent();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue); // ❗❗ handleError
    }
  } while (true);
  ...
  return workInProgressRootExitStatus;
}

동기화(Sync) 모드 또는 동시(Concurrent) 모드의 경우 작업 루프에 큰 try...catch가 적용되고 handleError()가 throw된 값을 처리합니다.

ℹ 참고로 동기화 모드와 동시 모드의 차이점은 메인 스레드에 yield해야 하는지 여부를 지속적으로 확인하는 workLoopConcurrent()에 있습니다. React 스케줄러의 작동 방식에 설명되어 있습니다.

function handleError(root, thrownValue): void {
  do {
    let erroredWork = workInProgress;
    try {
      ...
      throwException( // ❗❗ throwException
        root,
        erroredWork.return,
        erroredWork,
        thrownValue,
        workInProgressRootRenderLanes,
      );
      completeUnitOfWork(erroredWork); // ❗❗ completeUnitOfWork
    } catch (yetAnotherThrownValue) {
      ...
    }
    // Return to the normal work loop.
    return;
  } while (true);
}

handleError() 는 내부적으로 에러를 던지고 파이버 노드에서 작업을 완료하기 시작합니다. React는 파이버 트리를 어떻게 순회하나요? 에서 설명한 것처럼 에러가 발생하면 더 이상 자식으로 내려갈 필요가 없으므로 complete() 단계를 시작해야 합니다.

function throwException(
  root: FiberRoot,
  returnFiber: Fiber,
  sourceFiber: Fiber,
  value: mixed,
  rootRenderLanes: Lanes,
): void {
  // The source fiber did not complete.
  sourceFiber.flags |= Incomplete;
  // ❗❗                ↗ 여기 InComplete 플래그는 completeUnitOfWork()에게 중요합니다.
  if (
    value !== null &&
    typeof value === 'object' &&
    typeof value.then === 'function'
  ) {
    // ❗❗ 이 브랜치는 Suspense를 위한 것입니다.
    ...
  } else {
    // ❗❗ 이 브랜치는 일반적인 에러 바운더리를 위한 것입니다.
    ...
  value = createCapturedValueAtFiber(value, sourceFiber);
  renderDidError(value);
  let workInProgress: Fiber = returnFiber;
  do {
  // ↖ 이 do...while 루프는 루트 경로를 따라서 가장 가까운 에러 바운더리를 찾습니다.
    switch (workInProgress.tag) {
      case HostRoot: {
        ...
      }
      case ClassComponent: // ❗❗ ClassComponent
        // Capture and retry
        const errorInfo = value;
        const ctor = workInProgress.type;
        const instance = workInProgress.stateNode;
        if (
          (workInProgress.flags & DidCapture) === NoFlags &&
          (typeof ctor.getDerivedStateFromError === 'function' ||
                        // ❗❗ ↖ 우린 바운더리를 찾았습니다!
            (instance !== null &&
              typeof instance.componentDidCatch === 'function' &&
              !isAlreadyFailedLegacyErrorBoundary(instance)))
        ) {
          workInProgress.flags |= ShouldCapture;
                            // ↗ ❗❗ ShouldCapture 는 곧 DidCapture로 바뀔 예정입니다.
          const lane = pickArbitraryLane(rootRenderLanes);
          workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
          // Schedule the error boundary to re-render using updated state
          const update = createClassErrorUpdate(
                // ❗❗    ↗ 이 에러 바운더리는 지금 당장 처리되야 하므로 업데이트를 예약합니다.
            workInProgress,
            errorInfo,
            lane,
          );
          enqueueCapturedUpdate(workInProgress, update); // ❗❗ enqueueCapturedUpdate(workInProgress, update);
          return;
        }
        break;
      default:
        break;
    }
    // $FlowFixMe[incompatible-type] we bail out when we get a null
    workInProgress = workInProgress.return;
  } while (workInProgress !== null);
}

업데이트 내부에서 실제로 getDerivedStateFromError()가 호출되는 곳입니다.

function createClassErrorUpdate(
  fiber: Fiber,
  errorInfo: CapturedValue<mixed>,
  lane: Lane,
): Update<mixed> {
  const update = createUpdate(lane);
  update.tag = CaptureUpdate;
  const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
  if (typeof getDerivedStateFromError === 'function') {
    const error = errorInfo.value;
    update.payload = () => {
      return getDerivedStateFromError(error); // ❗❗ getDerivedStateFromError
    };
    update.callback = () => {
      logCapturedError(fiber, errorInfo);
    };
  }

그리고 enqueueCapturedUpdate()는 업데이트를 오류가 발생한 파이버의 updateQueue로 설정한 다음, mountClassInstance()updateClassInstance() 내부의 processUpdateQueue()를 통해 다음 렌더링에 처리되도록 합니다.

이는 Class Components에 대한 세부 사항으로 오래된 내용이므로 여기서는 자세히 다루지 않고, 이 이후에는 가장 가까운 에러 바운더리가 다음 렌더링에 오류를 반영하는 새로운 state를 갖게 된다는 점만 기억하세요.

2.2 언와인딩 중 ShouldCapture플래그는 DidCapture플래그로 바뀝니다.

React에서 컨텍스트는 어떻게 작동하나요? 에서 React 런타임은 경로를 따라 많은 정보를 저장하므로 각 파이버에 대한 정리 작업이 엉망이 되지 않도록 하는 것이 중요하다고 언급했습니다.

throwException() 에서 가장 가까운 ErrorBoundary를 찾았지만 아직 파이버 트리의 current 커서를 그 경계로 이동하지 않았습니다. 가장 가까운 에러 바운더리로 거슬러 올라가서 거기서부터 다시 렌더링을 시도하는 것이 언와인딩 과정입니다.

그리고 언와인딩은 이 글의 앞 부분에서 언급했던 completeUnitOfWork() 내부에서 이루어집니다.

ℹ complete() 단계의 작업에 대한 자세한 내용은 React는 파이버 트리를 어떻게 순회 할까요? 를 참고하세요.

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork;
  do {
    // The current, flushed, state of this fiber is the alternate. Ideally
    // nothing should rely on this, but relying on it here means that we don't
    // need an additional field on the work in progress.
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;
    // Check if the work completed or if something threw.
    if ((completedWork.flags & Incomplete) === NoFlags) {
        // ❗❗                 ↗ 여기서 Incomplete 플래그가 중요한 이유를 확인하세요.
        // ❗❗                    아래 브랜치는 일반 브랜치입니다.
      let next;
      if (
        !enableProfilerTimer ||
        (completedWork.mode & ProfileMode) === NoMode
      ) {
        next = completeWork(current, completedWork, subtreeRenderLanes);
      } else {
        startProfilerTimer(completedWork);
        next = completeWork(current, completedWork, subtreeRenderLanes);
        // Update render duration assuming we didn't error.
        stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);
      }
      resetCurrentDebugFiberInDEV();
      if (next !== null) {
        // Completing this fiber spawned new work. Work on that next.
        workInProgress = next;
        return;
      }
    } else {
    // ❗❗ ↖ 여기가 우리가 언와인드를 해야 할 Incomplete 브랜치입니다
      // 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);
      // Because this fiber did not complete, don't reset its lanes.
      if (next !== null) {
    // ❗❗ ↗ 이 브랜치는 completeWork()가 파이버 노드를 반환하면,
    // ❗❗ React가 부모에서 completeWork()를 계속하는 대신 해당 노드에서 리-렌더링하는 것을 보여줍니다.
        // If completing this work spawned new work, do that next. We'll come
        // back here again.
        // Since we're restarting, remove anything that is not a host effect
        // from the effect tag.
        next.flags &= HostEffectMask;
        workInProgress = next;
        return;
      }
      if (returnFiber !== null) {
        // Mark the parent fiber as incomplete and clear its subtree flags.
        returnFiber.flags |= Incomplete;
      // ❗❗ ↖ InComplete 파이버의 모든 조상 노드들은 모두 InComplete 합니다.
      // ❗❗ 이렇게 하면 가장 가까운 ErrorBoundary가 IncComplete 인지 확인하여,
      // ❗❗ 바운더리에 대한 unwindWork()가 호출됩니다.
        returnFiber.subtreeFlags = NoFlags;
        returnFiber.deletions = null;
      } else {
        // We've unwound all the way to the root.
        workInProgressRootExitStatus = RootDidNotComplete;
        workInProgress = null;
        return;
      }
    }
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      // If there is more work to do in this returnFiber, do that next.
      workInProgress = siblingFiber;
      return;
    }
    // Otherwise, return to the parent
    completedWork = returnFiber;
    // Update the next thing we're working on in case something throws.
    workInProgress = completedWork;
  } while (completedWork !== null);
  // We've reached the root.
  if (workInProgressRootExitStatus === RootInProgress) {
    workInProgressRootExitStatus = RootCompleted;
  }
}

그리고 unwindWork()에서는, DidCapture플래그가 플레이됩니다.

function unwindWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  // Note: This intentionally doesn't check if we're hydrating because comparing
  // to the current tree provider fiber is just as fast and less error-prone.
  // Ideally we would have a special version of the work loop only
  // for hydration.
  popTreeContext(workInProgress);
  switch (workInProgress.tag) {
    case ClassComponent: {
      const Component = workInProgress.type;
      if (isLegacyContextProvider(Component)) {
        popLegacyContext(workInProgress);
      }
      const flags = workInProgress.flags;
      if (flags & ShouldCapture) {
        workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
        // ❗❗ ↖ ShouldCapture 가 DidCapture로 바뀌는 것을 보세요.
        return workInProgress;
        // ❗❗ 앞서 설명했듯이, 이 리턴은 가장 가까운 바운더리인 이 파이버에서
        // ❗❗ 리-렌더링을 한다는 의미입니다.
      }
      return null;
    }
    ...
  }
}

2.3 DidCapture 플래그는 ErrorBoundary에서 새 자식으로 리-렌더링 하도록 강제합니다.

function finishClassComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  shouldUpdate: boolean,
  hasContext: boolean,
  renderLanes: Lanes,
) {
  const didCaptureError = (workInProgress.flags & DidCapture) !== NoFlags; 
                                                 // ❗❗ ↖
  ...
  const instance = workInProgress.stateNode;
  // Rerender
  ReactCurrentOwner.current = workInProgress;
  let nextChildren;
  if (
    didCaptureError &&
    typeof Component.getDerivedStateFromError !== 'function'
  ) {
    // If we captured an error, but getDerivedStateFromError is not defined,
    // unmount all the children. componentDidCatch will schedule an update to
    // re-render a fallback. This is temporary until we migrate everyone to
    // the new API.
    // TODO: Warn in a future release.
    nextChildren = null;
  } else {
    nextChildren = instance.render();
    // ❗❗ 이 때, 에러가 throw되면 예약된 업데이트가 생성되기 때문에 
    // ❗❗ render()가 다른 자식을 반환합니다.
  }
  // React DevTools reads this flag.
  workInProgress.flags |= PerformedWork;
  if (current !== null && didCaptureError) {
    // 오류에서 복구하는 경우 기존 자식을 재사용하지 않고 재조정(reconcile)합니다.
    // 개념적으로 정상 자식과 오류로 표시되는 자식은 서로 다른 두 세트이므로
    // 아이덴티티(identities)가 일치하더라도 일반 자식을 재사용해서는 안 됩니다.
    forceUnmountCurrentAndReconcile(
    // ❗❗ 이 이름과 위에 달린 주석들이 모든 것을 설명해줍니다.(💬 주석 번역함)
      current,
      workInProgress,
      nextChildren,
      renderLanes,
    );
  } else {
    reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  }
  return workInProgress.child;
    // ❗❗             ↗ 자식에게 더 깊이 들어가서 조정(reconciling)을 계속합니다.
}

코드는 매우 간단합니다.

3. 요약

이제 몇 가지 다이어그램을 통해 배운 내용을 요약해 보겠습니다.

💬 위 이미지를 클릭하세요

4. 코딩 챌린지

오늘 배운 내용을 강화하기 위해 다음 코딩 퀴즈를 풀어보세요.

💬 위 이미지를 클릭하세요

(원본 게시일: 2023-05-26)