React

[React] react-query 에러 핸들링

mygumi 2024. 9. 29. 14:40
반응형
이 글은 react-query 의 에러 핸들링에 대해 알아본다.
API 요청에서 4xx, 5xx 와 같은 상태코드를 받는 경우라고 이해하면 된다.
react-query - https://github.com/tanstack/query

 

이번 주제는 코드를 통해 진행하게된다.

해당 코드는 특정 라이브러리들을 기준으로 진행된다.

사용하는 라이브러리의 기본 지식은 필요하지만, 모르더라도 에러 핸들링의 전체적인 흐름만 이해해도 좋을 것이라고 생각하고 있다.

 


 

우리는 API 에러 응답에 대해서 핸들링 해야한다.

기본적으로 HTTP status code 를 4xx, 5xx 응답한다면, 에러라고 받아들이고 있다.

 

API 가 에러 응답을 한다면, 클라이언트는 무엇을 해야하는가?

에러의 경우에는 이미 예상된 에러와 예상치 못한 에러로 구분될 수 있다.

 

  • 예상된 에러
  • 예상치 못한 에러

 

우리는 이 2가지를 모두 대응해야한다.

모든 에러는 사용자 입장에서는 에러 상황을 어떻게 안내할지 대응해야한다.

 

https://www.kodingmadesimple.com/2015/09/codeigniter-modal-contact-form-jquery-ajax-validation-example.html
https://www.mkubdev.xyz/blog/nextjs/create-custom-error-page
https://www.tutorialrepublic.com/snippets/preview.php?topic=bootstrap&file=elegant-error-modal

 

 

얼럿을 노출하거나 문구를 안내, 별도 안내 페이지 등 다양한 방식으로 처리될 수 있다.

예상치 못한 에러의 경우에는 이것을 시스템에서 탐지하여 내부 알림을 받아 인지할 수 있도록 해야한다.

 

이 글에서 말하고자하는 "에러 핸들링" 이라는 것은 위와 같은 상황들을 대응하는 로직을 말한다.

react-query 에서 에러 핸들링 하는 법을 한번 알아보자.

 

react-query 는 상태 관리를 도와주는 라이브러리일뿐, HTTP 통신에 대해 직접적인 관여는 하지 않는다.

즉, HTTP 통신은 axios, ajax, fetch 와 같은 모듈에서 에러에 대한 상황을 받아들이고 있다.

react-query 로직 이전에 위와 같은 모듈에서 에러를 핸들링하는 것이 먼저이다.

 

API 에러 응답 구조는 다음과 같은 구조로 내려온다고 가정한다.

 

{
  code: "INVALID_PARAMETER",
  message: "잘못된 파라미터입니다."
}

 

 

code 의 경우에는 에러 구분을 위한 값을 의미한다.

그렇기에, code 의 값은 다양하게 존재할 것이고, 이러한 값들은 모두 클라이언트에서 관리되고있을 것이다.

code 값이 협의된 것이 위에서 언급한 "예상된 에러" 이고, 그 외는 "예상치 못한 에러" 라고 볼 수 있다.

 

우선 협의된 code 를 관리하고 해당 에러를 위한 에러 객체를 위한, "예상된 에러" 를 대응하기 위한 코드를 작성한다.

 

// ServerError.ts
export const ServerErrorCodeConst = [
  'INVALID_PARAMETER',
  'UNKNOWN',
] as const;

export type ServerErrorCode = (typeof ServerErrorCodeConst)[number];

export default class ServerError extends Error {
  code: ServerErrorCode;

  constructor(code: ServerErrorCode, message?: string) {
    super(message);
    this.code = code;
  }
}

 

 

그리고 에러 발생시 협의되지 않은 code, "예상치 못한 에러" 를 대응하기 위한 코드를 작성한다.

 

// errorService.ts
export interface ErrorResponse {
  code: ServerErrorCode;
  message: string;
}

class ErrorService {
  static generateServerError(axiosError: AxiosError<ErrorResponse>) {
    const error = axiosError?.response?.data;

    if (!error) {
      return new ServerError('UNKNOWN');
    }

    let code = ServerErrorCodeConst.includes(error.code)
      ? error.code
      : 'UNKNOWN';

    return new ServerError(code, error.message);
  }
}

 

axios 에서 발생한 에러 객체의 code 값이 code 관리 리스트(ServerErrorCodeConst)에 없다면, 이를 인지하기 위한 별도 "UNKNOWN" 코드로 지정해주고 있다.

 

위 코드들은 실질적으로 HTTP 통신에서 에러가 발생하는 지점에서 활용된다.

 

// apiService.ts
const apiService = axios.create();

apiService.interceptors.response.use(
  (response) => {
    return response;
  },
  (error) => {
    const exception = ErrorService.generateServerError(error);

    return Promise.reject(exception);
  }
);

 

axios 에러 콜백 함수에서 받아온 error 를 generateServerError 함수를 통해 한번 가공되어 반환된다.

서버의 응답 그대로 사용하지 않고, 가공 로직에 의해 에러 객체는 모두 핸들링 범위 안에 들어오게 된다.

Promise.reject(exception) 에 의해 이제 react-query 범위로 넘어가서 핸들링하게 된다.

 

react-query 의 에러 핸들링을 확인해보자.

에러 발생시 2가지 콜백 함수에서 에러 처리를 할 수 있다.

 

  • onError
  • throwOnError

2가지 모두 쿼리 요청에 대해 에러 발생시 호출된다. (suspense 값에 따라 결과는 상이할 수 있다. 해당 내용은 여기서 다루지 않는다.)

onError 는 기본적인 에러 콜백 함수라고 인지하면 된다.

throwOnError 는 ErrorBoundary 로 에러를 throw 할지말지를 결정할 수 있다.

 

