1. Error Boundaries

자바스크립트로부터 발생하는 에러가 리엑트 내부 상태들을 훼손하고 랜더링 문제를 발생시켰지만, 리엑트에서 적절하게 처리할 수 있는 방법을 제공하지 않았고, 이러한 문제를 해결하기 위해 리엑트v16 에서 새롭게 에러 경계(erorr-boundaries)라는 새로운 개념이 도입되었다.

에러 경계는 일부 UI에서 발생한 에러가 리엑트 앱 전역적으로 영향을 끼치게 되는 것을 막고, 에러 경계 하위 트리에서 발생한 자바스크립트 에러에 대해 기록을 남기고 깨진 컴포넌트 대신 콜백 UI로 전환될 수 있도록 한다.

에러 경계 구조

에러 경계는 static getDerivedStateFromError() 혹은 componentDidCatch() 가 선언된 클래스 컴포넌트로 구현되고, 이 컴포넌트 자체가 에러 경계가 된다.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트 합니다.
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 에러 리포팅 서비스에 에러를 기록할 수도 있습니다.
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 폴백 UI를 커스텀하여 렌더링할 수 있습니다.
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

에러가 발생하면 콜백 UI를 랜더링하는 상태 전환 역할은 static getDerivedStateFromError()에서 담당하고, 에러 정보를 기록하려면 componentDidCatch()를 사용한다.

children에 담기는 하위 트리에서 발생하는 에러는 이제 에러 경계로서 구현된 상위 클래스 컴포넌트에서 모두 캐치할 수 있게 된다. 여기서 에러 경계를 세분화할 지, 앱 전체를 묶어 최상단에서 관리할지에 대한 에러 경계의 배치는 개발자의 자유이다.

<ErrorBoundary>
  <UserProfile />
</ErrorBoundary>

리엑트 패키지, react-devtools-shared에서 구현한 ErrorBoundary는 에러 인스턴스를 통해 구분짓고 에러 인스턴스에 따라 각각 다른 폴백 UI를 노출시키는 훌륭한 예시를 제공한다.

type Props = {|
  children: React$Node,
  canDismiss?: boolean,
  onBeforeDismissCallback?: () => void,
  store?: Store,
|};

type State = {|
  callStack: string | null,
  canDismiss: boolean,
  componentStack: string | null,
  errorMessage: string | null,
  hasError: boolean,
  isUnsupportedBridgeOperationError: boolean,
  isTimeout: boolean,
  isUserError: boolean,
  isUnknownHookError: boolean,
|};

const InitialState: State = {
  callStack: null,
  canDismiss: false,
  componentStack: null,
  errorMessage: null,
  hasError: false,
  isUnsupportedBridgeOperationError: false,
  isTimeout: false,
  isUserError: false,
  isUnknownHookError: false,
};

export default class ErrorBoundary extends Component<Props, State> {
  state: State = InitialState;

  static getDerivedStateFromError(error: any) {
    const errorMessage =
      typeof error === "object" && error !== null && typeof error.message === "string"
        ? error.message
        : null;

    const isTimeout = error instanceof TimeoutError;
    const isUserError = error instanceof UserError;
    const isUnknownHookError = error instanceof UnknownHookError;
    const isUnsupportedBridgeOperationError = error instanceof UnsupportedBridgeOperationError;

    const callStack =
      typeof error === "object" && error !== null && typeof error.stack === "string"
        ? error.stack.split("\n").slice(1).join("\n")
        : null;

    return {
      callStack,
      errorMessage,
      hasError: true,
      isUnsupportedBridgeOperationError,
      isUnknownHookError,
      isTimeout,
      isUserError,
    };
  }

  componentDidCatch(error: any, { componentStack }: any) {
    this._logError(error, componentStack);
    this.setState({
      componentStack,
    });
  }

  componentDidMount() {
    const { store } = this.props;
    if (store != null) {
      store.addListener("error", this._onStoreError);
    }
  }

  componentWillUnmount() {
    const { store } = this.props;
    if (store != null) {
      store.removeListener("error", this._onStoreError);
    }
  }

