React-query 환경에서 효율적으로 오류 처리하기

React-query 환경에서 효율적으로 오류 처리하기

Tags
설계
오류핸들링
React-query
Published
February 29, 2024
Author
Seongbin Kim
 
 

1. 오류 처리 흐름

 

1-1. 오류 유형 별 화면에서의 표현 방법

  1. 조회 시 발생한 오류는
    1. API 오류인 경우 ApiErrorBoundary로 잡아요.
    2. 그 외의 오류인 경우 ErrorBoundary로 잡아요.
  1. 수정 시 발생한 오류는
    1. 응답 코드가 400인 경우 오류에 맞게 직접 Toast 등으로 표시해요.
    2. 응답 코드가 4xx, 5xx인 경우 전역에서 Toast로 표시해요.
    3. 그 외의 오류인 경우 ErrorBoundary로 잡아요.
  1. 그 외의 오류는
    1. ErrorBoundary로 잡아요.
 

1-2. 오류 유형 별 코드로 처리하는 방법

  1. 조회 API 오류를 처리하는 방법
    1. 2xx대 응답이 아니면 모두 throw해요.
  1. 수정 API 오류를 처리하는 방법
    1. Axios의 Response Interceptor에서 2xx, 400인 경우 정상 응답으로 반환해요.
    2. 그 외의 경우 defaultOptions.mutations.onError에 등록된 핸들러를 호출해요.
      1. AxiosError인 경우 Toast를 띄워요.
      2. 그 외의 경우 처리할 수 없으므로 throw 해요.
    3. 각 mutation에서 커스텀이 필요한 경우
 

1-3. 오류를 모니터링하는 방법

  1. 다음의 콜백 함수에서 Sentry 등의 도구로 오류를 전달할 수 있어요.
    1. 각 ErrorBoundary의 componentDidCatch
    2. React-query의 QueryCache, MutationCache의 onError
 

2. 비동기 오류 처리 시 주의 사항

 

2-1. 오류의 종류

JS 오류는 크게 동기, 비동기 오류로 구분할 수 있어요.
  1. 동기 코드
    1. 컴포넌트, 훅에서 실행한 동기 코드
  1. 비동기 코드
    1. 이벤트 핸들러
    2. Promise
    3. API 요청 (Axios, fetch + React-query 환경)
 

2-1-1. 동기 오류

이 때 동기 오류는 가장 바깥에 ErrorBoundary로 감싸주면 쉽게 잡을 수 있어요.
 

2-1-2. 비동기 오류

비동기 코드에서 발생하는 오류의 경우 Stack이 유지되지 않기 때문에
throw 시 호출된 곳에서 catch할 수 없어요.
 
따라서 throw하기 보다는 callback을 실행하는 방식으로
오류가 발생했음을 전달하고 표현해야 돼요.
 

2-2. 비동기 오류 발생 시 공통 처리 방법

 

2-2-1. 컴포넌트에 throw 위임하기

컴포넌트에서 대신 throw 하는 방식으로 ErrorBoundary에 오류 처리를 위임할 수 있어요.
핸들러를 try-catch로 감싼 후, catch에서 컴포넌트의 상태를 바꿔 throw해주는 방법이에요.
 
