
웹 애플리케이션에서는 네트워크 오류, HTTP 상태 오류, 비즈니스 로직 오류가 뒤섞여 발생합니다.
이럴 때 오류를 개별적으로 처리하다 보면 코드가 난잡해지고, 사용자 경험도 일관성을 잃게 됩니다.
이 글에서는 커스텀 에러 클래스(ApiError), axios 인터셉터 기반 네트워크 레이어, safeAxios 래퍼를 통해 이러한 문제를 어떻게 해결할 수 있는지 살펴보겠습니다.
API 호출 실패는 크게 3가지 레이어에서 발생할 수 있습니다.
각 레이어가 “어떤 오류인지”를 구분해야, 그에 맞는 처리를 할 수 있습니다.
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)
}
특징:
res.ok 검사, Axios는 자동으로 AxiosError를 던짐HTTP 요청은 성공(200 OK)이지만, 응답 바디 내부에서 “비즈니스 로직 실패”로 표시된 오류입니다.
예시:
{ 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": "비밀번호가 일치하지 않습니다."
}
특징:
success·code·result 등 필드명이 API마다 달라, 매번 커스텀 판별 코드가 필요요청이 서버에 도달하지 못하거나, 응답 자체를 받지 못했을 때 발생하는 오류입니다.
예시:
// 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.response가 undefined이므로 HTTP 상태를 알 수 없음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 → 전역 설정으로 중복 제거
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)
}
}
왜 필요한가?
Error엔 status나 code 같은 메타정보가 없습니다.ApiError의 메타정보를 활용해서 분기처리가 가능해집니다.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 })
}
)
무엇을 하나?
ApiError로 변환합니다.axios.isAxiosError 체크 대신 단순 err instanceof ApiError만 쓰면 됩니다.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 옵션으로 어떤 응답 구조도 유연하게 대응 가능합니다.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('알 수 없는 오류')
}
}
어디서 쓰나?
onErrorcatch 블록const queryClient = new QueryClient({
defaultOptions: {
queries: { onError: handleApiError },
mutations:{ onError: handleApiError },
}
})
장점
useQuery/useMutation에서 중복 없이 일관된 에러 UX 보장이렇게 ApiError, apiClient 인터셉터, safeAxios, 그리고 Global Error Handler를 조합함으로써 모든 API 호출에 대해 일관된 에러 처리 체계를 확립할 수 있었습니다.
이 코드를 적용한 뒤에는 에러 대응 로직이 한눈에 명확해지고, 유지보수성과 시스템 안정성이 크게 향상된 것을 실감하고 있습니다.