Axios 인터셉터에서 에러 핸들링하기

복준우·2023년 4월 3일
9

이번에는 Axios 인터셉터를 사용하여 에러를 핸들링하는 방법에 대해 알아보겠습니다.

Axios 인터셉터란?

기존 벨로그 이동

에러 핸들링을 하게된 이유

네트워크 연결 상태나 서버 상태 등에 따라 요청에 실패할 가능성이 있으므로, 이러한 실패를 처리하고 예기치 않은 상황에서 앱이 종료되는 것을 방지하며, 사용자가 이해할 수 있는 에러 메시지를 제공하여 사용성을 높일 수 있기 때문이다. 또한 서버에서 발생하는 에러 메시지가 노출되는 것을 방지하여 보안성을 높이는 효과도 있으므로 핸들링에 대해 좀 더 알아보게 되었다.

에러 핸들링하기

아래의 코드는 axios 공식 문서에서 인터셉터(interceptor)를 사용하여 응답 오류를 처리하는 예제 중 하나이다.

Axios에서 에러 핸들링은 responseError 인터셉터를 사용하여 처리할 수 있다. 이 인터셉터는 HTTP 응답 상태 코드가 200 이외의 값일 때 호출된다. 예를 들어, 400, 401, 404, 500 등의 상태 코드가 있다.

  • 에러 핸들링을 위한 Axios 인터셉터 코드
axios.interceptors.response.use(
  response => response,
  error => {
    if (error.response && error.response.status === 401) {
      // 401 Unauthorized 에러 처리
    } else if (error.response && error.response.status === 404) {
      // 404 Not Found 에러 처리
    } else if (error.response && error.response.status === 500) {
      // 500 Internal Server Error 에러 처리
    } else {
      // 기타 에러 처리
    }
    return Promise.reject(error);
  }
);

위 코드에서 responseError 인터셉터는 response 성공 콜백과 error 실패 콜백을 인수로 받는다. error 객체에는 response 프로퍼티가 있으며, 이 프로퍼티를 통해 HTTP 응답 객체에 액세스할 수 있다.
예를 들어, error.response.status를 사용하여 HTTP 응답 상태 코드에 액세스할 수 있다.

리팩토링

위와 같은 코드를 작성해 에러 핸들링을 할 수 있지만, 코드가 길어지고 복잡해질 수 있으며 가독성이 떨어질 수 있어 함수로 분리해 코드의 직관성을 높이고 유지보수에 용이하게 작성해봤다.

전체코드

  • 코드보기
    import axios, {
      AxiosError,
      AxiosInstance,
      AxiosRequestConfig,
      AxiosResponse,
      InternalAxiosRequestConfig,
    } from "axios";
    
    const instance = axios.create({
      baseURL: "http://localhost:8000/api",
      timeout: 1000,
      headers: {
        "Content-Type": "application/json",
      },
    });
    
    const logOnDev = (message: string) => {
      if (process.env.NODE_ENV === "development") {
        console.log(message);
      }
    };
    
    const onError = (status: number, message: string) => {
      const error = { status, message };
      throw error;
    };
    
    const onRequest = (
      config: AxiosRequestConfig
    ): Promise<InternalAxiosRequestConfig> => {
      if (!config.data && !config.params) 
        return Promise.reject(new Error("요청 데이터가 없습니다"));
    
      const token = localStorage.getItem("token");
      const { method, url, headers = {} } = config;
    
      headers.Authorization = token ? `Bearer ${token}` : "";
    
      logOnDev(`[API] ${method?.toUpperCase()} ${url} | Request`);
    
      return Promise.resolve({ ...config, headers } as InternalAxiosRequestConfig);
    };
    
    const onErrorRequest = (error: AxiosError<AxiosRequestConfig>) => {
      switch (true) {
        case Boolean(error.config):
          console.log("에러: 요청 실패", error);
          break;
        case Boolean(error.request):
          console.log("에러: 응답 없음", error);
          break;
        default:
          console.log("에러:", error);
          break;
      }
      return Promise.reject(error);
    };
    
    const onResponse = (response: AxiosResponse): AxiosResponse => {
      const { method, url } = response.config;
      const { status } = response;
    
      logOnDev(`[API] ${method?.toUpperCase()} ${url} | Request ${status}`);
    
      return response;
    };
    
    const onErrorResponse = (error: AxiosError | Error) => {
      if (axios.isAxiosError(error)) {
        const { message } = error;
        const { method, url } = error.config as AxiosRequestConfig;
        const { status, statusText } = error.response as AxiosResponse;
    
        logOnDev(
          `[API] ${method?.toUpperCase()} ${url} | Error ${status} ${statusText} | ${message}`
        );
    
        switch (status) {
          case 400:
            onError(status, "잘못된 요청입니다.");
            break;
          case 401: {
            onError(status, "인증 실패입니다.");
            break;
          }
          case 403: {
            onError(status, "권한이 없습니다.");
            break;
          }
          case 404: {
            onError(status, "찾을 수 없는 페이지입니다.");
            break;
          }
          case 500: {
            onError(status, "서버 오류입니다.");
            break;
          }
          default: {
            onError(status, `에러가 발생했습니다. ${error.message}`);
          }
        }
      } else if (error instanceof Error && error.name === "TimeoutError") {
        logOnDev(`[API] | TimeError ${error.toString()}`);
        onError(0, "요청 시간이 초과되었습니다.");
      } else {
        logOnDev(`[API] | Error ${error.toString()}`);
        onError(0, `에러가 발생했습니다. ${error.toString()}`);
      }
    
      return Promise.reject(error);
    };
    
    const setupInterceptors = (axiosInstance: AxiosInstance): AxiosInstance => {
      axiosInstance.interceptors.request.use(onRequest, onErrorRequest);
      axiosInstance.interceptors.response.use(onResponse, onErrorResponse);
    
      return axiosInstance;
    };
    
    setupInterceptors(instance);
    
    export default instance;