구현 예시: useErrorBoundary 커스텀 훅
import { useEffect, useMemo, useState } from 'react'; /** * Error Boundary로 오류를 전파하기 위한 훅이에요. * * 이벤트 핸들러와 같이 별도의 Stack에서 실행될 때 사용해요. * * 이벤트 핸들러는 감싸서 사용하면 돼요. * * @example * const bindThrow = useBindThrow(); * onClick: bindThrow(() => setIsOpen(true)), * * Promise의 경우 catch로 등록할 수 있어요. * */ const useErrorBoundary = () => { const [error, throwError] = useState<unknown>(undefined); useEffect(() => { if (!error) { return; } throw error; }, [error]); // FIXME: 타입 단언이 필요해요. 이 코드에선 생략되어 있어요. const passError = useMemo( () => <T extends Function>(handler: T): T => (...args: unknown[]) => { try { handler(...args); } catch (error) { throwError(error); } }, [], ); return { passError, throwError }; }; export default useErrorBoundary;
 

2-2-2. 이벤트 핸들러, setTimeout, requestAnimationFrame

사용 예시
import useErrorBoundary from '@/utils/useErrorBoundary'; export const HomePage = () => { const { passError } = useErrorBoundary(); const handleClick = passError(() => { throw 'handleClickError!!'; }); return ( <div className='flex flex-col'> <div className='grow'> <button onClick={handleClick}>Click me to throw</button> </div> </div> ); };
 

2-2-3. Promise 내의 throw, reject

 
사용 예시
// 컴포넌트 내부에서 Hook을 사용하기 const { throwError } = useErrorBoundary(); // Promise에 catch를 붙이기 getNotificationList().catch(throwError);
 

3. 오류 별 화면

 

3-1. 조회 오류

오류 페이지를 대신 렌더링할 수 있어요.
 
예시
notion image
 

3-1-1. 화면의 일부만 오류 페이지로 렌더링

화면 면적이 큰 PC의 경우 화면의 일부만 다시 시도할 수 있게 만들면 더 자연스러울 수도 있어요.
 
예시 (카카오 페이지 / 기술 블로그 글)
<오류 안내 문구와 재시도 버튼>을 해당 조회 영역 대신 렌더링할 수 있어요.
notion image
 

3-2. 수정 오류

페이지는 그대로 두고, <오류 안내 문구>를 토스트로 알릴 수 있어요.
 
예시
notion image
 
 
 
 

4. 구현 방법

 

4-1. ErrorBoundary

 

4-1-1. 필요 이유

ErrorBoundary는 자식 컴포넌트에서 throw가 발생하면 catch할 수 있는 컴포넌트에요.
 
예시
  • <NotificationList />에서 오류가 발생하면 MyErrorBoundary에서 알 수 있어요.
<ErrorBoundary> <NotificationList /> </ErrorBoundary>
 
 

4-1-2. 동작 방식

간단한 Class component에요.
 
자식 컴포넌트에서 발생한 오류가 있으면 호출되는componentDidCatch 메소드로
오류를 전달받고 오류 여부 상태도 전환해요.
이 때 본인의 오류 여부 상태에 따라 오류 페이지를 렌더링하면 돼요.
 

4-1-3. 구현

사용할 때 커스터마이징이 가능하도록, onReset, onCatch, fallback을 받아요.
  • onReset: 오류 화면에서는 리셋 역할의 버튼이 하나 존재하게 돼요. 그 버튼의 핸들러에요.
  • onCatch: 오류를 전달받는 콜백 함수에요.
  • fallback: 오류를 표시할 화면을 의미해요. onReseterror를 전달받아요.
import { Component, ComponentType, PropsWithChildren } from 'react'; // Fallback 컴포넌트와 그 prop 타입이에요. 알아서 바꾸시면 돼요. import ErrorAlertFullScreen, { type ErrorAlertProps, } from '../ErrorAlertFullScreen'; export interface ErrorBoundaryProps extends PropsWithChildren { onReset?: () => void; onCatch?: (error: unknown) => void; fallback?: ComponentType<ErrorAlertProps>; } interface State { error: unknown; isError: boolean; } const defaultState = { error: undefined, isError: false, }; /** * 자식 컴포넌트에서 오류를 잡는 경우 `fallback prop`을 대신 표시해요. * * 오류를 더 상위 ErrorBoundary에서 처리하게 만드려면 `onCatch`에서 throw를 할 수 있어요. */ export default class ErrorBoundary extends Component< ErrorBoundaryProps, State > { constructor(props: ErrorBoundaryProps) { super(props); this.state = defaultState; this.resetErrorState = this.resetErrorState.bind(this); } resetErrorState() { this.props.onReset?.(); this.setState(defaultState); } componentDidCatch(error: unknown) { this.props.onCatch?.(error); this.setState({ error, isError: true }); } render() { const { error, isError } = this.state; const { children, fallback: Fallback = ErrorAlertFullScreen } = this.props; if (!isError) { return children; } return <Fallback error={error} onReset={this.resetErrorState} />; } }
 
 

4-2. QueryErrorResetBoundary

 

4-2-1. 필요 이유

오류 페이지에서 사용한 Query들의 상태를 자동으로 reset 시켜줘요.
QueryErrorResetBoundary를 사용하지 않으면 수동으로 resetQueries 함수를 실행해줘야 돼요.
 

4-2-2. 개념 설명

Query 실행 중 오류가 반환되는 경우 query의 statuserror가 돼요.
 
이 상태에서는 다시 useQuery 훅을 호출해도 fetch 하지 않고 기존 error를 다시 던져요.
Query가 Error 상태인 경우 명시적으로 해제해주지 않으면 계속 error 상태가 유지되기 때문에요.
따라서 ErrorBoundary 화면으로 돌아오게 돼요.
 
notion image
 
error 상태의 query를 reset 수동으로 reset하려면 resetQueries 함수를 사용할 수 있어요.
queryClient.resetQueries({ queryKey })
 
실제로 사용되는 queryKey와 결합되게 때문에 추천하지는 않아요.
QueryErrorResetBoundary 사용을 추천해요.
 

4-3-3. 구현

ErrorBoundary와 함께 사용해요.
  • QueryErrorResetBoundary는 ErrorBoundary와 같은 componentDidCatch 기능이 없기 때문이에요.
  • ErrorBoundary에 onReset을 넘겨 쿼리를 리셋해요.
import { QueryErrorResetBoundary } from '@tanstack/react-query'; import ErrorBoundary, { ErrorBoundaryProps } from './ErrorBoundary'; /** * 하위 컴포넌트의 error 상태의 query의 상태를 reset 해요. */ const ApiErrorBoundary = ({ onCatch, onReset, fallback: Fallback, children, }: ErrorBoundaryProps) => { // ErrorBoundary의 prop을 그대로 이용해요. return ( <QueryErrorResetBoundary> {({ reset: resetErrorStateQueries }) => { const handleReset = () => { resetErrorStateQueries(); onReset?.(); }; return ( <ErrorBoundary fallback={Fallback} onReset={handleReset} onCatch={onCatch} > {children} </ErrorBoundary> ); }} </QueryErrorResetBoundary> ); }; export default ApiErrorBoundary;
 

4-3. queryClient.defaultOptions.mutations.onError

 

4-3-1. 필요 이유

Mutation시 발생하는 오류의 공통 처리를 할 때 사용해요.
 
Mutation에서는 throw하면 ErrorBoundary로 전달되어서 Toast를 띄우기 어려워요.
이 경우 defaultOptions.mutations.onError를 활용할 수 있어요.
 
useMutation에서 onError를 정의하지 않으면 해당 핸들러가 호출되게 돼요.
 

4-3-2. 구현

const queryClient = new QueryClient({ defaultOptions: { queries: { /* ... */ }, mutations: { onError: (error: unknown) => { if (!isAxiosError(error)) { throw error; } return showErrorToast( '요청을 처리할 수 없었습니다.\r\n잠시 후 다시 시도해주세요.', ); }, }, }, // ... }
 

4-4. QueryCache, MutationCache의 onError

 

4-4-1. 필요 이유

React-query 환경에서 발생한 오류를 모니터링할 때 사용할 수 있어요.
 
모든 Query, Mutation에서 발생한 오류가 catch 여부와 상관 없이 전달돼요.
따라서 모니터링 용도로는 이 콜백 함수만 등록해서 사용해도 충분해요.
 

4-4-1. 구현

// Sentry와 같은 로그 수집 및 모니터링 도구 사용 시 const sendToSentry = (error: unknown) => { /* ... */ }; const queryClient = new QueryClient({ // ... queryCache: new QueryCache({ onError: (error, queryConfig) => { console.log(error, queryConfig); sendToSentry(error); }, }), mutationCache: new MutationCache({ onError: (error, queryConfig) => { console.log(error, queryConfig); sendToSentry(error); }, }), });