[Refactoring] Chapter5. React query global 에러콜백과 Error Boundary로 컴포넌트 에러 핸들링을 하자

rlorxl·2024년 8월 28일
0

회고

목록 보기
7/7
post-thumbnail

클라이언트에서 모든 오류에 대해 일관적인 처리를 할 수 있다면 좋겠지만 실제로 모든 것을 방어할 수 있는 코드를 작성하는것은 어려운 일이다.

이전 사이드 프로젝트에서 react-query의 onError옵션과 Error Boundary로 에러에 대한 처리를 했었는데 이 때 API요청에서 발생하는 오류가 각 query의 옵션마다 다르게 지정되어 있어 이것을 한곳에서 처리하고 싶었다.


이전 프로젝트에서 에러에 대한 처리

  1. 서버측 에러 - errorboundary를 사용해 서버 에러가 발생했을 때 fallback 으로 모달을 띄워 에러메시지와 ‘재시도’, ‘취소’ 버튼을 선택할 수 있도록 함.

  2. API요청 오류 - useQuery의 onError옵션에서 처리.


요청 함수에서 발생하는 오류만이라도 제대로 관리할 수 있다면 코드 유지보수에 도움이 될 것이라고 생각해 React-query에서 제공하는 global callbacks과 Error Boundary를 이용하여 에러를 관리하도록 리팩토링을 진행해보았다.

서버요청 대신 msw모킹으로 응답을 처리하도록 변경한 상태여서 handlers에서는 무조건 에러가 반환되도록 해주고 get함수에서 try-catch구문으로 오류를 발생시켰다.

// request.ts
const axiosGet = async (url: string) => {
  try {
    const { data } = await client.get(url);
    return data;
  } catch (error) {
    throw new Error('Error code: AxiosError'); // 예시임
  }
};

defaultOptions OnError Callback

오류에 대한 처리는 최상단의 QueryClientProvider에 queryClient옵션을 지정해서 에러가 발생했을 때 각각의 에러 케이스에 따른 처리가 실행되도록 할 것이다.

queryClient에는 defaultOptions를 지정해줄 수 있는데 onError에 함수를 설정하면 사용되는 각 쿼리에 대한 글로벌 옵션이 된다.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      onError: queryErrorHandler, //  에러 발생시 실행시킬 함수
      retry: 0,
    },
  },
});

<QueryClientProvider client={queryClient}>

Error Boundary fallback설정

ErrorBoundary fallback으로 에러가 발생했을 때 보여줄 UI를 넘겨줬다.

<QueryClientProvider client={queryClient}>
  <ErrorBoundary errorFallback={<Toast />} modalFallback={<Modal />}>
    {children}
  </ErrorBoundary>
</QueryClientProvider>

에러발생시 예상 동작 과정

  1. 요청함수에서 에러 발생
  2. defaultOptions의 onError에서 에러캐치 후 모달UI등 메시지, 함수 세팅
  3. errorBoundary로 에러전파
  4. 폴백UI로 인해 화면에 오류메시지 팝업 렌더링

사실 처음엔 된줄 알았는데 componentDidCatch에서 오류를 찍어봤더니 나오지 않았다.

errorfallback이 렌더링 된것이 아니라 그냥 app에서 Modal과 Toast가 렌더링 된거였다. (이후 app에서는 Modal, Toast UI를 삭제함.)


Error Boundary가 동작하지 않는 이유

Error Boundary가 동작하지 않는 이유는 Error Boundary에서 비동기적 코드를 잡아내지 않기 때문이다.

리액트 공식문서에는 ’setTimeout 혹은 requestAnimationFrame 콜백과 같은 비동기적 코드’ 라고 설명이 되어있는데 axios를 통한 비동기 통신에서 발생하는 오류도 Error Boundary로 캐치하지 못한다는 뜻이다.

그럼 어떻게 할까?

axios오류를 Error Boundary로 캐치하고 싶다면 동기적으로 에러를 던져야 한다.

