React Query 전역 에러 핸들링 완전 정복 — 흩어진 onError 30곳을 QueryCache로 모으기까지
React Query(TanStack Query v5) 전역 에러 핸들링: QueryCache·MutationCache·meta로 onError 중복 제거, 401·403·5xx 분기와 메시지·로그 분리 실무 후기.

TanStack Query v5의
QueryCache,MutationCache, 그리고meta필드를 활용해 파편화된 에러 처리를 한 곳으로 모은 실전 리팩터링 기록입니다. React Query 전역 에러 핸들링을 고민 중이시라면, 저희가 겪은 시행착오가 조금이나마 도움이 되었으면 합니다.
안녕하세요. BAS KOREA IT팀에서 프론트엔드를 담당하고 있는 INSIK입니다.
React Query, 잘 쓰고 계신가요? 새 프로젝트라면 이미 자연스러운 선택이 되었지만, 한두 해쯤 굴러온 서비스라면 컴포넌트마다 박혀 있는 onError 콜백 앞에서 "이거 정리는 언젠가 해야 하는데…"라는 막막함이 남아있을지도 모릅니다.
저희가 만들고 있는 "Flow MATE" 는 Inquiry(문의) → Offer(견적) → Order(주문) → Logistics(물류) → Invoice(정산)로 이어지는 B2B 무역 운영 솔루션입니다. 흐름이 길다 보니 중간 어느 한 API가 조용히 실패하면, 사용자 입장에선 다음 단계 업무가 통째로 멈춰버립니다. "데이터가 안 보이는데요?"라는 CS 한 줄을 받아 디버깅해 보면, 정작 문제의 본질은 API 실패 그 자체가 아니라 "에러가 사용자에게 제대로 전달되지 않았다는 것" 인 경우가 많았습니다.
이 글에서는 파편화된 에러 처리를 TanStack Query v5의 QueryCache / MutationCache와 meta 필드로 한 곳에 모아간 과정을, 중간중간 막혔던 지점과 팀 안에서 오갔던 이야기를 섞어 공유드리려 합니다.
이런 분께 도움이 될 거예요
useQuery마다 반복되는 onError 블록을 정리하고 싶으신 분
401/403/5xx 같은 상태 코드별 전역 처리 패턴을 찾고 계신 분
"전역 처리와 개별 처리를 어떻게 공존시킬지" 고민 중이신 분
1. 발단 — 컴포넌트마다 박혀 있던 30개의 onError
처음 React Query를 도입할 때는 별 고민이 없었습니다. useQuery 훅이 onError를 친절하게 열어주고 있었고, 저도 그 자리에 토스트를 띄우는 코드를 그대로 적어 넣었습니다.
// ❌ Before — 페이지마다 반복되던 패턴
const { data } = useQuery({
queryKey: ["inquiry", id],
queryFn: () => fetchInquiry(id),
onError: (error) => {
if (isAxiosError(error) && error.response?.status === 401) {
authStore.logout();
router.replace("/login");
return;
}
toast.error("문의 정보를 불러오지 못했습니다.");
},
});
한두 개일 때는 문제가 없었습니다. 하지만 Inquiry, Offer, Order, Logistics, Invoice까지 도메인이 늘어나면서 이 비슷한 블록이 프로젝트 전체에서 30곳을 넘어가기 시작했습니다.
어느 날 백엔드 팀에서 "401 응답 스펙이 바뀝니다, 바디에 reason 필드가 추가돼요"라는 공지가 올라왔을 때, 저는 깨달았습니다. 이걸 받아 처리하는 위치가 한 곳이 아니라 서른 곳이라는 사실을요.
PR을 올리기 전, 문득 머릿속에 걸리는 게 있어서 AI 에이전트에게 한번 털어놨습니다.
나: 401 응답 스펙 바뀌었어. onError 블록들 돌면서 다 고쳐줘
AI: 네, 확인해보니 프로젝트 안에 onError에서 401을 처리하는 곳이 서른 곳 정도 됩니다. 순서대로 수정하면서 진행하겠습니다.
나: 야 근데 이거 너무 오래 걸리는데? 한 번에 처리할 수 있는 방향 없냐
AI: 지금 구조는 각 컴포넌트가 에러 처리를 직접 들고 있어서, 스펙이 한 번 바뀔 때마다 서른 곳을 동시에 건드려야 하는 상태입니다. 같은 작업이 반복되는 지점을 한 곳으로 모으는 게 맞아 보입니다.
나: 그러네 이번에 401만 고치고 끝나는 게 아니라, 다음에 5xx 포맷 바뀌면 또 서른 곳 돌아야 되는 거잖아
AI: 맞습니다. 그리고 지금 확인해보니 토스트 호출도 파일마다 달라서 — 어떤 곳은 toast.error, 어떤 곳은 Swal.fire — 같은 에러인데 UI 톤이 페이지마다 갈리는 상태입니다.
나: 아 그럼 이번 수정은 증상 때우는 거고, 애초에 에러 처리를 컴포넌트에 둔 게 문제였다는 소리네
AI: 그 방향이 맞다고 봅니다. TanStack Query는 QueryCache / MutationCache 레벨에서 에러를 한 번에 받을 수 있게 열어두고 있어서, 이 기회에 구조 자체를 옮기는 걸 제안드립니다.

