From Scattered Error Handling to a Centralized Architecture

분산된 에러 처리의 한계와 중앙집중형 리팩토링

프론트엔드에서 에러는 보통 Error Boundary와 Toast UI로 처리한다.
문제는 Toast를 각 컴포넌트에서 직접 호출하는 방식이다. 초기에는 단순하지만, 규모가 커질수록 에러 처리 로직이 파일 전반에 분산되어 중복과 누락이 늘어난다.

이 구조에서는 다음 부담이 반복된다.

  • 어떤 컴포넌트가 어떤 방식으로 에러를 노출하는지 추적해야 한다.
  • 정책 변경 시 수정 범위가 넓어져 유지보수 비용이 커진다.
  • 테스트 시나리오에서 누락 케이스가 발생하기 쉽다.

즉, 핵심 문제는 “에러 발생 지점”과 “에러 노출 지점”이 함께 흩어져 있다는 점이다.

목표: 에러 처리의 단일 진입점 구성

개선 방향은 에러 처리 결정을 컴포넌트 바깥으로 이동하는 것이다.

  • 요청 시점에 처리 모드(TOAST_UI | BOUNDARY)를 명시한다.
  • Axios 인터셉터에서 공통 에러 타입으로 래핑한다.
  • TanStack Query throwOnError에서 전파 여부를 결정한다.
  • 전파하지 않은 에러만 QueryCache.onError에서 Toast로 처리한다.

흐름은 아래처럼 정리된다. API 요청 → 응답 에러 → Axios Interceptor → throwOnError → QueryCache.onError

구현:

Step #0 - TanStack Query 기본 에러 타입 변경

TanStack Query의 기본 에러 타입은 Error다.
이번 구조에서는 Axios 요청 config의 커스텀 필드(mode, errorContent)를 에러 객체로 전달해야 하므로, 기본 에러 타입을 CustomAxiosError로 확장한다.

pseudo
register TanStackQuery.defaultError = CustomAxiosError
Step #1 - API 요청 시점에 처리 방식 선언

에러 처리 의도를 가장 명확하게 드러낼 수 있는 지점은 “요청을 보내는 시점”이다.
따라서 요청 config에 mode를 필수로 두고, TOAST_UI일 때만 errorContent를 선택적으로 받는다.

pseudo
RequestConfig =
  | { mode: "TOAST_UI", errorContent?: { title, description } }
  | { mode: "BOUNDARY" }

fetcher.get("/api/tickets", {
  mode: "TOAST_UI",
  errorContent: { title: "...", description: "..." }
})
Step #2 - 응답 에러를 공통 타입으로 래핑

서버 에러가 발생했을 때, 이후 레이어가 동일한 방식으로 해석할 수 있도록 CustomAxiosError로 통일한다.
이 객체는 최소한 다음 정보를 담는다.

  • isToast: Toast 처리 여부
  • errorContent: 사용자 노출 메시지
  • Axios 원본 정보(response, request, config)
pseudo
class CustomAxiosError extends AxiosError:
  isToast: boolean
  errorContent: title/description | null
Step #3 - Axios Interceptor에서 변환 후 throw

인터셉터는 “에러를 분류”하지 않고 “에러를 표준화”한다.
즉, 요청 config를 읽어 CustomAxiosError로 변환한 뒤 그대로 던진다.

pseudo
onResponseError(error):
  config = error.config as RequestConfig
  throw CustomAxiosError(
    originalError = error,
    errorContent = (config.mode == "TOAST_UI") ? config.errorContent : null,
    isToast = (config.mode == "TOAST_UI")
  )
Step #4 - throwOnError로 전파 여부 결정

여기서 실제 분기가 일어난다.

  • true: Error Boundary로 전파
  • false: QueryCache onError로 이동 (Toast 처리 경로)
pseudo
throwOnError(error):
  if error.status == 500:
    return true   // 치명 에러는 Boundary
  return !error.isToast
Step #5 - QueryCache.onError에서 Toast 일괄 처리

throwOnErrorfalse인 에러만 onError에 도달하므로, 여기서는 추가 분기 없이 Toast 렌더링만 담당하면 된다. 결과적으로 컴포넌트마다 useEffect + useToast를 반복할 필요가 없어진다.

pseudo
queryCache.onError(error):
  toast({
    title: error.errorContent.title ?? "잠시 후 다시 시도해 주세요.",
    description: error.errorContent.description ?? fallbackMessage(error)
  })

이로써 에러 처리 정책을 한 지점에서 일관되게 관리할 수 있고, 중복된 Toast 훅 호출이 줄어든다. 또한, 컴포넌트는 UI 렌더링 책임에 집중할 수 있으며, 변경 영향 범위와 테스트 포인트가 명확해진다.