const App = () => {
  const queryClient = new QueryClient()
  return (
    <QueryClientProvider client={queryClient}>
      <ErrorBoundary fallbackComponent={ErrorComponent}>
        <Page />
      </ErrorBoundary>
    </QueryClientProvider>
  )
}

 

2가지를 쉽게 UI/UX 적으로는 바라보면 다음과 같다.

onError 의 경우에는 현재 화면을 유지한채 에러 안내 문구나 얼럿 같은 기능을 활용할 수 있다.
throwOnError 는 ErrorBoundary 로 랜더링되기에, 화면이 극단적으로 변경되는 것이다.

 

예를 들어, GET 요청이 아닌 경우에서 발생되는 에러는 대부분 예상된 에러이다.

 

  • 회원가입에서 아이디가 중복된 경우 (code = ALREADY_REGISTERED)
  • 허용되지 않은 특수 문자를 보내는 경우 (code = SEARCH_INVALID)
  • 금지된 검색어를 입력한 경우 (code = SEARCH_PROHIBITED)

이러한 경우들에서는 화면의 변경되는 것은 없고 사용자에게 안내를 위해 얼럿창을 보여주게 된다.

 

반대로, 의도적으로 더 명확한 내용을 전달하기 위해서 화면 변경이 필요한 경우도 존재한다.

  • 서버의 시스템 에러가 발생하여 정상적인 동작을 보장할 수 없는 경우 (code = INTERNAL_ERROR)
  • 사용자가 해당 기능을 사용할 수 없는 경우 (code = USER_SUSPENDED)

 

이를 기반으로 react-query 의 에러에 대한 옵션을 설정해본다.

 

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      throwOnError: (error) => {
        if (error.code === 'NETWORK_ERROR') { return true; }
        return false
      },
    },
    mutations: {
      throwOnError: (error) => {
        if (['INTERNAL_ERROR', 'USER_SUSPENDED'].include(error.code)) { return true; }
        return false;
      },
      onError: (error) => {
        if (["ALREADY_REGISTERED","SEARCH_PROHIBITED","SEARCH_INVALID"].include(error.code)) { 
          alert(error.message); 
        }
        logger.error(error)
        // ...
      },
    },
  },
  queryCache: new QueryCache({
    onError: (error) => {
      logger.error(error)
      // ...
    },
  }),
});

 

위 설정에 대해 요약하면 다음과 같다.

 

queries: { throwOnError: ... }
=> useQuery 에 의해 발생한 에러는 code 값이 NETWORK_ERROR 인 경우에만 ErrorBoundary 로 넘긴다.

mutations: { throwOnError: ... }
=> useMutation 에 의해 발생한 에러는 code 값이 INTERNAL_ERROR, USER_SUSPENDED 인 경우에는 ErrorBoundary 로 넘긴다.

mutations: { OnError: ... }
=> useMutation 에 의해 발생한 에러는 code 값이 ALREADY_REGISTERED, SEARCH_PROHIBITED, SEARCH_INVALID 인 경우에는 alert 창을 띄우고, 반대인 경우에는 에러 로그를 보낸다.

queryCahce: { onError: ... }
=> useQuery 에 의해 발생한 에러 콜백 함수

 

일부 코드중 logger.error 의 의도는 알림 및 로그를 남길 필요가 있는 에러의 경우를 위함이다.

 

onError 는 특정 code 들을 얼럿을 통해 핸들링하고 있고, 추가적으로 필요한 code 들을 추가할 수 있다.

throwOnError 는 특정 code 들을  ErrorBoundary 로 던져 에러 핸들링을 위임한다.

결과적으로 넘어간 에러는 ErroryBoundary 에 의해 각각의 code 를 위한 컴포넌트로 노출하게 된다.

 

type ErrorComponentProps = {
  error: unknown;
};
const ErrorComponent = ({ error }: ErrorComponentProps) => {
  const renderComponent = useCallback(() => {
    switch (error.code) {
      case 'SEARCH_SUSPENDED':
        return <SuspendedError />;
      case 'INTERNAL_ERROR':
        return <InternalError />;
      default:
        return <UnknownError />;
    }
  }, [error]);

  return (
    <>
      <h2>Error Component</h2>
      {renderComponent()}
    </>
  );
};

 

위 설정들은 react-query 요청의 에러에 대해 기본 옵션으로 설정하였다.

즉, react-query 에 의한 모든 요청은 기본 옵션을 따르게 된다.

반대로 기본 옵션을 따르지 않아야하는 경우가 생겨난다면, 기본 옵션을 따르지 않도록 하면 된다.

 

const ComponentA = () => {
  const { mutate } = useMutation({
    mutationFn: () => { ... },
    onError: (error) => {
      if (error.code === 'SEARCH_CONFIRM') {
        confirm('I am confirm!');
      }
    },
  });

  return (
    <>...</>
  )
}

 

 

위처럼 각 사용처에서 기본으로 설정되어있는 onError, throwOnError 를 오버라이딩해서 사용하면 된다.

 


 

서비스마다 요구사항이 다르고, 처해진 개발 환경도 다를 것이다.

  • 여러 API 엔드포인트를 사용하고, 엔드포인트마다 에러 응답 구조가 다를 수 있는 경우
  • 비동기 요청은 react-query 뿐만 아니라, axios 로 직접 요청, 소켓 통신 등이 공존하는 경우

 

위와 같은 상황들도 전체적인 흐름을 이해하면, 이것들을 기반으로 더 많은 확장성을 충분히 보장할 수 있을 것이다.

 

전체적인 흐름 설명을 위해 최소화된 코드로 진행하였다.

아래 예제 코드는 언급된 내용과 코드를 기반으로 조금 더 심화된 예제 코드이니 살펴보면 좋을 것이다.

 

반응형