그 순간 뭔가 띵-! 하고 걸렸습니다. 이번에 고쳐야 할 건 401 한 줄이 아니라, 에러 처리를 어디에 두느냐라는 구조 자체였습니다. 이 대화가 이번 개선의 시작점이었습니다.
2. 방향 잡기 — "컴포넌트는 UI에만 집중하게 하자"
고민은 단순했습니다. 페이지 컴포넌트는 무엇을 보여줄지만 알면 충분하지, 401이 뜨면 로그아웃을 해야 한다거나 5xx가 뜨면 Sentry에 로그를 쌓아야 한다는 건 알 필요가 없습니다. 그건 애플리케이션 인프라의 책임입니다.
그래서 저는 에러 처리를 세 개 층으로 분리하기로 했습니다.
계층 책임 위치 전역 핸들러 HTTP 상태 코드별 기본 동작, 로깅, 인증 처리 QueryCache / MutationCache onError 도메인 컨텍스트 "이 쿼리는 어떤 메시지로 보여줄까?" meta.errorMessage 등 컴포넌트 로딩/빈 상태 같은 UI 분기만 useQuery 반환값
이 구조를 머릿속에 그리고 나니, 이후 구현은 의외로 빠르게 진행됐습니다.
3. 1단계 — QueryCache에 에러 경로를 모으기
TanStack Query v5는 QueryClient를 만들 때 QueryCache와 MutationCache를 주입할 수 있고, 각각의 onError는 모든 쿼리/뮤테이션의 에러가 지나가는 단일 통로가 됩니다. 개별 훅에서 아무 설정을 하지 않아도 여기로 흘러 들어옵니다.
// src/lib/queryClient.ts
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
GlobalErrorHandler.handle(error, query.meta);
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
GlobalErrorHandler.handle(error, mutation.meta);
},
}),
});
핵심은 두 번째 인자인 query.meta / mutation.meta입니다. 이 필드가 전역 핸들러와 개별 쿼리 사이의 유일한 대화 창구가 됩니다. "전역으로 다 처리하면 유연성을 잃는 거 아니냐"는 걱정이 바로 이 지점에서 풀립니다.
4. 2단계 — meta로 쿼리별 맥락 전달하기
어떤 쿼리는 에러가 나도 조용히 넘어가야 하고(예: 백그라운드 prefetch), 어떤 쿼리는 반드시 모달로 사용자 확인을 받아야 합니다. 이런 쿼리마다 다른 맥락을 전역 핸들러가 알 수 있도록 meta 타입을 먼저 확장했습니다.
// src/lib/react-query.d.ts
import "@tanstack/react-query";
declare module "@tanstack/react-query" {
interface Register {
queryMeta: {
errorMessage?: string;
ignoreGlobalError?: boolean;
feedback?: "toast" | "modal" | "silent";
};
mutationMeta: {
errorMessage?: string;
successMessage?: string;
ignoreGlobalError?: boolean;
feedback?: "toast" | "modal" | "silent";
};
}
}
타입을 확장해 두니, 새 쿼리를 작성할 때 meta: 뒤에 자동완성이 뜹니다. "이 옵션을 쓸 수 있었나?" 하고 코드를 뒤지는 일이 사라진 것만으로도 체감 효과가 꽤 컸습니다.
실제 사용부는 이렇게 단순해집니다.
// 👍 After — 컴포넌트는 메시지만 선언
const { data } = useQuery({
queryKey: ["inquiry", id],
queryFn: () => fetchInquiry(id),
meta: {
errorMessage: "문의 정보를 불러오지 못했어요. 잠시 후 다시 시도해 주세요.",
},
});
30줄 넘던 중복 블록이, meta 한 줄로 줄어든 순간이었습니다.