logOnDev 함수

  • 개발환경에서만 실행되는 로깅 함수

개발환경에서는 일반적으로 디버깅이 필요하고, 로그를 확인해야 하는 경우가 많아서, 이 함수를 사용하면 개발환경에서만 로그를 출력할 수 있다.

const logOnDev = (message: string) => {
  if (process.env.NODE_ENV === "development") {
    console.log(message);
  }
};

함수 내부에서는 process.env.NODE_ENV'development'일 경우에만 console.log를 호출한다. 이 값은 일반적으로 개발환경에서는 'development'로 설정되고, 배포환경에서는 'production'으로 설정된다. 따라서 개발환경에서만 console.log가 실행되고, 배포환경에서는 실행되지 않는다.

onRequest 함수

  • **request요청 시 config객체를 받아와서 처리하는 함수**

함수는 AxiosRequestConfig를 입력값으로 받고, Promise를 반환한다. Promiseresolvereject메소드를 가지고 있어 비동기 작업의 성공과 실패를 알릴 수 있다.

const onRequest = (
  config: AxiosRequestConfig): Promise<InternalAxiosRequestConfig> => {
   if (!config.data && !config.params) 
        return Promise.reject(new Error("요청 데이터가 없습니다"));

  const token = localStorage.getItem("token");
  const { method, url, headers = {} } = config;

  headers.Authorization = token ? `Bearer ${token}` : "";

  logOnDev(`[API] ${method?.toUpperCase()} ${url} | Request`);

  return Promise.resolve({ ...config, headers } as InternalAxiosRequestConfig);
};
  • config객체에 data, params프로퍼티가 없을 경우, Promise.reject()메소드를 사용하여 새로운 Error객체를 생성하고, 이를 반환한다. 이는 data, params프로퍼티가 없으면 요청을 처리할 수 없기 때문이다. 예시1)
    POST 요청을 보내는 경우, 보통 요청 본문(request body)에 데이터(data)를 담아서 서버로 전송한다. 이때 요청 본문에 아무 데이터도 담기지 않은 경우, 즉 config.dataundefinednull일 경우, 서버는 요청을 처리할 수 없다. 때문에 이를 미리 확인하고 오류를 발생시킨다.
    예시2)
    GET 요청을 보내는 경우에도, 보통 URL에 쿼리 파라미터(query parameter)를 추가하여 요청을 보내는데, 이때도 config.params가 없는 경우에는 요청을 처리할 수 없기 때문에 오류를 발생시킨다.
  • localStorage에서 token값을 가져옵니다. 이 token값은 요청 헤더에 Authorization필드에 넣어서 보내야 하는데, headers객체가 없는 경우 새로 생성한다.
  • token값이 있으면 headers.Authorization"Bearer ${token}"값을 할당하고, 없으면 빈 문자열을 할당한다.
  • 마지막으로 로그를 출력하고, config객체에 headers객체를 추가하고, Promise.resolve()메소드를 사용하여 새로운 InternalAxiosRequestConfig객체를 생성하여 반환한다.

onErrorRequest 함수

