들어가며
이 글은 매번 instanceof를 쓰고 있다면 — TanStack Query Register로 에러 타입 지정하기의 후속편입니다.
이전 글에서 TanStack Query v5의 Register 인터페이스를 통해 프로젝트 전체의 에러 타입을 ApiError로 통일했어요.
declare module '@tanstack/react-query' {
interface Register {
defaultError: ApiError;
}
}
이 선언 이후, 모든 useQuery/useMutation의 error가 ApiError | null로 추론돼요.
컴포넌트에서 error.detail, error.status를 타입 가드 없이 바로 참조할 수 있게 되었습니다.
에러 타입이 명확해지니, 이전에는 신경 쓰지 않았던 부분이 눈에 들어오기 시작했습니다.
error.detail을 타입 가드 없이 바로 쓸 수 있게 됐는데, 그러면 런타임에서도 정말 항상 ApiError가 reject되고 있는 걸까?
인터셉터를 다시 보니, 4개 에러 경로 중 1개만 ApiError로 변환하고 있었습니다.
인터셉터의 에러 경로 분석
프로젝트의 axios 응답 인터셉터는 서버 에러를 ApiError 클래스로 변환하는 역할을 해요.
ApiError에 대한 자세한 설명은 이전 글의 “프로젝트의 에러 처리 구조” 섹션을 참고해주세요.
개선 전 인터셉터의 전체 에러 경로를 살펴보겠습니다.
// src/services/interceptor.ts — 개선 전
export const handleResponseErrorInterceptor = (error: any) => {
const errorType = error.response?.data?.type;
const errorStatus = error.response?.data?.status;
// 경로 1: 401 토큰 만료
if (errorType === 'AUTH_003') {
handleAuthError('로그인이 만료되었습니다...', 'auth-expired');
return Promise.reject(error); // ← AxiosError 그대로
}
// 경로 2: 계정 비활성화 (419)
if (errorStatus === 419) {
handleAuthError(error.response.data.detail, 'account-disabled');
return Promise.reject(error); // ← AxiosError 그대로
}
// 경로 3: 서버가 detail 필드를 포함한 에러 응답
if (error.response?.data?.detail) {
return Promise.reject(new ApiError(error.response.data)); // ✅ 유일하게 변환
}
// 경로 4: 네트워크 에러, 타임아웃 등
console.error('API Error:', error);
return Promise.reject(error); // ← AxiosError 그대로
};
도식화하면 문제가 선명하게 보입니다.
handleResponseErrorInterceptor(error)
│
├─ 경로 1: AUTH_003 (토큰 만료) → reject(AxiosError) ❌
├─ 경로 2: 419 (계정 비활성화) → reject(AxiosError) ❌
├─ 경로 3: detail 필드 있음 → reject(new ApiError) ✅
└─ 경로 4: 그 외 (네트워크 에러 등) → reject(AxiosError) ❌
4개 경로 중 3개가 AxiosError를 그대로 reject하고 있었습니다.
Register로 에러 타입을 명확히 지정한 덕분에, 이전에는 눈에 띄지 않던 이 불일치가 드러난 거예요.
경로별 위험도가 다르다
모든 경로가 같은 수준으로 위험한 건 아니었어요.
- 경로 1, 2 (인증 에러) —
handleAuthError()가 먼저 실행되어 로그아웃 + 로그인 페이지 리다이렉트를 수행합니다. reject된 에러가 컴포넌트의onError까지 도달하더라도, 리다이렉트로 컴포넌트가 이미 언마운트된 상태이므로 실질적 위험은 낮습니다. - 경로 4 (네트워크 에러) — 이것이 실제 위험 구간입니다. 네트워크 단절, CORS 에러, 타임아웃, 서버가
detail없이 응답하는 경우 등에서AxiosError가 그대로 reject됩니다.
경로 4에서 문제가 발생하는 시나리오는 이렇습니다.
const { error } = useQuery(someQuery);
// Register 선언으로 error는 ApiError | null로 추론됨
// 하지만 네트워크 에러 시 실제로는 AxiosError가 들어옴
toast.error(error?.detail); // → undefined. 사용자에게 빈 에러 메시지가 표시됨
타입은 ApiError라고 말하는데 런타임에서는 AxiosError가 들어오니, error.detail이 undefined가 돼요.
에러 타입을 지정하지 않았을 때는 어차피 instanceof로 방어하고 있어서 드러나지 않던 문제였습니다.
타입을 정확히 지정한 뒤에야 비로소 보이게 된 구멍이었어요.
두 가지 방향과 판단
해결 방향은 두 가지였어요.
방향 A: 인터셉터에서 모든 경로를 ApiError로 통일
Register 선언의 전제(“모든 에러가 ApiError”)를 런타임에서도 보장합니다.
변경 지점이 인터셉터 한 곳에 집중돼요.
방향 B: 사용처에서 방어적 유틸리티로 처리
function getErrorMessage(error: ApiError): string {
if (error instanceof ApiError) return error.detail;
return (error as any)?.message ?? '알 수 없는 오류가 발생했습니다.';
}
인터셉터를 건드리지 않지만, instanceof를 유틸 안에 숨기는 것에 불과합니다.
Register를 등록한 의미가 퇴색돼요.
방향 A를 선택했습니다. Register를 등록한 목적 자체가 “모든 에러가
ApiError”라는 전제를 선언한 것이므로, 런타임도 그 전제에 맞추는 것이 자연스럽습니다.
저는 아래 이유로 방향 A가 맞다고 판단했습니다.
error.detail을 직접 참조하는 곳이 38곳 이상 — 모든 곳에 방어 코드를 추가하는 것보다 인터셉터 한 곳을 고치는 게 효율적- 네트워크 에러에 대해서도
status: 0,type: 'NETWORK_ERROR'처럼 구조화된 정보를 제공하면, 사용처에서 에러 종류별 분기도 깔끔해짐 - 방향 B는 Register 선언과 런타임 사이의 불일치를 방치하면서 사용처에서 수습하는 구조 — 근본적 해결이 아님
개선된 인터셉터
모든 return Promise.reject(...) 지점에서 ApiError를 생성하도록 수정했어요.
// src/services/interceptor.ts — 개선 후
export const handleResponseErrorInterceptor = (error: any) => {
const errorType = error.response?.data?.type;
const errorStatus = error.response?.data?.status;
/** 401 토큰 만료 */
if (errorType === 'AUTH_003') {
handleAuthError('로그인이 만료되었습니다...', 'auth-expired');
return Promise.reject(
new ApiError({
type: 'AUTH_003',
title: '인증 만료',
status: 401,
detail: '로그인이 만료되었습니다.',
})
);
}
/** 계정 비활성화 */
if (errorStatus === 419) {
handleAuthError(error.response.data.detail, 'account-disabled');
return Promise.reject(new ApiError(error.response.data));
}
/** 서버가 detail 필드를 포함한 에러 응답 */
if (error.response?.data?.detail) {
return Promise.reject(new ApiError(error.response.data));
}
/** 네트워크 에러, 타임아웃 등 */
console.error('API Error:', error);
return Promise.reject(
new ApiError({
type: 'NETWORK_ERROR',
title: '네트워크 오류',
status: 0,
detail: error.message || '서버에 연결할 수 없습니다.',
})
);
};
변경의 핵심은 간단해요.
기존에 return Promise.reject(error)로 AxiosError를 그대로 넘기던 세 곳을 모두 new ApiError(...)로 감쌌습니다.
검증: 모든 경로가 ApiError를 반환하는가
개선 후 에러 경로를 다시 도식화하면 이렇습니다.
handleResponseErrorInterceptor(error)
│
├─ AUTH_003 (토큰 만료) → reject(new ApiError({type:'AUTH_003', status:401})) ✅
├─ 419 (계정 비활성화) → reject(new ApiError(error.response.data)) ✅
├─ detail 필드 있음 → reject(new ApiError(error.response.data)) ✅
└─ 네트워크 에러 등 → reject(new ApiError({type:'NETWORK_ERROR', status:0})) ✅
모든 경로에서 ApiError가 reject됩니다.
pnpm type-check 통과를 확인했어요.
pnpm type-check 통과까지 확인하면서, 4개 경로가 모두 ApiError를 반환하는 구조가 완성됐습니다.
마치며
에러 타입을 제대로 지정하고 나니, 절반만 해결된 상태가 보였어요.
타입 시스템은 “모든 에러가 ApiError”라고 알고 있는데, 인터셉터는 아직 그에 맞게 정비되지 않은 경로가 3개 있었습니다.
이번에 배운 것을 정리하면 이렇습니다.
- 타입 선언은 약속이다.
declare module로 타입을 오버라이드했다면, 런타임도 그 약속을 지켜야 합니다. 타입만 바꾸고 런타임을 방치하면undefined참조라는 더 은밀한 버그가 생깁니다. - 에러 경로는 도식화해서 검증해야 한다. 인터셉터의 분기를 머릿속으로만 따라가면 놓치기 쉽습니다. 모든
return Promise.reject(...)지점을 나열하고, 각각 어떤 타입이 reject되는지 확인하는 것이 효과적이었습니다. - 원본 에러 정보 유실이라는 트레이드오프가 있다. 네트워크 에러를
ApiError로 감싸면 원본AxiosError의config,request,response등 디버깅 정보가 유실됩니다. 필요시ApiError생성 시cause필드로 원본을 보존하는 확장을 고려할 수 있습니다.
에러 타입을 제대로 설계한 덕분에 인터셉터의 빈틈이 드러났고, 그 빈틈을 메우면서 타입 선언과 런타임이 일치하게 됐어요.
이제 error.detail을 안심하고 쓸 수 있습니다. 그런데 이 detail 메시지를 사용자에게 그대로 보여줘도 괜찮을까요? 다음 글에서 다루겠습니다.