React·TypeScript 프로젝트에서 통합된 API 에러 핸들링 구축기

양정규·2025년 5월 18일
post-thumbnail

웹 애플리케이션에서는 네트워크 오류, HTTP 상태 오류, 비즈니스 로직 오류가 뒤섞여 발생합니다.
이럴 때 오류를 개별적으로 처리하다 보면 코드가 난잡해지고, 사용자 경험도 일관성을 잃게 됩니다.
이 글에서는 커스텀 에러 클래스(ApiError), axios 인터셉터 기반 네트워크 레이어, safeAxios 래퍼를 통해 이러한 문제를 어떻게 해결할 수 있는지 살펴보겠습니다.


기본적인 에러 유형

API 호출 실패는 크게 3가지 레이어에서 발생할 수 있습니다.
각 레이어가 “어떤 오류인지”를 구분해야, 그에 맞는 처리를 할 수 있습니다.

Transport-level 에러

  • HTTP 프로토콜 수준에서 리턴된 오류입니다.

  • 예시:
    서버가 4xx/5xx 상태 코드를 응답했을 때

    HTTP/1.1 404 Not Found
    { "message": "Not Found" }
    HTTP/1.1 500 Internal Server Error
    <html>…서버 오류 페이지…</html>
      // Fetch
    try {
    const res = await fetch('/api/data')
    // HTTP 오류라도 res.ok가 false면 직접 예외를 던져야 함
    if (!res.ok) throw new Error('요청 실패')
    const data = await res.json()
    } catch (err) {
      console.error(err) // 어떤 상태 코드인지 알 수 없음
    }
    
    // Axios
    try {
    const { data } = await axios.get('/api/data')
    } catch (err) {
    // err.response?.status에 접근해야 401/500 구분 가능
    console.error(err) 
    }
  • 특징:

    • HTTP 상태 코드 자체가 실패를 의미
    • Fetch는 res.ok 검사, Axios는 자동으로 AxiosError를 던짐
    • 본문 형식(JSON, HTML 등)이 일정치 않아 파싱 로직이 복잡해질 수 있음

Business-level 에러

  • HTTP 요청은 성공(200 OK)이지만, 응답 바디 내부에서 “비즈니스 로직 실패”로 표시된 오류입니다.

  • 예시:

    • RESTful: { success: false, code: 'X', message: '…' }
    • 레거시: { result: 0, message: '…' }
    HTTP/1.1 200 OK
    Content-Type: application/json
    
    {
    "success": false,
    "code": "INVALID_INPUT",
    "message": "이름을 입력해주세요."
    }
    HTTP/1.1 200 OK
    Content-Type: application/json
    
    {
    "result": 0,
    "message": "비밀번호가 일치하지 않습니다."
    }
  • 특징:

    • HTTP 200이지만 애플리케이션 로직에서 실패로 간주
    • success·code·result 등 필드명이 API마다 달라, 매번 커스텀 판별 코드가 필요

Network-level 에러

  • 요청이 서버에 도달하지 못하거나, 응답 자체를 받지 못했을 때 발생하는 오류입니다.

  • 예시:

    • 인터넷 끊김, DNS 실패, CORS 차단
    • 클라이언트 타임아웃 설정 초과
    // Fetch
    fetch('/api/data')
      .catch(err => console.error('Network Error:', err.message))
    
    // Axios
    axios.get('/api/data')
      .catch(err => {
        if (!err.response) {
          console.error('Network or CORS Error:', err.message)
        }
      })
  • 특징:

    • err.responseundefined이므로 HTTP 상태를 알 수 없음
    • 재시도, 오프라인 처리, 사용자 안내(UI) 등을 별도 구현해야 함

전역화된 에러 처리 전략

API 호출 중 발생하는 오류를 적절히 처리하지 않으면, 사용자 경험도 망가지고 디버깅도 어려워집니다.
특히 RESTful API가 아닌 레거시 응답 형식, 네트워크 끊김, 서버 내부 오류까지 모두 포괄하려면 단순한 try/catch를 넘어서 전역화된 에러 처리 전략이 필요합니다.

🔑 구현한 방법

ApiError

  • HTTP·네트워크 오류와 비즈니스 실패를 하나로 묶어 전달
  • message, status, code, cause를 포함해 일관된 에러 분기 지원

