⚠️ 03. 에러 핸들링과 전역 콜백(Global Callbacks) 아키텍처

2026년 4월 30일 수정됨

📋 개요

로컬 try-catch의 한계를 넘어서, QueryCache 단위의 전역 에러 핸들링과 React Error Boundary 결합을 통한 최상의 에러 방어망(Defensive UX) 구축을 다룹니다.

📋 목차

"영철 님, 만약 유저 토큰이 갓 만료되어서 터진 '401 Unauthorized API' 호출이 1초 사이에 5개가 동시다발적으로 돌아갔다면, 브라우저에 '로그인 해주세요' 토스트 알림을 5개 동시에 띄우고 유저한테 욕먹을 건가요?"

☕️ 영철이의 고민: "에러 토스트 창이 화면을 다 덮어버려요!"

(목요일 오후, 화면을 빼곡히 덮은 빨간색 실패 알람 창들에 식은땀을 흘리는 영철)

🐣 영철: 저기... 🦁 리드 님. 제가 각 useQuery 컴포넌트마다 아래처럼 방어 로직을 참 잘 짜놨거든요?

const { data, isError, error } = useQuery(['user'], fetchUser);
 
useEffect(() => {
  // ⚠️ 각 컴포넌트의 독단적인 에러 핸들링
  if (isError) {
    if (error.response.status === 401) {
      toast.error("토큰이 만료되었습니다. 다시 로그인 해 주세요.");
      router.push('/login');
    }
  }
}, [isError, error]);

근데 갑자기 백엔드(영수) 님이 잠시 배포한다고 서버를 10초 내렸거든요? 그 사이에 제 대시보드 화면에 있던 10개의 위젯(각각 다른 API 호출 중)이 일제히 응답을 못 받더니, 에러 토스트(알림창)가 우다다다 매우 파바박 뜨면서 화면을 벌겋게 덮어버렸어요 ..

🦁 영호: 영철 님... API 하나하나의 아주 끄트머리인 로컬(Local) 컴포넌트 단위 에서 저렇게 "인증 에러"나 "500 서버 붕괴 에러" 같은 치명적인 상태를 다루려고 하니까 10중 급증이 치는 겁니다.
이런 중대한 공통 에러는 컴포넌트 내부(로컬)에서 잡는 게 아닙니다. 어차피 모든 네트워크 비동기 에러가 귀결되어 쏟아지는 최상단 중앙 댐, 바로 QueryClient의 캐시(Cache) 전역 콜백 단에서 통합 타격을 가해야죠.


🤔 왜 알아야 하는가: 댐을 어디에 건설할 것인가?