**request요청 시 발생하는 에러를 처리하는 함수**

const onErrorRequest = (error: AxiosError<AxiosRequestConfig>) => {
  switch (true) {
    case Boolean(error.config):
      console.log("에러: 요청 실패", error);
      break;
    case Boolean(error.request):
      console.log("에러: 응답 없음", error);
      break;
    default:
      console.log("에러:", error);
      break;
  }
  return Promise.reject(error);
};
  • 요청이 실패하거나 요청 전에 문제가 발생한 경우 발생한다. request 프로퍼티는 XMLHttpRequest 인스턴스 또는 http.ClientRequest 인스턴스이다. 이 프로퍼티는 서버에서 응답을 받지 못한 경우 발생한다.
  • 각각의 경우에 따라 적절한 에러 메시지를 출력하고, Promise.reject() 메소드를 사용하여 error 객체를 반환한다. 이렇게 반환된 에러 객체는 호출하는 쪽에서 처리할 수 있다.

onRequest함수는 요청 시 발생하는 에러, onErrorRequest함수는 요청이 실패하거나 응답을 받지 못하는 경우 발생하는 에러를 처리한다.

onResponse 함수

  • 응답이 성공적으로 수신되었을 때 호출되는 콜백 함수

onResponse 함수에서는 **config** 프로퍼티를 이용하여 요청에 대한 정보를 추출하고, **status** 프로퍼티를 이용하여 응답 상태 코드를 추출한다. 이 정보를 로그에 출력하여 개발자가 요청과 응답을 신속하게 파악할 수 있도록 한다.

const onResponse = (response: AxiosResponse): AxiosResponse => {
  const { method, url } = response.config;
  const { status } = response;

  logOnDev(`[API] ${method?.toUpperCase()} ${url} | Request ${status}`);

  return response;
};

AxiosResponse 객체는 Axios가 반환하는 HTTP 응답에 대한 정보를 담고 있다. 이 객체는 다음과 같은 프로퍼티를 가지고 있다.

  • data: 서버에서 반환한 데이터
  • status: HTTP 응답 상태 코드
  • statusText: HTTP 응답 상태 텍스트
  • headers: 응답 헤더
  • config: AxiosRequestConfig 객체

onErrorResponse 함수

  • 응답이 실패한 API 요청에 대한 로깅 및 에러 코드 처리를 담당하는 함수

AxiosError 객체 안에 있는 configresponse프로퍼티를 이용해 요청한 API의 메서드, URL, 상태 코드 등의 정보를 추출하고, 이 정보를 기반으로 에러 코드를 처리한다.
switch문을 이용해 에러 코드를 분기하여 각각의 에러에 대한 처리를 할 수 있다.

const onErrorResponse = (error: AxiosError | Error) => {
  if (axios.isAxiosError(error)) {
    const { message } = error;
    const { method, url } = error.config as AxiosRequestConfig;
    const { status, statusText } = error.response as AxiosResponse;

    logOnDev(
      `[API] ${method?.toUpperCase()} ${url} | Error ${status} ${statusText} | ${message}`
    );

    switch (status) {
      case 400:
        onError(status, "잘못된 요청입니다.");
        break;
      case 401: {
        onError(status, "인증 실패입니다.");
        break;
      }
      case 403: {
        onError(status, "권한이 없습니다.");
        break;
      }
      case 404: {
        onError(status, "찾을 수 없는 페이지입니다.");
        break;
      }
      case 500: {
        onError(status, "서버 오류입니다.");
        break;
      }
      default: {
        onError(status, `에러가 발생했습니다. ${error.message}`);
      }
    }
  } else if (error instanceof Error && error.name === "TimeoutError") {
    logOnDev(`[API] | TimeError ${error.toString()}`);
    onError(0, "요청 시간이 초과되었습니다.");
  } else {
    logOnDev(`[API] | Error ${error.toString()}`);
    onError(0, `에러가 발생했습니다. ${error.toString()}`);
  }

  return Promise.reject(error);
};
  • switch (status) 서버로부터 받은 HTTP 응답 상태 코드(status)에 따라서 onError 함수를 호출하는 로직을 작성하고 있다.
    HTTP 응답 상태 코드에 따라서 에러 메시지를 다르게 처리함으로써, 어떤 종류의 에러가 발생했는지 쉽게 파악할 수 있다.
  • else if (error instanceof Error && error.name === "TimeoutError") Axios 인스턴스에서 timeout 값을 설정하면, 해당 시간 동안 응답을 받지 못하면 자동으로 TimeoutError가 발생한다. TimeoutError가 발생한 경우, 이를 로깅하고, onError() 함수를 이용하여 에러 메시지를 전달한다.

