들어가며
TanStack Query에서 useQuery나 useMutation의 error는 기본적으로 Error 타입으로 추론됩니다.
message 하나만 가진 가장 기본적인 에러 객체예요.
하지만 실제 프로젝트에서는 이것만으로 부족한 경우가 많습니다.
백엔드와 에러 응답 형식을 협의하고, 그에 맞는 커스텀 에러 클래스를 만들어 쓰는 팀이 적지 않아요.
저희 프로젝트도 그랬습니다.
백엔드가 status, type, detail 같은 필드를 포함한 구조화된 에러를 반환하기로 합의했고, 프론트엔드에서는 이에 대응하는 ApiError라는 커스텀 에러 클래스를 만들어 사용하고 있었어요.
그런데 문제가 있었습니다.
TanStack Query가 이 커스텀 타입을 모른다는 거였거든요.
const { mutate } = useMutation({
mutationFn: () => deleteService(serviceId),
onError: (error: Error) => {
// ← Error로 추론됨. ApiError가 아님
if (error instanceof ApiError) {
// ← 매번 타입 가드를 써야 함
toast.error(error.detail);
}
},
});
error.detail이라는 단순한 속성 접근을 위해 매번 instanceof 타입 가드를 거쳐야 했어요.
프로젝트 전체를 검색해보니 이 패턴이 38곳에 반복되고 있었습니다.
queryOptions 팩토리 패턴 덕분에 데이터 타입은 완벽하게 추론되는데, 에러 타입만 항상 Error로 고정되어 있는 게 이상했어요.
왜 그런지 파보다가, TanStack Query가 에러 타입의 기본값을 결정하는 구조를 발견했습니다.
그리고 그 구조를 활용하면 코드 3줄로 프로젝트 전체의 에러 타입을 바꿀 수 있다는 걸 알게 되었어요.
프로젝트의 에러 처리 구조
먼저 프로젝트의 에러 처리 흐름을 짚어야 합니다.
이 구조를 알아야 “왜 전역 에러 타입 설정이 가능한가”를 이해할 수 있어요.
앞서 말한 대로, 백엔드와 협의해서 모든 API 에러 응답을 RFC 7807 (Problem Details) 형식으로 통일한 상태입니다.
RFC 7807은 HTTP API의 에러 응답을 status, type, title, detail 같은 필드로 구조화하는 표준이에요.
프론트엔드에서는 이 형식에 대응하는 ApiError 클래스를 다음과 같이 정의했습니다.
// src/types/ApiException.ts
export class ApiError extends Error {
status: number;
type: string;
title: string;
detail: string;
instance?: string;
constructor(data: ErrorSchema) {
super(data.detail);
this.status = data.status;
this.type = data.type;
this.title = data.title;
this.detail = data.detail;
this.instance = data.instance;
}
}
이 ApiError는 axios 응답 인터셉터에서 생성됩니다.
서버가 에러를 반환하면, 인터셉터가 AxiosError를 잡아 ApiError로 변환한 뒤 reject하는 구조예요.
// src/services/interceptor.ts (핵심 부분만 발췌)
export const handleResponseErrorInterceptor = (error: any) => {
// 401 토큰 만료, 419 계정 비활성화 등 특수 케이스 처리 후...
// 서버가 detail 필드를 포함한 경우 → ApiError로 변환
if (error.response?.data?.detail) {
return Promise.reject(new ApiError(error.response.data)); // ✅ ApiError로 변환
}
return Promise.reject(error); // ← 일부 경로에서는 AxiosError 그대로
};
즉, 런타임에서 대부분의 API 에러는 ApiError 인스턴스로 reject됩니다.
사용처에서는 error.detail이나 error.status를 참조해 사용자에게 에러 메시지를 보여주는 패턴이 반복되고 있었어요.
다만 타입 시스템은 이 흐름을 모릅니다.
타입 시스템이 모르는 것
WARNING
인터셉터가 런타임에서 ApiError를 throw하든 말든, TanStack Query의 타입 시스템은 이를 알 수 없습니다. error는 항상 Error | null로 추론됩니다.
useQuery나 useMutation에서 반환하는 error의 타입은 Error | null입니다.
이 때문에 프로젝트 전체에 세 가지 보일러플레이트 패턴이 반복되고 있었어요.
// 패턴 1: onError 콜백에서 instanceof (20곳 이상)
onError: (error: Error) => {
if (error instanceof ApiError) {
toast.error(error.detail);
}
},
// 패턴 2: 삼항 연산자로 분기 (6곳)
onError: error => {
toast.error(
error instanceof ApiError ? error.detail : t('networkError'),
);
},
// 패턴 3: error 필드 직접 사용 시 타입 가드 필수
const { error } = useMutation({ ... });
// error: Error | null ← detail, status 접근 불가
if (error instanceof ApiError) {
setErrorMessage(error.detail);
} else {
setErrorMessage(error?.message ?? '');
}
한편 데이터 타입은 queryOptions 팩토리 덕분에 완벽하게 추론되고 있었습니다.
// src/services/queries/agent.ts
export const agentQueries = {
detail: (agentId: number) =>
queryOptions({
queryFn: () => getUserAgent().getAgentDetail(agentId),
queryKey: AGENT_DETAIL_QUERY_KEY(agentId),
}),
};
const { data, error } = useQuery(agentQueries.detail(agentId));
// data: AgentDetailRs ← 완벽하게 추론됨
// error: Error | null ← ApiError가 아님
error.detail이라는 단순한 속성 접근을 위해 매번 instanceof를 쓰는 건 분명 불필요한 반복이었어요.
그렇다면 TanStack Query는 왜 에러 타입을 Error로 고정하는 걸까요?
TanStack Query의 DefaultError 결정 구조
TanStack Query v5의 소스를 들여다봤더니, 에러 타입의 기본값을 결정하는 구조가 세 단계로 되어 있었습니다.
// @tanstack/query-core/src/types.ts 내부 구조
// 1단계. Register 인터페이스 — 기본값은 빈 객체
export interface Register {
// defaultError: Error ← 주석 처리됨 (사용자가 오버라이드하도록 설계)
}
// 2단계. DefaultError — 조건부 타입으로 결정
export type DefaultError = Register extends { defaultError: infer TError }
? TError // Register에 defaultError가 있으면 → 그 타입
: Error; // 없으면 → Error (기본값)
// 3단계. useQuery/useMutation에서 DefaultError를 기본값으로 사용
function useQuery<
TQueryFnData = unknown,
TError = DefaultError, // ← 여기서 에러 타입 결정
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(options): UseQueryResult<TData, TError>;
핵심은 Register 인터페이스입니다.
Register가 빈 객체 →{ defaultError: infer TError }에 매칭 안 됨 →DefaultError = ErrorRegister에defaultError를 추가하면 → 그 타입이 프로젝트 전체의 기본 에러 타입이 됨
저희 프로젝트는 Register를 augment하지 않았으니 DefaultError = Error였던 거예요.
인터셉터에서 ApiError를 throw하든 말든, 타입 시스템은 모든 에러를 Error로만 취급하고 있었던 겁니다.
방법은 분명했지만, 다른 선택지와 비교 없이 바로 적용하기보다 먼저 정리해봤습니다.
다섯 가지 방법을 비교하다
에러 타입 추론 문제를 해결하는 방법은 다섯 가지가 있었습니다.
| 방법 | 설정 비용 | 사용처 변경 | 유연성 |
|---|---|---|---|
| Register (module augmentation) | 3줄 | 없음 | 전체 균일 |
| useQuery 제네릭 직접 지정 | 없음 | 모든 호출 | 쿼리별 |
| queryOptions 팩토리 제네릭 | 없음 | 모든 팩토리 | 팩토리별 |
| 커스텀 훅 래퍼 | 훅 1개 | 모든 호출 | 커스텀 로직 |
| 타입 가드 (instanceof) | 없음 | 사용처마다 | 런타임 검증 |
각 방법의 한계를 살펴보면 이렇습니다.
- useQuery 제네릭 직접 지정 — 모든 호출마다
useQuery<ServiceRs[], ApiError>식으로 지정 필요. queryOptions 팩토리와 함께 쓰면 팩토리에서 이미 타입이 결정되어 효과 없음 - queryOptions 팩토리 제네릭 — 모든 팩토리를 수정해야 하고,
select옵션과의 충돌 이슈(#5436)도 있음 - 커스텀 훅 래퍼 — 기존 코드 전부 교체 필요. TanStack Query 업데이트 시 래퍼 유지보수 부담
- 타입 가드 (instanceof) — 런타임에서 가장 안전하지만, 38곳의 보일러플레이트가 그 비용을 보여줌
Register를 선택한 결정적인 이유는 프로젝트의 에러 처리 구조와의 정합성이었습니다.
인터셉터가 대부분의 에러를 ApiError로 변환하는 구조이므로, 에러 타입도 전역으로 ApiError를 기본값으로 설정하는 게 자연스러웠어요.
코드 3줄 추가로 프로젝트 전체에 적용되고, 기존 사용처 수정이 필요 없으며, queryOptions 팩토리 패턴과도 호환됩니다.
적용: 코드 3줄
적용 자체는 간단합니다.
queryClient를 설정하는 파일에 module augmentation을 추가하면 됩니다.
// src/services/queryClient.ts
import type { ApiError } from '@/types/ApiException';
declare module '@tanstack/react-query' {
interface Register {
defaultError: ApiError;
}
}
이것만으로 프로젝트 전체에 두 가지가 바뀝니다.
useQuery/useMutation이 반환하는error의 타입이Error | null→ApiError | nullerror.detail에 바로 접근 가능.instanceof타입 가드 불필요
한편, 기존 코드가 error: Error를 전제로 작성되어 있었기 때문에 충돌하는 곳이 있을 수 있었어요.
pnpm type-check로 확인해봤습니다.
검증: 타입 체크가 잡아준 3개의 불일치
[!SUCCESS]
pnpm type-check실행 결과 3개의 타입 에러가 발생했습니다. 기존 코드의 버그가 아니라, 타입이 정확해지면서 불필요한 분기가 드러난 것입니다.
기존 코드에서 error를 Error로 가정하고 작성한 부분들이 ApiError로 바뀌면서 드러난 불일치였습니다.
1. AgentTestChat.tsx — AxiosError 캐스팅 실패
// Before: error가 Error → AxiosError 캐스팅이 호환됨
const responseData = (error as AxiosError<{ detail: string }>)?.response?.data;
// After: error가 ApiError → AxiosError와 호환되지 않음
// 수정: 중간 캐스팅 추가
const responseData = (error as unknown as AxiosError<{ detail: string }>)
?.response?.data;
Error에서 AxiosError로의 캐스팅은 타입 계층이 호환되어 허용됐지만, ApiError에서 AxiosError로는 구조가 달라 직접 캐스팅이 불가능했어요.
2. LoginPage.tsx — else 분기가 never로 추론
// Before: error: Error | null → else 분기에서 Error로 message 접근 가능
if (error instanceof ApiError) {
setErrorMessage(error.detail);
} else {
setErrorMessage(error?.message ?? ''); // error: Error
}
// After: error: ApiError | null → instanceof ApiError가 false이면 null뿐
// else 분기의 error가 never → message 접근 에러
// 수정: instanceof 불필요, 직접 접근으로 단순화
setErrorMessage(error?.detail ?? '');
ApiError는 Error를 상속하므로, error: ApiError | null에서 instanceof ApiError가 false이면 타입상 null만 남습니다.
그래서 else 분기에서 error가 never가 되어 message 속성에 접근할 수 없었어요.
오히려 코드가 단순해지는 방향이었습니다.
3. QueryEditor.tsx — 2번과 같은 패턴입니다. error instanceof ApiError ? error.detail : error?.message에서 삼항 연산자 전체가 error?.detail로 단순화되었습니다.
3개 모두 기존 코드가 틀렸다기보다, 타입이 정확해지면서 불필요한 분기가 드러난 경우였습니다.
수정 후 pnpm type-check가 통과했어요.
마치며
처음 문제는 단순했습니다.
error.detail에 접근하고 싶은데, 매번 instanceof ApiError를 써야 하는 게 번거로웠어요.
프로젝트 전체에 38곳이나 반복되는 이 패턴을 보고, 제가 에러 타입 자체를 바꿀 수 있는 방법을 찾아보게 됐습니다.
정리하면 이렇습니다.
- TanStack Query v5는
Register인터페이스를 통해 에러 타입의 기본값을 오버라이드할 수 있도록 설계되어 있다 - 다섯 가지 방법 중, 인터셉터에서
ApiError로 균일하게 변환하는 프로젝트 구조와 가장 잘 맞는 Register 방식을 선택했다 - 변경은
queryClient.ts에 module augmentation 3줄을 추가한 것이 전부였고, 타입 체크에서 발견된 3개의 불일치도 오히려 코드를 단순하게 만드는 방향이었다
타입 선언만으로는 절반의 해결이고, 런타임에서도 그 전제를 보장해야 완전한 해결이 됩니다.
이 gap을 메우는 인터셉터 개선은 다음 글에서 다루겠습니다.