에러 처리 개선 과정

유의진·2025년 5월 3일

찍찍이

목록 보기
7/7
post-thumbnail

에러 처리에 대한 문제점 인식

최근 포트폴리오 기반의 면접을 진행하던 중 에러 처리 방식의 확장성과 공통화 여부에 대한 질문을 받았습니다.
그에 대한 대답을 하며, 제가 구현한 API 요청 로직에서 개선할 여지가 많다는 점을 인식하게 되었습니다.

기존 API 구조 분석

첫 번째로 현재 구조는 다소 분산되어 있었습니다.
이전에 리펙토링했던 대로 각각의 역할을 가진 4개의 API 함수로 구성되어 있습니다.

GET 요청을 담당하는 함수는 다음과 같이 작성되었습니다.

export async function GET<T>(url: string): Promise<T> {
  try {
    const response = await axiosInstance.get(url);
    return response.data;
  } catch (error) {
    throw new Error(error instanceof Error ? error.message : String(error));
  }
}

POST 요청을 담당하는 함수는 다음과 같이 작성되었습니다.

export async function POST<T, U>(url: string, data?: U): Promise<T> {
  try {
    const response = await axiosInstance.post(url, data);
    return response.data;
  } catch (error) {
    throw new Error(error instanceof Error ? error.message : String(error));
  }
}

각 메서드는 네트워크 요청, 응답 데이터 반환, 에러 처리 등 거의 동일한 흐름을 갖고 있었으며 이로 인해 같은 패턴의 코드가 여러 파일에 중복되어 유지보수가 어렵고 에러 처리 기준을 일관되게 적용하기 힘들었습니다.


두 번째로 에러 처리 방식이 너무 단순해서 개선할 필요성을 느꼈습니다.

기존에는 에러의 상태나 종류를 구분하지 않고 발생한 에러를 그대로 다시 던지는 방식이었습니다.

그렇게 되면 GET요청처럼 useQuery를 사용하는 경우 UI 상에서 에러 메시지를 처리하거나 사용자에게 알리는 로직이 빠져있었습니다. 특히 Tanstack Query v5 에서는 에러 처리를 QueryClient의 공통 설정으로 이동하는 방식이 권장되기 때문에 기존 구조로는 사용자에게 적절한 피드백이 어려웠습니다.


이러한 두 가지의 문제를 해결하기 위해 개선하였습니다.

개선 과정

API 함수 통합

중복을 제거하고 일관된 에러 처리와 확장 가능한 구조를 만들기 위해 모든 HTTP 요청을 처리할 수 있는 공통 request함수로 통합하였습니다.

async function request<T>({
  method,
  url,
  data,
  token,
}: RequestOptionsProps): Promise<T> {
  try {
    if (token) {
      setAuthToken(token);
    }

    const response = await axiosInstance.request<T>({
      method,
      url,
      data,
    });

    return response.data;
  } catch (error) {
    handleHttpError(error);
    throw error;
  }
}

여기서 tokenGET 요청을 서버사이드에서 사용할 때 필요하기 때문에 포함시켰습니다.

위에서 구현한 request를 간편하게 사용하기 위해서 역할별로 객체로 나누어 정의했습니다.

export const API = {
  get: <T>(url: string, token?: string) =>
    request<T>({ method: 'GET', url, token }),
  post: <T, U>(url: string, data?: U) =>
    request<T>({ method: 'POST', url, data }),
  put: <T, U>(url: string, data: U) => request<T>({ method: 'PUT', url, data }),
  delete: <T>(url: string) => request<T>({ method: 'DELETE', url }),
};

이로서 기존에 사용하던 API 함수들과 사용방식은 거의 동일하지만 내부적으로는 공통 request함수로 로직이 통합되어 있어 유지보수성과 재사용성이 크게 향상되었습니다.

공통 에러 처리 함수

handleHttpError라는 함수를 만들어 에러가 발생했을 때 공통적으로 에러를 처리할 수 있도록 했습니다.

import { AxiosError } from 'axios';

import { notify } from '@/store/useToastStore';