5. 3단계 — GlobalErrorHandler에서 상태 코드별 분기
전역 핸들러는 단순한 토스트 발사기가 아닙니다. 백엔드 응답 규약에 따라 애플리케이션 상태를 제어하는 컨트롤 타워 역할을 맡습니다. 저는 Axios 에러 기준으로 상태 코드별 경로를 이렇게 정리했습니다.
// src/lib/GlobalErrorHandler.ts
export class GlobalErrorHandler {
static handle(error: unknown, meta?: QueryMeta | MutationMeta) {
if (meta?.ignoreGlobalError) return;
if (!isAxiosError(error)) {
Sentry.captureException(error);
this.toast(meta?.errorMessage ?? "알 수 없는 오류가 발생했어요.");
return;
}
const status = error.response?.status;
switch (status) {
case 401:
return this.handleUnauthorized();
case 403:
return this.handleForbidden(meta);
case 429:
return this.toast("요청이 많아요. 잠시 후 다시 시도해 주세요.");
case 500:
case 502:
case 503:
case 504:
Sentry.captureException(error);
return this.toast("일시적인 서버 문제로 요청을 처리하지 못했어요.");
default:
return this.toast(
meta?.errorMessage ?? error.response?.data?.message ?? "요청을 처리하지 못했어요.",
);
}
}
private static handleUnauthorized() {
AuthService.clear();
window.location.replace("/login?reason=expired");
}
// ... 이하 생략
}
여기서 신경 썼던 지점은 "사용자에게 보이는 문구와, 개발자에게 보내는 로그를 분리하자" 는 원칙이었습니다. 사용자는 "서비스 이용에 불편을 드려 죄송해요"만 봐도 충분하고, 스택 트레이스와 요청 URL은 Sentry로 조용히 흘려보냅니다.
상태 코드별 대응 요약
HTTP 상황 전역 동작 사용자 피드백 401 토큰 만료 인증 상태 초기화 → /login 이동 "세션이 만료되었어요. 다시 로그인해 주세요." 403 권한 없음 이전 경로 복귀 "해당 기능에 접근 권한이 없어요." (모달) 4xx 유효성/비즈니스 오류 서버 메시지 우선, 없으면 meta.errorMessage 토스트 429 요청 제한 쿨다운 안내 "잠시 후 다시 시도해 주세요." 5xx 서버 장애 Sentry 전송 "일시적인 서버 문제가 발생했어요."
6. 복병 — "이 쿼리는 전역 처리에서 빼주세요"

전역 핸들링을 깔고 난 뒤 가장 많이 받은 요청은, 역설적이게도 "여긴 전역 처리 빼주세요" 였습니다.
예를 들어 Invoice 페이지에서는 미리보기용 데이터를 여러 개 useQueries로 병렬 요청하는데, 이 중 하나가 실패했다고 전면 토스트를 띄우면 화면이 난장판이 됩니다. 그렇다고 전역 핸들링을 포기할 수도 없었죠.
해결은 meta.ignoreGlobalError였습니다.
useQueries({
queries: invoiceIds.map((id) => ({
queryKey: ["invoice-preview", id],
queryFn: () => fetchInvoicePreview(id),
meta: { ignoreGlobalError: true }, // 인라인 에러 UI로만 표시
})),
});
이 패턴을 만들고 나서야, "전역 처리의 반대말은 '개별 처리'가 아니라 '명시적 오버라이드'" 라는 감각을 스스로 규칙으로 세울 수 있었습니다. 개별적으로 처리해도 되지만, 그 예외는 반드시 meta에 드러나 있어야 한다 — 라는 원칙입니다.
7. 도입 후 — 숫자로 확인된 효과
도입 후 한 달 정도 지켜본 결과입니다.
에러 처리 관련 코드 라인 수: 약 1,200 → 180 (−85%)
신규 API 연동 평균 PR 라인 수: 약 40% 감소 (반복 코드 제거 효과)
에러 관련 CS 티켓: 전 분기 대비 절반 수준. "뭐가 됐다/안 됐다"를 사용자가 알 수 있게 된 것이 가장 큰 변화였습니다.
숫자보다 더 좋았던 건, "새 페이지를 만들 때 에러 처리를 고민하지 않아도 된다" 는 감각이었습니다. 이제는 useQuery 옆에 에러 메시지 한 줄만 적고 곧바로 비즈니스 로직으로 돌아갑니다.
8. 정리 — 핵심만 다시

글이 길었으니, 오늘 이야기의 뼈대만 다시 정리해 봅니다.
통로를 하나로 —
QueryCache/MutationCache의onError에 모든 에러를 모읍니다.맥락은
meta로 — 컴포넌트는 메시지만 선언하고, 해석은 핸들러에 맡깁니다.예외는 명시적으로 —
ignoreGlobalError처럼, 빠져나가는 쿼리는 코드에 드러나게 둡니다.사용자 문구와 개발자 로그를 분리 — 사용자는 안내 문구만, Sentry엔 스택 트레이스를.
마무리 — 에러는 사용자와의 마지막 대화입니다
전역 에러 핸들링을 만들며 가장 많이 생각했던 말은, "에러 메시지도 결국은 서비스가 사용자에게 건네는 말이다"였습니다. 무역 업무처럼 한 번의 실수가 다음 단계로 도미노처럼 번지는 도메인에서는, 에러가 단순한 팝업이 아니라 "다음 행동을 안내하는 가이드" 여야 합니다.
처음엔 제 개인적인 바람으로 시작한 정리였지만, 지금은 Flow MATE의 자연스러운 표준이 되었습니다. 앞으로는 네트워크 에러뿐 아니라 런타임 에러까지 감싸는 통합 Error Boundary, 그리고 사용자가 직접 재시도/복구할 수 있는 Self-healing UI로 확장해 보려 합니다.
긴 글 읽어주셔서 감사합니다. 여러분의 프로젝트에서도 onError 블록이 하나씩 사라지길 바라겠습니다!
BAS KOREA IT챕터 프론트엔드, INSIK 드림.
Tags: React React Query TanStack Query TypeScript Frontend Error Handling 웹개발


