Error Boundary(에러 바운더리)로 우아하게 에러 처리하기

개미·2024년 4월 2일
0
post-thumbnail

에러 바운더리란?

공식 문서 says

기본적으로 애플리케이션이 렌더링 도중 에러를 발생시키면 React는 화면에서 해당 UI를 제거합니다. 이를 방지하기 위해 UI의 일부를 에러 경계(error boundary)로 감싸면 됩니다. 에러 경계는 에러가 발생한 부분 대신 에러 메시지와 같은 폴백 UI를 표시할 수 있는 특수한 컴포넌트입니다.

간단히 말하자면, 페이지에서 에러가 발생했을 때 빈화면이 아닌 정의한 에러를 띄우고 싶다! 그러면 에러 바운더리를 써서 폴백 UI에 에러 화면을 지정할 수 있다.

함수형 에러 바운더리를 쓰고 싶어요

공식문서에 나온 에러 바운더리 예제는 모두 클래스형으로 구현되어 있다.

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

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the 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) {
      // You can render any custom fallback UI
      return this.props.fallback;
    }

    return this.props.children;
  }
}

나는 클래스형을 한번도 사용해 본 적이 없어서 함수형으로 작성하고 싶었다. 다행히 다음과 같이 공식문서에서 알려주듯이 react-error-boundary 라이브러리를 통해 함수형 컴포넌트로 에러 바운더리를 구현할 수 있었다.

공식 문서 says

현재 에러 경계를 함수 컴포넌트로 작성할 수 있는 방법은 없습니다. 하지만 에러 경계 클래스를 직접 작성할 필요는 없습니다. 예를 들어, react-error-boundary를 대신 사용할 수 있습니다.

react-error-boundary

기본 사용 예제

import { ErrorBoundary } from "react-error-boundary";

function Fallback({ error, resetErrorBoundary }) {
	// 에러 바운더리를 리셋하고, 렌더링을 재시도하기 위해 resetErrorBoundary()를 호출할 수 있다.
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre style={{ color: "red" }}>{error.message}</pre>
    </div>
  );
}

<ErrorBoundary
  FallbackComponent={Fallback}
  onReset={(details) => {
    // 에러가 다시 발생하지 않도록 앱의 상태를 리셋하는 코드 작성
  }}
>
  <ExampleApplication />
</ErrorBoundary>;

FallbackCompomponent로 지정한 컴포넌트는 error와 resetErrorBoundary를 props로 전달받을 수 있다.

resetErrorBoundary를 통해 onReset에서 지정한 상태 리셋 코드를 호출할 수 있다!

프로젝트 적용 코드

// @pages/Error.tsx

import { useEffect, useRef } from 'react';

import { DefaultButton, SvgIcon } from '@components/index';
import ClearLayout from '@layouts/ClearLayout';
import FixedBottomLayout from '@layouts/FixedBottomLayout';
import { useLocation, useNavigate } from 'react-router-dom';

type FallbackProps = {
	error: {
		message: string;
	};
	resetErrorBoundary: () => void;
};

const Error = ({ resetErrorBoundary }: FallbackProps) => {
	const navigate = useNavigate();
	const location = useLocation();
	const errorLocation = useRef(location.pathname);

	const handleClickHome = () => {
		navigate('/');
		resetErrorBoundary();
	};

	const message = {
		title: '앗, 여기는 달달한 상품이 \n 없는 것 같아요 🥲',
		description: `죄송합니다. 오류가 발생했습니다.\n문제를 해결하기 위해 열심히 노력중입니다.\n잠시 후 다시 들어와주세요.`,
	};
	
	useEffect(() => {
		if (location.pathname !== errorLocation.current) {
			resetErrorBoundary();
		}
	}, [location.pathname]);

	return (
		<ClearLayout className="px-[25px] py-[24px]">
			<div className="flex flex-col gap-3">
				<SvgIcon id="error" width={206} height={78} />
				<h2 className="typography-Subhead text-White whitespace-pre-line">
					{message.title}
				</h2>
				<h4 className="typography-Body2 typography-R text-Gray20 whitespace-pre-line">
					{message.description}
				</h4>
			</div>
			<FixedBottomLayout childrenPadding="px-6" height="h-15">
				<DefaultButton
					title="달달쇼핑 홈으로 가기"
					color={{ bgColor: 'White', textColor: 'Black' }}
					size="large"
					onClick={handleClickHome}
				/>
			</FixedBottomLayout>
		</ClearLayout>
	);
};

