🐶
Develop

ErrorBoundary 설명회

선언적인 에러 핸들링으로 관심사를 분리해보자

2024-02-05

ErrorBoundary

리액트 공식문서에서 제공하는 ErrorBoundary 예제

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
 
  static getDerivedStateFromError(error) {
    // state를 업데이트하여 다음 렌더링에 fallback UI가 표시되도록 합니다.
    return { hasError: true };
  }
 
  componentDidCatch(error, info) {
    // Example "componentStack":
    //   in ComponentThatThrows (created by App)
    //   in ErrorBoundary (created by App)
    //   in div (created by App)
    //   in App
    logErrorToMyService(error, info.componentStack);
  }
 
  render() {
    if (this.state.hasError) {
      // 사용자 지정 fallback UI를 렌더링할 수 있습니다.
      return this.props.fallback;
    }
 
    return this.props.children;
  }
}

ErrorBoundary의 역할은 아래와 같습니다.

  • 렌더링 과정에서 에러가 발생할 경우, 리액트의 렌더링 엔진은 에러를 상위 컴포넌트로 전파(버블링)합니다.
  • 버블링 과정에서 getDerivedStateFromError를 만나면 호출합니다.
    • componentDidCatchgetDerivedStateFromError 뒤에 호출됩니다.
      • getDerivedStateFromErrorcomponentDidCatch를 분리한 이유는 다음과 같습니다.
      • getDerivedStateFromError는 UI가 그려지기 전에 호출되고, componentDidCatch는 UI가 그려진 이후에 호출됩니다.
      • 에러 발생시 getDerivedStateFromError를 사용해서 에러 상태(hasError)를 변경하여 즉시 fallback을 보여주고, 에러 발생 후 이를 로깅하거나 후속 작업을 위해서는 componentDidCatch를 사용함으로써 두 메서드의 역할을 구분해놓았다고 할 수 있습니다.
  • 만나지 못한다면 전역으로 전파됩니다.
    • 전역으로 전파된 에러는 전체 컴포넌트 트리의 마운트를 해제합니다.
      • crash되는 이유는 react 팀에서 에러 발생시 손상된 UI를 보여주는 것이 아무 것도 보여주지 않는 것보다 더욱 나쁘다고 판단했기 때문입니다.
  • 따라서 ErrorBoundary는 getDerivedStateFromError를 사용하여 하위 컴포넌트의 렌더링 과정에서 발생하는 에러를 포착, 에러가 전역으로 전파되지 않게 하는 컴포넌트라고 요약할 수 있겠습니다.

ErrorBoundary는 다음과 같은 에러는 포착하지 못합니다.

  • 이벤트 핸들러에서 발생한 에러
  • 비동기 작업에서 발생한 에러
  • 서버사이드에서 발생한 에러
  • ErrorBoundary(자기자신)에서 발생한 에러

이벤트 핸들러에서 발생한 에러, 비동기 작업에서 발생한 에러의 경우

이 경우는 렌더링 과정에서 발생하는 에러가 아니므로 포착할 수 없습니다. 예시 코드는 아래와 같습니다.

export default function App() {
  return (
    <ErrorBoundary>
      <AsyncChild />
      <Button />
    </ErrorBoundary>
  );
}
 
// 비동기 작업에서 에러가 발생하는 경우
function AsyncChild() {
 
  function throwErrorFn() {
    throw new Error("will it be catched?");
  }
 
  setTimeout(throwErrorFn, 1000);
  return <div></div>;
}
 
// 이벤트 핸들러에서 에러가 발생하는 경우
function Button() {
 
  return (
    <button
      onClick={() => {
        throw new Error("will it be catched?");
      }}
    >
      click
    </button>
  );
}

두 경우 모두 에러를 포착하지 못합니다.

하지만 이렇게 변경하면 어떨까요?

export default function App() {
  return (
    <ErrorBoundary>
      <AsyncChild />
      <Button />
    </ErrorBoundary>
  );
}
 
// 비동기 작업에서 에러가 발생하는 경우
function AsyncChild() {
	const [isAsyncError, setIsAsyncError] = useState(false);
 
	useEffect(() => {
		if(isAsyncError) throw new Error("will it be catched?");
	}, [isAsyncError])
 
  function throwErrorFn() {
		setIsAsyncError(true)
  }
 
  setTimeout(throwErrorFn, 1000);
  return <div></div>;
}
 
// 이벤트 핸들러에서 에러가 발생하는 경우
function Button() {
	const [isHandlerError, setIsHandlerError] = useState(false);
 
	useEffect(() => {
		if(isHandlerError) throw new Error("will it be catched?");
	}, [isHandlerError])
 
  return (
    <button
      onClick={() => {
        setIsHandlerError(true);
      }}
    >
      click
    </button>
  );
}

이벤트 핸들러와 비동기 작업에서 곧바로 에러를 throw하지 않고, 상태를 변경하고 useEffect를 통해 에러를 throw하도록 수정했습니다.

이 경우, ErrorBoundary는 에러를 포착할 수 있습니다. 이유는 렌더링 과정에서 발생한 에러이기 때문입니다.

그렇다면 react query와 함께 써보면 어떨까요?

export default function App() {
  return (
    <ErrorBoundary>
      <Children />
    </ErrorBoundary>
  );
}
 
const useExampleQuery = () => {
	return useQuery({
		queryKey: ['example'],
		queryFn: getExample,
	});
};
 
function Children() {
	const { data } = useExampleQuery();
 
  return <div>{data}</div>;
}
 

위의 코드에서, getExample이 실패한다면 ErrorBoundary는 에러를 포착할 수 없습니다. 이유는 렌더링 과정에서 발생한 에러가 아니기 때문입니다.