apiClient 인터셉터

  • 모든 4xx/5xx·네트워크 예외를 ApiError로 변환
  • 호출 코드에서는 instanceof ApiError만 체크하면 됨

safeAxios

  • “통신은 성공했지만 비즈니스 로직 실패”(success: false 등) 감지
  • validateSuccess·extractPayload 옵션으로 어떤 응답 구조도 재사용 가능

Global Error Handler (handleApiError)

  • 에러 메시지 토스트, 로그인 리다이렉트, Sentry 로깅 등을 한곳에서 관리
  • React Query onError → 전역 설정으로 중복 제거

ApiError - Error형태 정의

export class ApiError extends Error {
  readonly name = 'ApiError'
  readonly status?: number    // HTTP 상태 코드
  readonly code?: string      // 서버 비즈니스 오류 식별자
  readonly cause?: unknown    // 원본 응답이나 내부 예외

  constructor(message: string, options?: {status?:number;code?:string;cause?:unknown}) {
    super(message)
    this.status = options?.status
    this.code   = options?.code
    this.cause  = options?.cause
    Error.captureStackTrace?.(this, ApiError)
  }
}
  • 왜 필요한가?

    • 기본 Errorstatuscode 같은 메타정보가 없습니다.
    • ApiError의 메타정보를 활용해서 분기처리가 가능해집니다.

apiClient 인터셉터 - HTTP/네트워크 오류 일원화

const apiClient = axios.create({ baseURL, timeout:10000 })
apiClient.interceptors.response.use(
  response => response,
  err => {
    const status = err.response?.status
    const data   = err.response?.data ?? {}
    const msg    = data.message || err.message || '서버 오류 발생'
    throw new ApiError(msg, { status, code: data.code, cause: data })
  }
)
  • 무엇을 하나?

    • 4xx/5xx, 네트워크 끊김 등 axios가 던지는 모든 에러를 가로채서 ApiError로 변환합니다.
    • 이후 호출 코드에선 axios.isAxiosError 체크 대신 단순 err instanceof ApiError만 쓰면 됩니다.

safeAxios - 비즈니스 실패 감지 + 데이터 추출

async function safeAxios<T>(
  config: AxiosRequestConfig,
  options?: {
    validateSuccess?: (data:any) => boolean,
    extractPayload?: (data:any) => T
  }
): Promise<T> {
  const res  = await apiClient.request(config)    // HTTP 레벨은 인터셉터가 처리
  const data = res.data ?? {}

  // 1) 비즈니스 실패 감지
  const isSuccess = options?.validateSuccess?.(data) ?? data.success !== false
  if (!isSuccess) throw new ApiError(data.message||'요청 실패', { status:res.status, code:data.code, cause:data })

  // 2) 필요한 부분만 꺼내기
  return options?.extractPayload?.(data) ?? (data.data ?? data)
}
  • 왜 나눴나?

    • apiClient는 “통신 실패”만,
    • safeAxios는 “통신은 됐는데 비즈니스 로직상 실패”를 처리합니다.
    • validateSuccess/extractPayload 옵션으로 어떤 응답 구조도 유연하게 대응 가능합니다.

Global Error Handler - UX와 로깅 통합

function handleApiError(err: unknown) {
  if (err instanceof ApiError) {
    if (err.status === 401) return navigate('/login')
    toast.error(err.message)
    Sentry.captureException(err)
  } else {
    toast.error('알 수 없는 오류')
  }
}
  • 어디서 쓰나?

    • React Query onError
    • 컴포넌트 catch 블록
    • 전역 에러 바운더리 등

React Query 전역 설정

const queryClient = new QueryClient({
  defaultOptions: {
    queries:   { onError: handleApiError },
    mutations:{ onError: handleApiError },
  }
})
  • 장점

    • 모든 useQuery/useMutation에서 중복 없이 일관된 에러 UX 보장

이렇게 ApiError, apiClient 인터셉터, safeAxios, 그리고 Global Error Handler를 조합함으로써 모든 API 호출에 대해 일관된 에러 처리 체계를 확립할 수 있었습니다.
이 코드를 적용한 뒤에는 에러 대응 로직이 한눈에 명확해지고, 유지보수성과 시스템 안정성이 크게 향상된 것을 실감하고 있습니다.

profile
롤보다 개발이 재밌는 프론트엔드 개발자입니다 :D

0개의 댓글