export default Error;
// App.tsx

import Error from '@pages/Error';

<ErrorBoundary
  FallbackComponent={Error}
  onReset={() => {
				window.location.reload();
			}}
>
  <Others />
</ErrorBoundary>;

직접 구현한 Error 컴포넌트를 불러와서 FallbackComponent에 넣어주었다. 그러면 에러가 발생할 경우 Error 컴포넌트가 화면에 보이게 된다.

트러블 슈팅 과정

onReset을 넣지 않으면 에러 상태 리셋이 되지 않는다.

에러 코드

// App.tsx

import Error from '@pages/Error';

<ErrorBoundary
  FallbackComponent={Error}
>
  <Others />
</ErrorBoundary>;

onReset은 기본적으로 hasErrortrue에서 false로 리셋하여준다. 그 이외에 상태를 리셋해야 한다면, onReset 내에 로직을 넣어서 추가할 수 있다.

처음에는 위와 같이 onReset에 추가적인 로직을 넣지 않아도, 잘 동작하는 것처럼 보였다. 하지만 다음과 같은 경우에 정상적인 동작을 하지 않는 것을 알 수 있었다.

1. /detail/3 페이지에서 /items/3을 호출하는 과정에서 에러가 발생하여 Error 페이지가 표현된다.2. 에러 페이지에서 ‘홈으로 가기’ 버튼을 클릭하여 홈으로 이동한다.3. /details/2 페이지로 갔을 때, /items/2 api을 새로 호출해야 하는데 호출하지 않는다. <- 문제상황!

다시 호출하지 않는 이유

tanstack-query로 호출한 API에서 발생한 에러는 캐싱되기 때문에 리셋되는 시점에 캐싱된 error query도 리셋해줘야 한다.

에러가 난 queryCache만 지우는 것이 베스트겠지만, 우리 프로젝트에서는 reload를 하여 전체 캐시가 날라가도록 구현하였다. 왜냐하면 api 호출 시 발생하는 에러 이외에, 자바스크립트 자체 에러의 경우에도 상태를 리셋해주어야 하므로, 포괄적인 reload를 써서 모든 상태를 리셋시켰다.

만약, queryCache를 따로 제거하고 싶다면 QueryErrorResetBoundary를 사용할 수 있다.

import { ErrorBoundary } from 'react-error-boundary';
import Error from '@pages/Error';
import { QueryErrorResetBoundary } from '@tanstack/react-query';

<QueryErrorResetBoundary>
  {({ reset }) => (
    <ErrorBoundary FallbackComponent={ErrorFallback} onReset={reset}>
      <Others />
    </ErrorBoundary>
  )}
</QueryErrorResetBoundary>

QueryErrorResetBoundary 컴포넌트의 자식 함수에는 reset이라는 함수가 제공된다. 이 함수를 호출하면 오류 쿼리에 대한 오류 상태가 초기화되고, 쿼리를 재요청할 수 있게 된다.

최종 에러 해결 코드

import Error from '@pages/Error';

<ErrorBoundary
  FallbackComponent={Error}
  onReset={() => {
    window.location.reload();
  }}
>
  <ExampleApplication />
</ErrorBoundary>;

home에서 에러가 나는 경우, 에러 상태가 리셋되지 않는다.

에러 코드

// @pages/Error.tsx

const location = useLocation();
const errorLocation = useRef(location.pathname);

useEffect(() => {
  if (location.pathname !== errorLocation.current) {
    resetErrorBoundary();
  }
}, [location.pathname]);
	
const handleClickHome = () => { // '홈으로 가기' 버튼 클릭 시 실행
  navigate('/');
};

