최근 다독다독 프로젝트를 진행하면서 Axios 에러 처리와 관련된 고민을 많이 했었다. 타입스크립트에 익숙치 않았을 뿐더러 커스텀 에러 코드를 사용하면서 구글링도 쉽지 않았는데..🥹 고민의 과정을 적어보려고 한다.

Custom Error Code

API 요청이 실패한 경우, 다독다독 서버는 Error Response body에 아래와 같이 사전에 정의된 에러 코드를 함께 보내주고 있다. 클라이언트 측에서는 에러 코드에 따라 필요한 핸들링을 하면 된다.

error_code_docs_image

커스텀 에러 코드들은 아래와 같이 string 리터럴 타입으로 미리 선언해 두었다.

type ServiceErrorCode =
  | 'CM1'
  | 'CM2'
  ...
  | '9798';

서버에서 에러에 대한 설명이 담긴 메세지도 보내주지만, 어떤 처리 없이 바로 사용하기에는 무리가 있어 보였다. 그래서 클라이언트 측에서 사용할 에러 메세지도 별도로 관리하면 좋을 것 같았다.

const ERROR_MESSAGE: Record<ServiceErrorCode, string> = {
  U1: '이미 존재하는 닉네임이에요.',
  A3: '유효한 리프레시 토큰이 아니에요. 다시 로그인 해 주세요.',
  A4: '액세스 토큰이 만료되었어요. 새로운 액세스 토큰 발급 요청을 보내주세요.',
  ...
};

필요한 것들은 어느정도 정의한 것 같으니.. 이제 응답에서 에러 코드만 잘 뽑아서 사용하면 될 것 같은데..!?!

it's me..

역시 타입스크립트는 마음처럼 쉽게 되지 않았다..^_^..

AxiosError 타입 가드

AxiosResponse에 담긴 에러 코드에 안전하게 접근하려면 먼저 해당 에러가 AxiosError 임이 보장되어야 했다. 자바스크립트에서는 어떤 값이든 에러로 throw 할 수 있기 때문에 catch를 통해 전달받는 Error 객체는 기본적으로 unknown 타입이다.

Axios 깃헙에서 AxiosError 타입 가드와 관련된 내용을 발견할 수 있었다. isAxiosError() 라는 함수를 제공하는데, 조건문을 통과하면 error 객체는 AxiosError로 타입이 추론된다!

try {
  const { data } = await axios.get('/user/1');
} catch (error) {
  if (axios.isAxiosError(error)) {
    console.log(error.response);
  }
}

서버로부터 받은 응답 body는 error.response.data에 담겨있는데, response는 옵셔널한 속성이라 조건을 추가해야만 data를 보장받을 수 있었다.

if (axios.isAxiosError(error) && error.response) {
  const { code } = error.response.data;
}

ErrorResponseType 정의

여기까지 하면 codeany 타입이다. 미리 선언했던 SERVICE_ERROR_CODE 타입으로 좁혀야 한다. isAxiosError()의 타입 선언을 살펴보니 제네릭을 받고 있다!

export function isAxiosError<T = any, D = any>(payload: any): payload is AxiosError<T, D>;

제네릭 T가 어디로 전달되는지 따라가보니, AxiosError를 거쳐 AxiosResponse의 data 타입으로 지정된다.

export class AxiosError<T = unknown, D = any> extends Error {
  constructor(
      message?: string,
      code?: string,
      config?: InternalAxiosRequestConfig<D>,
      request?: any,
      response?: AxiosResponse<T, D>
  );
  ...
}
export interface AxiosResponse<T = any, D = any> {
  data: T;
  ...
}

아래처럼 응답 스키마를 타입으로 정의하고 제네릭으로 넘겨서 code의 타입을 보장받을 수 있었다.

type APIErrorResponse = {
  code: ServiceErrorCode;
  status: HttpStatusCode;
  ...
};

code의 타입이 ServiceErrorCode 니까 ERROR_MESSAGE 객체에 키 값으로 넣을 수도 있게 되었다!

if (axios.isAxiosError<APIErrorResponse>(error) && error.response) {
  const { code } = error.response.data;
  const message = ERROR_MESSAGE[code];
}

사용자 정의 타입 가드

타입적으로는 해결된 것 같은데.. 문득 핸들링되지 않은 서버 에러는 응답에 에러 코드가 포함되지 않을테니, 의도치 않은 런타임 에러가 발생할 수도 있겠다는 생각이 들었다. isAxiosError()처럼 응답에 에러 코드가 포함되어 있는지 확인하고 타입적으로도 보장하는 타입 가드 함수를 만들면 좋을 것 같았다.

const isAxiosErrorWithCustomCode = (
  error: unknown,
): error is RequiredWith<AxiosError<APIErrorResponse>, 'response'> => {
  return (
    typeof error === 'object' &&
    error !== null &&
    isAxiosError<APIErrorResponse>(error) &&
    !!error.response &&
    !!error.response.data.code &&
    error.response.data.code in ERROR_MESSAGE
  );
};

AxiosError 객체에서 response 속성만 Required로 바뀐 타입이 필요했는데, 새롭게 정의하는 것 보다 기존 AxiosError 객체를 재사용하면 좋을 것 같아 RequiredWith 라는 유틸리티 타입을 만들었다.

export type RequiredWith<T, K extends keyof T> = T &
  {
    [P in K]-?: T[P];
  };

옵셔널한 속성들이 많아서 조건문 뎁스가 계속 깊어졌었는데, 이렇게 함수로 추출하니 코드가 훨씬 깔끔해졌다!

const handleAxiosError = (error: unknown) => {
  if (!isAxiosErrorWithCustomCode(error)) {
    showToast({ message: '잠시 후 다시 시도해주세요.' });
    return;
  }

  const { code } = error.response.data;
  const message = ERROR_MESSAGE[code];

  // 에러 코드에 따라 원하는 로직을 수행하면 된다.
  showToast({ message, duration: 3000 });
};

마무리

뚝딱뚝딱 한 것 같지만.. 가독성 좋은 코드를 위해 고민을 많이 하다보니 팀원들에게 미안할 정도로 오랫동안 붙잡고 있었다..ㅠ.ㅠ 하지만 그만큼 타입스크립트와 가까워질 수 있었던 시간이었다.

여전히 고민되는 부분이 많이 있긴 하다. 백엔드에서 보내주는 에러 코드 자체가 직관적이지 않아 코드를 작성할 때 여러번 확인해야하는 불편함도 있고, 커스텀 에러 클래스들을 만들어서 어느정도 체계화된 에러 핸들링 방식을 구축해두고 싶은 욕심도 있다.

하지만 뭐든 처음부터 완벽하게 하기란 쉽지 않은 것 같다. 당장 이 코드를 작성하기까지도 꽤 오랜 시간이 걸렸으니.. 아직 공부할게 많구나~!! 😆

참고


더 좋은 방법, 개선점이 있다면 댓글로 알려주세요! 🙇🏻‍♀️