우리는 그동안 에러 처리를 할 때마다 useQuery 혹은 useMutation 설정 객체의 onError 콜백이나 isError 상태 변수만 바라보았습니다. 이건 "자신의 컴포넌트만 지키는 작은 우산"입니다.

  • 문제점 1: 영철이의 사례처럼 A 컴포넌트, B 컴포넌트, C 컴포넌트가 동시에 401 에러를 만났을 때, 코드 중복이 반복적으로 일어납니다.
  • 문제점 2: useMutation({ onError: ... }) 안쪽에 로그인을 튕구는 로직을 넣어두면? 다른 개발자가 나중에 똑같은 로그인 연동 Mutation을 만들 때마다 방어 코드를 "복붙"해야 합니다. 누군가 하나라도 까먹으면? 곧장 보안 버그가 됩니다. (TkDodo 아티클 #11 참조)

진짜 성숙한 5년 차의 프론트엔드 라면, 개별 컴포넌트의 우산을 다 버리고 QueryCacheMutationCache 라는 가장 높은 하늘에 전천후 방어 돔(전역 콜백) 을 치는 아키텍처를 세팅해야 합니다.


1. 최상단 수문장: Global Cache Callbacks 세팅

React Query v5(App Router) 환경의 QueryClient 생성 팩토리 코드로 돌아가 봅시다(Basic 09강 참조). 여기에 진정한 의미의 방어 시스템을 결합합니다.

// 📝 app/providers/get-query-client.ts
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
import { toast } from 'react-hot-toast'; // 글로벌 토스트 라이브러리 예시
 
function makeQueryClient() {
  return new QueryClient({
    // 🚀 [1] 데이터를 가져오는 Query(GET) 전역 에러 제어
    queryCache: new QueryCache({
      // 지구상의 어떤 useQuery 훅에서 에러가 발생하든 결국 이곳으로 한 번 불려옵니다.
      onError: (error, query) => {
        // 백그라운드 재귀(Refetch) 중에 발생한 에러는 짜증 나니까 살짝 무시하고...
        if (query.state.data !== undefined) {
          toast.error(`최신 데이터를 가져오는 데 실패했습니다: ${error.message}`);
          return;
        }
 
        // 치명적인 401, 500 전역 라우팅 핸들링 로직은 오로지 이 한 곳에 응집시킴!
        if (error.status === 401) {
          toast.error('세션이 만료되었습니다.');
          window.location.href = '/login'; // 글로벌 로그인 강제 이동 (단 1회 급증)
        }
      },
    }),
 
    // 🚀 [2] 데이터를 바꾸는 Mutation(POST/DELETE) 전역 에러 제어
    mutationCache: new MutationCache({
      onError: (error, _variables, _context, mutation) => {
        // 해당 Mutation을 호출한 컴포넌트에서 "나 스스로 onError 처리 잘 해놨어요!"
        // 라고 선언한 경우(meta 객체를 통해)에는 전역 토스트를 띄우지 않고 로컬에 맡김
        if (mutation.meta?.bypassGlobalError) return;
 
        toast.error(`저장 중 오류가 발생했습니다: ${error.message}`);
      }
    }),
  });
}

이제 전 세계 컴포넌트 100군데에서 퍼져있던 401 인증 튕김 로직이나 isError 잡동사니 코드들이 순삭 증발합니다. UI 개발자는 그저 "화면에 데이터 예쁘게 그리기" 에만 집중하게 되죠!


2. Error Boundary의 아름다운 결합 (Declarative UX)

🐣 영철: 아 전역 처리 좋네요! 근데 로컬 쪽에선 "데이터 자체가 손상나서 isError === true 일 때, 아예 화면 대신 '빨간색 에러 났어 안내' 컴포넌트(Fallback UI)를 그려줘야" 할 때도 있잖아요. 그건 결국 개별 컴포넌트에서 if (isError) return <div>에러남</div> 써야 하는 거 아닌가요?

🦁 영호: 네, 맞습니다. 하지만 컴포넌트 안쪽에 if (isError) 분기를 반복하는 방식도 유지보수가 어렵습니다. 에러가 났을 때 "UI를 무엇으로 (선언적으로) 렌더링 할 것인가"는 React 본연의 강력한 기능인 Error Boundary 에게 위임해야 합니다.

React Query 옵션 중 throwOnError: true (v5 변경점, 예전엔 useErrorBoundary) 를 켜면, 훅 내부에서 잡고 있던 꼬리가 React의 기본 에러 시스템으로 탁! 던져집니다(throw).

export const todoOptions = {
  detail: (id: number) => queryOptions({
    queryKey: ['todos', id],
    queryFn: fetchTodoDetail,
    // 🔥 마법의 지시어: "에러 나면 내가(isError) 쥐고 있지 않고 에러 바운더리로 위험 요소 던질게!"
    throwOnError: true,
  }),
};

이렇게 하면, 이제 이 데이터를 소비하는 컴포넌트 코드는 불순물이 1g도 안 섞인 "순수 데이터 정상 렌더링 코드" 로 극한의 캡슐화가 됩니다.

import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';
 
// 화면 최상단 또는 페이지 레이아웃 단
function Page() {
  return (
    // 터진 위험 요소을 캐치해서 예쁜 "다시 시도" 에러 카드 그려주는 우산 ☂️
    <ErrorBoundary fallback={<ErrorFallbackCard />}>
      <Suspense fallback={<Spinner />}>
        {/* 🎉 이 대상 내부엔 온갖 예외 제어물(if isLoading, if isError)이 아예 없습니다! */}
        <PureTodoDetail id={1} />
      </Suspense>
    </ErrorBoundary>
  )
}

이 패턴이 바로 선언적 비동기 UX의 정점, 이른바 "Suspense + Error Boundary + React Query" 대통합 패턴입니다.


📝 배운 내용 점검하기 (Quiz)

Q. 전역 MutationCache 안에 토스트 실패 알림(onError)을 달아두었습니다. 그런데 개발 중, 어느 한 특정 버튼(게시글 좋아요 취소) 만큼은 전역 에러 팝업을 띄우지 않고 조용히 뒷단에서 처리하거나 다른 스타일로 예외 처리를 하고 싶어졌습니다. 이때 팩토리 전역 코드를 더럽히지(수정하지) 않고, 해당 로컬 컴포넌트 쪽의 Mutation에서 전역 콜백을 "우회(Bypass)" 할 수 있게 알려주는 가장 권장되는 React Query 문법/옵션은 무엇입니까?

  • A) 훅 옵션에 ignoreGlobalError: true 같은 자체 내장 옵션을 건다.
  • B) 불가능하다. 전역 캐시에 박힌 로직은 반드시 지구상 모든 훅에 동일하게 적용되므로 우회할 수 없다.
  • C) useMutation ({ meta: { bypassGlobalToast: true } }) 처럼 커스텀 인계 메타객체(meta) 안에 키 값을 달아, 전역 캐시가 그 값을 읽고 분기 처리하게 만든다.

