React Query 훅에서 에러를 삼키면 안 되는 이유

Mechanism vs Policy — 훅과 호출부의 책임 분리

react-querycustom-hookserror-handling

난이도: ★★★☆☆


핵심 요약

  1. 훅은 단순히 패칭하는 메커니즘 역할만 한다. 성공 시·실패 시에 따른 후속 처리(정책)는 훅이 정하지 않는다. 그래서 에러가 나면 그냥 에러를 던진다. data, error에 대한 처리는 훅 밖에서 한다 — 그에 따른 로직(화면 분기, fallback 등)을 태우는 쪽이 호출부이기 때문이다.
  2. 훅 안에서 try/catch로 에러를 잡아 undefined를 리턴하면, 그 Promise는 resolve된다 — undefined도 결국 에러는 아니기 때문이다. 그러면 react-query 입장에서는 “정상적으로 끝났는데 데이터가 없다”가 되어 isError: false, isSuccess: true가 된다. 원래 기대했던 “reject → isError: true → retry 발동”이라는 흐름 자체가 사라진다. 에러가 조용히 성공으로 둔갑하는 것이다.

왜 헷갈렸나

  • 처음엔 “훅 안에서 에러를 catch해서 undefined를 리턴하고, 밖에서 그 undefined를 처리하면 되지 않나?”라고 생각했다. 겉보기엔 호출부가 undefined를 방어하니 안전해 보인다.
  • isError가 어떻게 될지도 처음엔 “isError는 true일 텐데 error 객체만 없는 것”이라고 잘못 짚었다 — 즉 “에러는 났는데 정보가 유실된 상태”라고 생각한 것.
  • 실제로는 그게 아니다. catch로 잡는 순간 함수가 정상적으로 값을 반환한 것으로 취급되어 Promise가 resolve되고, isError는 애초에 true가 되지 않는다. “정보가 유실된 에러 상태”가 아니라 “에러 상태 자체가 존재하지 않게 되는 것”이 핵심 차이다.

메커니즘

[queryFn 내부에서 삼킴 — 안 좋은 패턴]

  fetch 실패
     │ reject
     ▼
  catch { return undefined }
     │
     ▼
  Promise가 RESOLVE됨 (undefined도 "정상 값")
     │
     ▼
  react-query: isSuccess=true, isError=false, data=undefined
     │
     ▼
  retry 없음, error 없음 → 에러가 조용히 성공으로 둔갑


[에러를 그대로 던짐 — 올바른 패턴]

  fetch 실패
     │ reject (가로채지 않고 그대로 전파)
     ▼
  react-query가 rejection을 캐치
     │
     ▼
  isError=true, error=<실제 에러 객체>, 자동 retry 실행
     │
     ▼
  호출부에서 isError로 명시적 분기 가능

Promise에는 resolve/reject라는 두 개의 독립된 채널이 있고, react-query의 isError/isSuccess는 정확히 이 채널을 그대로 반영한다. queryFn 안에서 catch하는 순간, 실패였다는 사실이 reject 채널에서 resolve 채널로 옮겨져 버린다.


해결 패턴

// ❌ 훅이 정책까지 떠맡음 — 에러를 삼켜서 성공으로 둔갑시킴
export const useServerCondition = () => {
  return useQuery<{ flag: boolean }>("server-condition", async () => {
    try {
      const { data } = await axios.get("/condition");
      return data;
    } catch {
      return undefined; // Promise가 resolve됨 → isError는 영원히 false
    }
  });
};

// ✅ 훅은 mechanism만 — 에러는 그대로 던진다
export const useServerCondition = () => {
  return useQuery<{ flag: boolean }>("server-condition", async () => {
    const { data } = await axios.get("/condition");
    return data; // 실패하면 axios가 reject → react-query가 그대로 캐치
  });
};

// 호출부(정책) — 로딩/에러/성공을 명시적으로 구분해서 처리
const { data, isLoading, isError } = useServerCondition();
const flag = data?.flag ?? false; // "정답"이 아니라 "일단 이 값으로 처리"임을 스스로 인지

