프로젝트를 진행하면서 클라이언트에서 에러 핸들링을 어떻게 처리해줘야 할 지 고민이 참 많았다. 특히나 우리 서비스의 경우 클라이언트단에서 나는 에러는 거의 없고 (유저 인풋이 없기때문) 모두다 api 에러였다. 또한 data fetching 라이브러리로 react-query 를 쓰고 있었기 때문에, 어떻게하면 react-query 로 우아하게 api 에러를 핸들링 할 수 있을지 고민 많이 했던 것 같다.

그러다가 요 아티클을 발견했고, react-query 의 옵션을 사용한다면 아주 간단단하게 전역적으로 api 에러를 catch 할 수 있다는 것을 알게 되었다.

요구사항과 문제점

  • 프로젝트 요구사항은, api 에러가 나면 올바른 에러메시지를 Snackbar 로 띄워주는 것 이었다.
  • 우리 프로젝트에서는 아래와 같은 useSnackbar 커스텀 훅을 사용 중이다.
import { useSetRecoilState } from 'recoil';
import { snackbarState } from '@src/@atoms';

type openHandler = (message: string) => void;

interface UseSnackbarResult {
  openSuccessSnackbar: openHandler;
  openFailureSnackbar: openHandler;
}

function useSnackbar(): UseSnackbarResult {
  const setState = useSetRecoilState(snackbarState);

  const openSuccessSnackbar = (message: string) => {
    setState({
      isOpened: true,
      message,
      status: 'SUCCESS',
    });
  };

  const openFailureSnackbar = (message: string) => {
    setState({
      isOpened: true,
      message,
      status: 'FAIL',
    });
  };
  return { openSuccessSnackbar, openFailureSnackbar };
}

export default useSnackbar;

원래의 에러처리 전략 대로라면 아래와 같이 처리해줬다.