export function handleHttpError(error: unknown) {
  if (!(error instanceof AxiosError)) {
    notify('error', '에러가 발생했습니다.', 3000);
    return;
  }

  const status = error.response?.status;
  const message = error.response?.data?.message || '';

  switch (status) {
    case 401:
      notify('info', '로그아웃 되었습니다.', 3000);
      break;
    case 500:
      notify('error', '서버에서 오류가 발생하였습니다.', 3000);
      break;
    default:
      notify('error', message, 3000);
      break;
  }
}

handleHttpError 함수는 에러가 AxsosError 인스턴스인지 확인한 뒤, HTTP 상태 코드에 따라 적절한 사용자 메시지를 표시합니다.

예를 들어 401에러는 인증 만료로 간주해 로그아웃 안내 메시지를 보여주고 500에러는 서버 오류 메시지를 표시합니다. 그 외의 에러는 서버에서 내려준 메시지를 그대로 사용자에게 전달합니다.

QueryClient 공통 에러 처리

useQuery 에서 발생한 에러를 처리하기 위해서 QueryClient 객체에서 공통적인 에러 처리를 할 수 있도록 queryCache 부분에 onError를 추가하였습니다.

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: DEFAULT_STALE_TIME,
        refetchOnWindowFocus: false,
        refetchOnReconnect: false,
        retry: 0,
      },
      dehydrate: {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
    },
    queryCache: new QueryCache({
      onError: (error: unknown) => {
        handleHttpError(error);
      },
    }),
  });
}

이렇게 구현하여 잘 통합했다고 생각했지만 예상치 못한 문제가 발생해 추가 수정이 필요했습니다.

에러 처리 중복 문제 발생

문제 정의

현재 공통 API에서는 handleHttpError를 통해 에러를 처리하고 있지만, GET 요청의 경우 QueryClient에서 설정한 공통 에러 핸들러(onError)가 한 번 더 실행되면서 에러 처리가 중복되는 문제가 발생했습니다.

또한 useMutation을 사용하는 요청에서는 각 onError 설정에 따라 또 한 번 에러 처리가 이루어지게 되어, 동일한 에러에 대해 여러 번 토스트 메시지가 표시되는 현상이 나타났습니다.

결국은 동일한 에러에 대해 request 내부와 Tanstack Query의 에러 핸들러가 각각 토스트 메시지를 표시하면서 사용자에게 동일한 에러 메시지가 중복 노출되는 문제가 발생했습니다.

해결 방안

따라서 공통 API 내에서의 에러 처리는 에러를 파싱하는 역할만 하도록 수정했습니다.

  } catch (error) {
    if (!(error instanceof AxiosError)) {
      throw new Error('오류가 발생했습니다.');
    }

    const status = error.response?.status;
    const message = error.response?.data.message || error.message;

    throw new Error(JSON.stringify({ status, message }));
  }

이렇게 수정하면 useQuery 부분은 QueryClient 에서 설정한 공통된 에러 처리로 토스트 메시지를 보여주고 useMutation 부분은 각자의 onError 에서 파싱된 에러 객체를 받아 설정한 토스트 메시지를 보여주게 됩니다.

느낀점

이번 경험을 통해 에러 처리와 공통 로직 수정에 대해서 부분적으로만 이해하고 있었다는 것을 알게되었습니다. 실제로 공통화한다고 해서 끝나는 게 아니라 상황에 따라 어떻게 처리할지도 함께 고민해야 한다는 걸 배웠고 기능 분리나 재사용성 외에도 흐름 제어사용자 경험까지 고려한 설계가 필요하다는 걸 체감했습니다.

이번에 적용한 개선 방향이 정말 올바른 방향인지는 잘 모르겠습니다. 실제 서비스 상황에서의 다양한 예외 케이스를 더 많이 경험해봐야 진짜로 견고한 구조라고 말할 수 있을 것 같습니다.

또한 ErrorBoundary를 실제 프로젝트에 적용해본 경험이 없어, 다음 프로젝트에서는 컴포넌트 단위의 예외 상황도 포괄적으로 처리할 수 있도록 도입해볼 것입니다.

앞으로는 사용자 경험을 해치지 않으면서도 예외 상황을 안정적으로 처리할 수 있는 구조와, 도메인의 특성을 고려한 에러 메시지 설계까지 함께 고민해야될 것 같습니다.

profile
안녕하세요. 프론트엔드 개발 공부를 하고 있습니다.

0개의 댓글