useLocation() 훅을 사용하여 현재 브라우저의 경로를 가져온다. 이 값은 리액트 컴포넌트가 렌더링될 때마다 변경될 수 있다. useRef(location.pathname)를 사용하여 이전 경로를 기록한다. 이전 경로를 기록하기 위해 useRef 훅을 사용하는 이유는, 렌더링 사이클 간에 값이 유지되기 때문이다. 즉, 리렌더링이 발생해도 이전 경로가 변경되지 않는다.

위의 코드의 문제점은 홈에서 에러가 나서 handleClickHome을 눌렀을 때, 현재 경로인 location.pathname 이 계속 동일하게 홈(’/’)으로 같기 때문에 resetErrorBoundary가 실행되지 않는다. 그래서 여전히 오류 페이지를 마주하게 된다.

말이 조금 어려운 것 같은데, 조금 정리해보자면!

홈이 아닌 페이지에서 오류가 날때, 에러 페이지 내의 ‘홈으로 가기 버튼’ 클릭시홈에서 오류가 날때, 에러 페이지 내의 ‘홈으로 가기 버튼’ 클릭시
이전 경로와 홈의 경로는 달라서 resetErrorBoundary 호출이전 경로도 홈이기에 pathname이 달라지지 않아 useEffect가 실행자체가 안 될 것. resetErrorBoundary를 호출 못해서 상태가 초기화되지 못함 <- 문제상황!

따라서 다음과 같이 에러를 해결할 수 있었다.

최종 에러 해결 코드

const handleClickHome = () => { // '홈으로 가기' 버튼 클릭 시 실행
  navigate('/');
  resetErrorBoundary();
};

handleClickHome 함수에 resetErrorBoundary를 넣어주어 ‘홈으로 가기’버튼을 클릭했을 때 상태를 초기화할 수 있도록 하였다.

그렇다면 처음부터 handleClickHome 함수에만 resetErrorBoundary를 넣으면, useEffect 처리도 안해줄 수 있는 것 아닌가? 생각이 들 수도 있다. 하지만 위의 useEffect로 경로 확인을 해서 리셋해주는 코드도 필요하다. 왜냐하면, 사용자가 ‘홈으로 가기’ 버튼이 아닌 브라우저 내의 뒤로가기 버튼을 클릭하여 에러 상황에서 나가려고 할수도 있기 때문이다!

에러 바운더리가 포착할 수 없는 에러

  1. 이벤트 핸들러
  2. 비동기적 코드
  3. 서버 사이드 렌더링
  4. 자식에서가 아닌 에러 경계 자체에서 발생하는 에러

Tanstack Query에서 에러 포착하게 하기

Tanstack Query는 서버와 클라이언트 사이의 비동기 로직을 다루는, 비동기 상태 관리 라이브러리이다. 따라서 위의 포착할 수 없는 에러 사례 2번에 해당하기 때문에, Error Boundary가 에러를 처리할 수 없다.

하지만 Tanstak Query의 throwOnError 옵션을 true로 지정하면, query시 발생하는 에러가 상위 컴포넌트로 던져지는 걸 보장할 수 있다.

const queryClient = new QueryClient({
	defaultOptions: {
		queries: {
			throwOnError: true,
		},
		mutations: {
			throwOnError: true,
		},
	},
});

+다음과 같이 상태에 따라 에러 바운더리를 적용할수도, 하지 않을 수도 있다.

throwOnError: (error) => {
  // 오류 Status가 400이거나 500일때만 Error Boundary 사용하도록.
  return error.status === 400 || error.status === 500;
},

참조

https://www.jbee.io/articles/react/선언적으로 에러 상황 제어하기

ErrorBoundary 가 포착할 수 없는 에러와 그 이론적 원리 분석

[react] errorBoundary

React ErrorBoundary를 사용하여 에러 처리 개선하기 (with react-query)

React Query Error Boundary

Error Boundary, React-Query와 함께 사용해보기

Error Boundaries in React - Handling Errors Gracefully | Refine

profile
개발자

0개의 댓글