  render() {
    const { canDismiss: canDismissProp, children } = this.props;
    const {
      callStack,
      canDismiss: canDismissState,
      componentStack,
      errorMessage,
      hasError,
      isUnsupportedBridgeOperationError,
      isTimeout,
      isUserError,
      isUnknownHookError,
    } = this.state;

    if (hasError) {
      if (isTimeout) {
        return (
          <TimeoutView
            callStack={callStack}
            componentStack={componentStack}
            dismissError={canDismissProp || canDismissState ? this._dismissError : null}
            errorMessage={errorMessage}
          />
        );
      } else if (isUnsupportedBridgeOperationError) {
        return (
          <UnsupportedBridgeOperationView
            callStack={callStack}
            componentStack={componentStack}
            errorMessage={errorMessage}
          />
        );
      } else if (isUserError) {
        return (
          <CaughtErrorView
            callStack={callStack}
            componentStack={componentStack}
            errorMessage={errorMessage || "Error occured in inspected element"}
            info={
              <>
                React DevTools encountered an error while trying to inspect the hooks. This is most
                likely caused by a developer error in the currently inspected element. Please see
                your console for logged error.
              </>
            }
          />
        );
      } else if (isUnknownHookError) {
        return (
          <CaughtErrorView
            callStack={callStack}
            componentStack={componentStack}
            errorMessage={errorMessage || "Encountered an unknown hook"}
            info={
              <>
                React DevTools encountered an unknown hook. This is probably because the
                react-debug-tools package is out of date. To fix, upgrade the React DevTools to the
                most recent version.
              </>
            }
          />
        );
      } else {
        return (
          <ErrorView
            callStack={callStack}
            componentStack={componentStack}
            dismissError={canDismissProp || canDismissState ? this._dismissError : null}
            errorMessage={errorMessage}
          >
            <Suspense fallback={<SearchingGitHubIssues />}>
              <SuspendingErrorView
                callStack={callStack}t
                componentStack={componentStack}
                errorMessage={errorMessage}
              />
            </Suspense>
          </ErrorView>
        );
      }
    }

    return children;
  }

  _logError = (error: any, componentStack: string | null) => {
    logEvent({
      event_name: "error",
      error_message: error.message ?? null,
      error_stack: error.stack ?? null,
      error_component_stack: componentStack ?? null,
    });
  };

  _dismissError = () => {
    const onBeforeDismissCallback = this.props.onBeforeDismissCallback;
    if (typeof onBeforeDismissCallback === "function") {
      onBeforeDismissCallback();
    }

    this.setState(InitialState);
  };

  _onStoreError = (error: Error) => {
    if (!this.state.hasError) {
      this._logError(error, null);
      this.setState({
        ...ErrorBoundary.getDerivedStateFromError(error),
        canDismiss: true,
      });
    }
  };
}

에러 경계가 캐치하지 못하는 에러

에러 경계가 모든 에러를 캐치할 수 있는 것은 아니다. 가령, 자식에서가 아닌 에러 경계 자체에서 발생하는 에러는 에러 경계에서 잡아낼 수 없다.

대표적으로, 이벤트 핸들러에서 발생한 에러는 에러 경계에서 잡아낼 수 없다. 이벤트 핸들러에서 발생한 에러가 render를 포함한 생명주기 메서드가 전개됨에 있어 랜더링에 영향을 주지 않기 때문이다.

리엑트 공식 문서에서는 이벤트 핸들러 내에서 발생하는 에러는 try/catch문을 사용하도록 권하고 있다.

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = { error: null };
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    try {
      // 에러를 던질 수 있는 무언가를 해야합니다.
    } catch (error) {
      this.setState({ error });
    }
  }

  render() {
    if (this.state.error) {
      return <h1>Caught an error.</h1>;
    }
    return <button onClick={this.handleClick}>Click Me</button>;
  }
}

추가적으로, setTimeout, requestAnimationFrame과 같이 비동기적인 코드의 콜백의 경우 에러 경계에서 에러를 잡아낼 수 없고, 서버 사이드 랜더링 과정에서 발생하는 에러 또한 잡아낼 수 없다.

Reference

https://ko.reactjs.org/docs/error-boundaries.html

https://github.com/facebook/react/tree/main/packages/react-devtools-shared/src/devtools/views/ErrorBoundary

Last updated