ErrorBoundary에서 이 에러를 포착하기 위해서는 다음과 같이 수정하면 됩니다.

export default function App() {
  return (
    <ErrorBoundary>
      <Children />
    </ErrorBoundary>
  );
}
 
const useExampleQuery = () => {
	const queryResult = useQuery({
		queryKey: ['example'],
		queryFn: getExample,
	});
 
	if (queryResult.error) {
		throw queryResult.error;
	}
 
	return queryResult;
};
 
function Children() {
	const { data } = useExampleQuery();
 
  return <div>{data}</div>;
}
 
  • useQuery는 요청이 실패할 경우 컴포넌트를 리렌더링합니다.
    • useQuery는 useBaseQuery라는 훅으로 구현되어 있습니다. useBaseQuery는 useState와 useSyncExternalStore를 사용하고 있어 리액트의 리렌더링 사이클과 맞물려 동작할 수 있습니다.

실패시 발생하는 리렌더링 과정에 queryResult.error는 존재하므로, queryResult.error를 그대로 throw하고 이를 ErrorBoundary가 포착할 수 있습니다.

이 과정을 대신해주는 옵션이 바로 v5기준 throwOnError, v4기준 useErrorBoundary 입니다. useQuery의 throwOnError(useErrorBoundary)를 켜두면 저렇게 코드를 작성하지 않아도 ErrorBoundary가 useQuery에서 발생한 비동기 작업의 에러를 포착할 수 있게 됩니다.

const useExampleQuery = () => {
  return useQuery({
		queryKey: ['example'],
		queryFn: getExample,
		throwOnError: true,
	});
};

서버사이드에서 발생한 에러

  • ErrorBoundary는 클라이언트 사이드에서 발생한 에러만을 포착합니다.
    • getDerivedStateFromError는 리액트 클래스 컴포넌트의 라이프사이클 메서드로, 동적으로 UI를 업데이트하는 과정에서 발생하는 에러를 포착하기 위한 메서드입니다.
    • 반면 서버사이드 렌더링은 정적인 HTML을 생성하게 됩니다. 이 과정에서는 사용자 인터랙션에 의한 동적인 변화가 없기 때문에 클라이언트 사이드에서만 동작하도록 설계되어 있습니다.

ErrorBoundary 자체에서 발생한 에러

ErrorBoundary가 자신의 렌더링 도중에 발생한 에러를 포착하려고 시도하면, 무한 루프에 빠질 수 있기 때문입니다.

에러를 포착하여 상태를 업데이트하고 fallback UI를 렌더링하려다 다시 에러가 발생, 이를 다시 포착하여 상태를 업데이트하고 fallback UI를 렌더링하려다 다시 에러가 발생…

ErrorBoundary를 사용하는 이유

그렇다면 ErrorBoundary는 어떤 장점이 있어서 사용하는걸까요? try catch구문이나, react-queryonError를 사용해도 되는 것 아닌가요?

가장 큰 장점은 선언적인 에러 처리 수단이라는 것입니다.

  • 에러 처리 로직을 위임하여, 하위 컴포넌트는 에러와 관련된 로직을 생각하지 않아도 된다.
    • 명령형 코드로 작성할 경우 코드 길이로 인한 가독성 문제, 코드 작성에 따른 리소스 소모, 유사한 중복 코드 생산 문제 등이 발생할 수 있습니다.
  • 유지보수 포인트가 명확해진다.
    • 에러 발생시 보여줄 컴포넌트나 실행할 함수가 변경되어야 한다면, 즉시 가장 가까운 ErrorBoundary를 찾으면 됩니다.
  • 통일된 에러 처리 로직을 팀원이 공유할 수 있다.
    • 명령형 코드로 작성할 경우, **개발자의 코딩 스타일에 따라 다르게 구현될 수 있으므로 코드 리뷰의 피로도를 증가시키거나 유지보수시 코드 파악에 시간이 걸릴 수 있습니다.

ErrorBoundary를 조금 수정해봅시다.

단순히 에러가 발생하면 fallback을 보여주는 기능만으로는 조금 부족합니다. 필요할 것 같은 기능을 간단히 추가해보았습니다.

  • 에러가 발생하면 실행될 onError 구현
  • fallback 컴포넌트에서 에러를 reset할 수 있도록 하는 resetError 구현
import { Component, type ReactElement, type ReactNode } from 'react';
 
import type { AxiosError } from 'axios';
 
interface ErrorBoundaryState {
  hasError: boolean;
  error: AxiosError | null;
}
 
export interface ErrorBoundaryProps {
  errorFallback?: ReactElement | ((resetError: () => void) => ReactElement);
  onError?: (error?: AxiosError) => void;
  children: ReactElement;
}
 
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false, error: null };
  }
 
  static getDerivedStateFromError(error: AxiosError): ErrorBoundaryState {
    return { hasError: true, error: error };
  }
 
  componentDidCatch(error: AxiosError): void {
    this.props.onError?.(error);
  }
 
  resetError = () => {
    this.setState({ hasError: false, error: null });
  };
 
  render(): ReactNode {
    if (this.state.hasError) {
      const returnElement =
        typeof this.props.errorFallback === 'function'
          ? this.props.errorFallback?.(this.resetError)
          : this.props.errorFallback;
 
      return returnElement;
    }
 
    return this.props.children;
  }
}
 
export default ErrorBoundary;

현재 회사에서는 가장 가까운 useQuery를 reset해주는 useQueryErrorResetBoundary Hook과 함께 사용할 수 있도록 구현해두었습니다. 정리하여 추후 본 글에 추가하도록 하겠습니다.