정답: C

💡 상세 해설:

  • 원리 설명: React Query의 useQueryuseMutation 에는 아주아주 강력하지만 유저들이 잘 모르는 마법의 뒷구멍인 meta 객체가 존재합니다(TkDodo 아티클 #11 역설). 이 meta 객체의 키와 값들은 오직 정보 전달 용도로만 쓰이며, 최상단 글로벌 에러 핸들러인 (QueryCache/MutationCache 의 콜백)의 4번째 인자로 고스란히 배달됩니다. 글로벌 핸들러는 이 meta 꼬리표를 읽어보고 "아! 이 Mutation은 글로벌 토스트를 띄우지 말라고 부탁했구나! 무시하자~" 라고 스마트하게 우회 분기를 태울 수 있습니다.
  • 오답 피드백: "영철 님, A번처럼 라이브러리에 없는 맘대로 지어낸 이름 넣으면 TS 에러 나고 발생합니다! 오로지 개발자 전용 통신 규약인 meta 옵션을 통해서만 훅 깊숙한 안쪽에서부터 최상단 전역 캐시 대기권 바깥쪽으로 무전(signal)을 때릴 수 있는 겁니다."
  • 📌 핵심 기억법: 로컬 훅이 전역 댐 통제실에 몰래 귓속말을 보내는 유일한 뒷구멍, meta 옵션!

📝 마무리 퀴즈

Q1. 여러 컴포넌트의 useQuery마다 토스트 에러 처리를 넣으면 어떤 문제가 생기나요?

정답: 같은 장애가 여러 토스트로 중복 노출되고, 인증 만료 같은 공통 정책이 컴포넌트마다 흩어집니다.

💡 상세 해설:
401, 네트워크 장애, 서버 점검 같은 에러는 화면 단위가 아니라 앱 정책으로 다뤄야 할 때가 많습니다. QueryCache나 MutationCache의 전역 콜백은 중복 처리와 정책 분산을 줄여줍니다. 영호는 에러 메시지보다 에러의 소유 위치를 먼저 봅니다.

Q2. meta 필드를 전역 에러 핸들러와 함께 쓰는 이유는 무엇인가요?

정답: 개별 쿼리나 뮤테이션이 전역 핸들러에 분기 힌트를 전달해 예외 정책을 선언할 수 있기 때문입니다.

💡 상세 해설:
모든 에러가 같은 토스트를 띄워야 하는 것은 아닙니다. 삭제 확인 모달 내부에서 이미 에러를 처리한다면 meta: { skipGlobalToast: true } 같은 신호를 전역 콜백이 읽게 할 수 있습니다. 중앙화와 예외 허용을 함께 잡는 방식입니다.

Q3. 영철이의 테스트 타임: 대시보드 위젯 10개가 동시에 401을 받아 로그인 만료 토스트가 10개 뜹니다. 가장 먼저 바꿀 설계는 무엇인가요?

정답: 개별 컴포넌트 토스트를 제거하고 QueryClient의 전역 에러 콜백에서 401 정책을 한 번 처리하도록 모읍니다.

💡 상세 해설:
인증 만료는 위젯 개별 문제가 아니라 세션 정책입니다. 전역 콜백에서 중복 방지와 라우팅 처리를 담당하면 화면 컴포넌트는 데이터 표시 책임에 집중할 수 있습니다. 이게 에러 처리의 캐시 레벨 아키텍처입니다.

🐣 영철이의 퇴근 일기

아까 백엔드 401이 동시에 발생했을 때 화면에 토스트 알림이 12개나 쌓였던 장면이 떠올랐다. 이걸 최상단 QueryCache로 모으니까 같은 인증 만료를 한 번만 처리하고 로그인 화면으로 자연스럽게 안내할 수 있었다.

💡 오늘의 교훈: "로컬 훅(onError, isError)에서 모든 문제를 처리하려고 하지 말자. 인증 만료나 500 응답처럼 공통 정책이 필요한 오류는 전역 캐시(QueryCache, MutationCache)에서 한 번 처리하고, 로컬 UI 오류는 throwOnError: true로 Error Boundary에 위임하자."

결국 시니어 영호 님이 항상 강조하던 "너의 컴포넌트가 너무 많은 책임을 알게 하지 말라"는 철학이 상태 관리뿐만 아니라 에러 방어망(Defensive UX) 설계에도 똑같이 통하는 거였다. 인상적이다... 오늘은 맥주 말고 에스프레소 마시고 이 철학을 메모해둬야겠다. ☕️