다음에 이 상황을 만나면

커스텀 데이터 훅을 만들다가 try/catch를 쓰고 싶어지면 → 그 catch가 정책을 대신 정하고 있는 건 아닌지 점검한다.

  • try/catch로 에러를 잡고 undefined/null 같은 “정상처럼 보이는 값”을 리턴하고 있지 않은가?
  • 실패했을 때 “무엇을 보여줄지”를 훅이 대신 정하고 있지 않은가? (그건 호출부 몫)
  • 호출부에서 isError를 실제로 꺼내 쓰고 있는가, 아니면 data만 보고 있는가?

커넥팅 닷

← 선행 개념 (이걸 알아야 이해된다)

  • Promise의 resolve/reject — 성공과 실패를 나타내는 두 개의 독립된 채널. catch는 이 채널 자체를 바꿔버린다는 걸 알아야 이 노트가 이해된다.

→ 확장 개념 (여기서 더 나아가면)

  • Mechanism vs Policy — Butler Lampson이 OS 커널 설계에서 정리한 원칙(“무엇이 일어났는지 알리는 것”과 “그래서 뭘 할지 결정하는 것”을 분리). 커스텀 훅 설계에도 그대로 적용된다.

↔ 같은 원리가 적용되는 곳

  • Express/Node의 에러 핸들링 미들웨어 — 라우트 핸들러는 에러를 그냥 throw하고, 상태 코드·응답 형식 같은 정책은 중앙 에러 미들웨어가 결정한다. “던지는 쪽은 판단하지 않는다”는 같은 원리.

부록: queryFn 안의 try/catch가 정당화되는 경우

queryFn 안에 catch가 있다고 무조건 안티패턴은 아니다. 기준은 catch 이후 rethrow냐 swallow냐다.

  • ❌ catch 후 값을 반환(swallow) → Promise가 resolve됨 → 본문에서 다룬 안티패턴
  • ✅ catch 후 다시 throw(rethrow) → Promise는 여전히 reject → react-query는 그대로 에러로 인식

rethrow가 정당화되는 대표 케이스는 원본 에러를 더 의미 있는 도메인 에러로 변환할 때다:

export const useServerCondition = () => {
  return useQuery<{ flag: boolean }>("server-condition", async () => {
    try {
      const { data } = await axios.get("/condition");
      return data;
    } catch (err) {
      if (axios.isAxiosError(err) && err.response?.status === 404) {
        throw new Error("조건 정보를 찾을 수 없습니다"); // 원본 에러를 도메인 에러로 변환해 재던짐
      }
      throw err; // 그 외엔 그대로 재던짐
    }
  });
};

여기서도 catch는 “정책”(뭘 보여줄지)을 정하지 않는다 — “어떤 종류의 에러인지 이름 붙이기”까지만 하고, 그 이후 판단은 여전히 호출부·전역 설정 몫이다.

그럼 에러 핸들링 자체는 어디서 하나? (react-query v3 기준)

위치역할
retry 옵션 (기본 3회, exponential backoff)일시적 실패 자동 재시도 — mechanism
onError 콜백 (useQuery 옵션 또는 QueryClient의 전역 defaultOptions.queries.onError)에러 발생 시 부수효과(토스트, 로깅) — policy
useErrorBoundary: true에러를 렌더링 단계로 던져 가까운 Error Boundary가 처리 — policy
컴포넌트에서 isError/error 직접 소비화면 분기 — policy

포인트: onError도 useErrorBoundary도 queryFn 안이 아니라 호출부(또는 QueryClient 생성 시점)에서 넘기는 옵션이다. react-query 자체가 “에러가 났을 때 뭘 할지는 옵션으로 주입해라, fetch 함수 안에 박아두지 마라”는 구조로 설계돼 있다 — mechanism/policy 분리가 라이브러리 API 형태로 이미 강제돼 있는 것.