useQuery를 훅으로 분리해 컴포넌트에서 호출하고 있기에 useQuery커스텀훅 내부에서 useEffect로 에러가 발생했을 시 다시 에러를 던져주는 처리를 해주었다. 이러면 요청함수에서 발생한 오류도 Error Boundary로 캐치할 수 있다.

export const useGetProduct = ({ productId }: { productId: string }) => {
  const getProductQuery = useQuery({
    queryKey: ["product", productId],
    queryFn: async () => await apiGet.GET_ITEM(productId),
    enabled: !!productId,
  });

  const { error } = getProductQuery;

  useEffect(() => {
    if (error) {
      throw "AxiosError";
    }
  }, [error]);

  return getProductQuery;
};

여기서 해결되면 좋았겠지만 이번엔 defaultOptions의 에러콜백이 동작하지 않았다.

원인을 파악해보니 명백한 내 실수였는데 사용하는 쿼리에서도 onError옵션을 사용하고 있어서 글로벌onError는 무시된거였다.

useQuery훅의 onError옵션을 제거했더니 정상적으로 동작하는걸 볼 수 있었다.


QueryProvider

에러 콜백에서 에러 발생시 띄워줄 토스트, 모달UI를 설정하는데 설정하는 로직은 contextApi를 통해 설정되기 때문에 contextProvider보다 QueryClientProvider가 하위에 위치해 있어야했고, QueryProvider를 만들어 에러가 발생했을때의 처리가 되도록했다.

기본으로는 알 수 없는 오류가 발생했다는 토스트 팝업이 뜨고, 명확한 에러 케이스에 대해서는 에러에 해당하는 모달을 설정할 수 있다.

const QueryProvider = ({ children }: PropsWithChildren) => {
  const { setToast } = useContext(toastContext);
  const { setModal } = useModal();

  const router = useRouter();

  const { reset } = useQueryErrorResetBoundary();
  const queryErrorHandler = (error: any) => {
    console.log(error.name);

    switch (error?.name) {
      case "AxiosError":
        setModal({
          message: "게시물 정보를 가져올 수 없습니다.",
          btnText: "재시도",
          cancelFn: () => router.replace("/").then(() => router.reload()),
          submitFn: () => router.reload(),
        });
        return;
      default:
        setToast({ message: "알 수 없는 오류가 발생했습니다.\n잠시 후 다시 시도해주세요.", isError: true });
        return;
    }
  };

  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        onError: queryErrorHandler,
        retry: 0,
      },
    },
  });

  return (
    <>
      <QueryClientProvider client={queryClient}>
        <ErrorBoundary errorFallback={<Toast />} modalFallback={<Modal />}>
          {children}
        </ErrorBoundary>
      </QueryClientProvider>
    </>
  );
};

export default QueryProvider;

Error Boundary

ErrorBoundary는 state에 각 에러에 맞는 state를 추가했다. 지금은 axiosError만 추가된 상태인데

에러발생시 componentDidCatch에서 error의 경우에 따른 setState로 인해 렌더링될 UI가 달라지고 에러가 없으면 기존 컴포넌트가 렌더링된다.

class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);

    // Define a state variable to track whether is an error or not
    this.state = {
      hasError: false,
      axiosError: false,
    };
  }

  static getDerivedStateFromError(error: Error) {
    // Update state so the next render will show the fallback UI
    return { hasError: true };
  }

  componentDidCatch(error: any, errorInfo: ErrorInfo) {
    // You can use your own error logging service here
    console.log(error);

    switch (error) {
      case "AxiosError":
        this.setState({ axiosError: true });
    }
  }

  render() {
    // Check if the error is thrown
    if (this.state.hasError) {
      // You can render any custom fallback UI
      if (this.state.axiosError) return this.props.modalFallback;

      return this.props.errorFallback;
    }

    // Return children components in case of no error
    return this.props.children;
  }
}
export default ErrorBoundary;

0개의 댓글