onError 함수

  • API 요청이 실패한 경우에 호출되는 함수

매개변수로 받은 상태 코드와 메시지를 객체로 묶은 후, throw를 통해 예외를 발생시킨다. 해당 실패 이유와 함께 예외를 발생시킴으로써 호출자에게 실패한 이유를 알리고, 실패한 요청을 재시도하거나, 그에 대한 대처를 취할 수 있도록 도와준다.

  1. 매개변수로 받은 상태 코드(status)와 메시지(message)를 객체로 묶는다.
  2. 이후 묶인 객체를 throw를 통해 예외를 발생시킨다.
const onError = (status: number, message: string) => {
  const error = { status, message };
  throw error;
};

// 예를 들어, 에러 메시지가 "인증 실패입니다."인 경우, 
// 이 함수는 { status: 401, message: "인증 실패입니다." } 객체를 묶어 throw를 통해 예외를 발생

에러를 캐치하는 예시코드

axios.get("https://example.com/api/data")
  .then(response => {
    console.log(response.data);
  })
  .catch(error => {
		// 에러를 캐치하는 예시 코드
    console.error('err', err);
  });

Promise.reject()와 throw의 차이점

  • Promise.reject() Promise.reject()는 일종의 실패 상황을 나타내는 프로미스 객체를 반환한다. 이것은 명시적으로 "거절" 상태를 만든다. 이 경우, then() 또는 catch() 메서드 중 하나를 사용하여 프로미스를 처리할 수 있다. /co
  • throw throw는 예외를 발생시킨다. 이 경우, 예외가 발생한 지점에서 프로그램이 멈추고, try-catch 문 등을 사용하여 예외를 처리해야 한다. 즉, throw는 코드 흐름을 중단시키는 예외를 발생시키는 것이며, 해당 예외를 캐치하지 않으면 프로그램이 종료된다.

Promise.reject()는 프로미스 체인의 일부로 예외 처리를 수행할 때 사용되며, throw는 일반적으로 try-catch 구문에서 예외 처리를 수행할 때 사용됩니다.

setupInterceptors 함수

  • 인터셉터를 설정한 후에 Axios 인스턴스를 반환하는 함수

함수들은 각각 요청(request)과 응답(response)이 발생했을 때 수행되어야 하는 로직을 담고 있다.
instance객체를 활용하여 API 요청을 보낼 때, 등록한 인터셉터에 따라 요청과 응답이 처리 된다.

const setupInterceptors = (axiosInstance: AxiosInstance): AxiosInstance => {
  axiosInstance.interceptors.request.use(onRequest, onErrorRequest);
  axiosInstance.interceptors.response.use(onResponse, onErrorResponse);

  return axiosInstance;
};

setupInterceptors(instance);

결론

responseError 인터셉터를 사용하여 HTTP 응답 상태 코드에 따라 에러를 처리할 수 있다.
이를 통해, request를 보내기 전에 요청 데이터의 존재 여부를 확인하고, 토큰을 header에 추가하는 작업을 할 수 있다. 또한, response를 받아올 때 로깅을 하여 개발 시 디버깅에 도움이 되며, 에러 발생 시 적절한 메시지와 함께 에러 핸들링이 가능하다.
이러한 interceptor 설정은 코드의 중복을 방지하고, 코드의 가독성과 유지 보수성을 높이는 효과를 가져온다.
또한, AxiosInstance를 이용하여 인터셉터를 설정하므로, 인터셉터가 적용된 AxiosInstance를 생성하여 모듈화하여 재사용성을 높이는 것이 가능하다.

참고자료

profile
사람들에게 하나의 문화를 선물해줄 수 있는 프로그램을 개발하고 싶습니다.

1개의 댓글

comment-user-thumbnail
2023년 12월 17일

안녕하세요 프론트엔드 개발자로 현재 재직중인 주니어 개발자입니다!
지나가다가 우연히 포스팅을 보게 되었어요! 질문이 있어 이렇게 댓글로 남깁니다!
onRequest 함수에서 get 요청을 하게 될 때 params를 안 사용하는 경우도 있고, data가 안 들어가는 경우도 있어 이 예제에 대해서는 에러가 발생할 수 있는데 따로 예외를 두고 하신 부분은 아니고, 프로젝트에 맞춰 진행을 하신게 맞을까요?

답글 달기