function Page(){

   const [isLoading, isError..] = useQuery(//..);
   const { openFailureSnackbar } = useSnackbar();

   return {
      if(isError) openFailureSnackbar('서버에러 발생!');
	     //... 생략
   }
}

즉 매 페이지마다 api 요청의 isError 여부를 받아서 렌더링 할 때 if 문으로 분기처리를 통해서 스낵바를 띄워주게 된다.

근데 우리 프로젝트에서 api 에러 처리는 모두 snackbar 를 띄워주는걸로 통일하기로 했다. 즉, 에러 처리 - 스낵바 오픈횡단 관심사 가 되는 것이다. 그런데 위와 같은 처리 방법이라면 모든 api 호출부마다 isError ~ 여부를 판단해서 스낵바를 띄워주는 로직을 반복해야 한다. Api 호출부가 n 개 이면 n 개의 똑같은 코드가 반복되어버리는 것이다. 이러한 중복 문제를 해결하고 싶었다.

따라서 React-Query 에서 전역적으로 에러를 핸들링할 방법을 찾아보자

준비하기

자 먼저 전역적으로 에러를 처리하기 전에 준비해둬야 할 것이 있다. 바로 서버 api 응답에 돌아올 에러코드의 종류와 내용을 프론트엔드 개발자가 알아야 한다.

그래서 나는 바로 백엔드 팀원들에게 커스텀한 에러 코드를 문서화하자고 제안했다.

여기서 잠깐 백엔드에서 날아오는 에러 메시지를 그대로 보여줄 것인가, 프론트에서 한번 바꿔서 보여줄 것인가에 대한 토론이 있었다. 나의 경우, 프론트에서 한번 바꿔서 보여주기 로 의견을 냈다.

왜냐하면,

  1. Api 에러 메시지 내용을 사용자가 자세히 알 필요 없다. 때로는 두루뭉술한 말로 숨겨야하는 api 에러도 있다.
  2. Api 에러 메시지 역시, 사용자에게 보여지는 영역이기 때문에 프론트엔드에서 관리하는게 맞다.

그래서, 백엔드 측에 프론트엔드로 보내줄 에러 메시지가 아니라, 에러 코드를 정하자고 했고 다행히 다른 팀원들도 동의했다.

5

결과물은 위와 같다.

해결하기

어떻게 react-query 에서 전역적으로 api 에러를 catch 할 것인가

queryClient.setDefaultOptions({
  queries: {
    refetchOnWindowFocus: false,
    onError: handleError,
    retry: 0,
  },
  mutations: {
    onError: handleError,
  },
});
  • 리액트 쿼리의 디폴트 옵션에 onError 가 있다. 이 부분에 에러 핸들링 로직을 주입하면 된다.
  • 각각 쿼리 사용 로직의 옵션과, 뮤테이션의 로직 옵션을 따로 주입해줘야 한다.

그러면 handleError 는 어떻게 생겼을까 ?

에러처리 커스텀 훅

나는 useApiError 라는 커스텀 훅을 만들었다. 내부에서 useSnackbar 를 사용해야 하기 때문에 어쩔 수 없이 훅으로 작성해야 했다.

import { useNavigate } from 'react-router-dom';
import useSnackbar from '@src/hooks/useSnackbar';
import { ERROR_CODE } from '@src/@constants/api';
import { API_ERROR_MESSAGE, MESSAGE } from '@src/@constants/message';
import { PATH_NAME } from '@src/@constants/path';
import { CustomError } from '@src/@types/shared';

interface UseApiErrorResult {
  handleError: (error: any) => void;
}

function useApiError(): UseApiErrorResult {
  const navigate = useNavigate();
  const { openFailureSnackbar } = useSnackbar();

  const handleError = (error: CustomError) => {
    const errorCode = error.response?.data?.code; // 서버에서 받아온 에러 코드를 가져온다.
    if (errorCode === ERROR_CODE.INVALID_TOKEN) return;
    // 에러코드가 invalid Token 이라면 Private Router 에서 처리하도록 에러 처리를 종료한다.

    const errorMessage = API_ERROR_MESSAGE[errorCode] ?? MESSAGE.DEFAULT_SERVER_ERROR;
    // 프론트엔드에서 코드에 따른 에러메시지를 가져온다.
    // 문서화 되지 않은 코드라면 디폴트 메시지를 보여준다.

    openFailureSnackbar(errorMessage); // 스낵바를 띄워준다.

    // 에러 사항이 특수한 경우 - 구독 채널이 없을 경우
    if (errorCode === ERROR_CODE.SUBSCRIPTION_NOT_FOUND) {
      navigate(PATH_NAME.ADD_CHANNEL); // 채널 구독 페이지로 이동시켜준다.
      return;
    }
  };
  return { handleError };
}

export default useApiError;

이러면 앞으로 에러 처리 로직이 추가 될 때, 해당 api 호출부에 가서, 분기 처리를 해줄 필요 없이 useApiError 훅으로 들어가서, handleError 로직만 수정해주면 된다.

에러바운더리 사용하지 않은 이유

리액트 쿼리를 사용 했을 때, 전역적으로 api 에러를 잡는 법은 크게 두가지가 있을 것 같다. 내가 사용한 onError 에 에러핸들링 함수를 주입하는 것과, 에러바운더리를 사용하는 것.

하지만 요구사항이 snackbar 를 띄우는 것이었고, 따라서 hook 을 사용해야 했기에 class 형 컴포넌트로 구현하는 에러바운더리를 사용하기에는 무리가 있었다. 물론 HOC 를 사용하면 에러바운더리에서도 hook 을 사용 할 수 있겠지만, 굳이 다른 페이지 UI 를 보여줘야하는 에러 처리 요구사항이 없었기 때문에 간결하게 디폴트 onError 옵션에 함수를 주입해주는 방법을 사용했다.

하지만, api 에러에 따라서 페이지를 달리 보여줘야한다면 에러바운더리를 사용 할 듯 싶다. 결국 요구사항에 따라서 구현 방법이 달라질 것 같다.

끗!

참고로 지금은 코드가 많이 바뀌었지만, 당시에 작성한 pr 링크 도 첨부한다.