에러 처리의 필요성
우리가 만든 서비스에서는 정말 다양한 원인으로 에러가 발생합니다. 기존에는 에러가 발생할 때마다 개별적으로 처리해 줬었는데요. 이렇게 하면 코드가 복잡해질 뿐 아니라 에러를 처리하는 방법이 제각각이다 보니 사용자 경험이 일관되지 않았습니다. 심지어는 에러 처리를 잊어버리는 경우도 있었고요. 이런 문제를 해결하기 위해서 Error Boundary를 활용해서 에러 처리 방식을 체계적으로 정리하기로 했습니다.
에러의 종류
에러가 발생하는 위치와 원인에 따라 처리 방식도 달라집니다. 에러가 발생할 수 있는 지점들을 분석해 보니 크게 3가지 계층으로 나눌 수 있었습니다.
- Router 외부 에러 : 앱 전체에서 발생할 수 있는 예기치 못한 에러
- Router 내부 에러 : 라우팅 과정이나 특정 페이지에서 발생하는 에러
- API 에러 : 서버와의 통신 중에 발생하는 에러
이렇게 에러가 발생하는 계층을 구분한 뒤, 계층별로 적절한 Error Boundary를 두고 API 에러는 커스텀 useQuery
와 useMutation
을 이용해서 일관되게 처리하기로 했습니다.
API 에러 처리하기
useQuery, useMutatation을 커스텀하자
React Query의 useQuery
와 useMutation
을 그대로 사용할 때는 에러 처리를 위해서 각 API 호출마다 onError
를 작성해 줘야 하는데요. 이런 방법에는 다음과 같은 문제가 있었습니다.
- 에러 처리 코드의 중복 : 여러 API 호출에서 비슷한 에러 처리 로직을 반복해서 작성해야 합니다.
- 일관성 부족 & 안정성 부족 :
onError
를 빼먹거나, 에러를 잘못 처리하는 경우가 종종 발생해서 일관된 에러 경험을 제공하지 못하거나, 서버 에러 화면을 그대로 보여주는 상황이 생깁니다.
이런 문제를 해결하기 위해 에러 처리 방식을 한 곳에서 제어하고 기본 에러 처리 핸들러를 제공하는 useApiError
커스텀 훅을 만들고, 기존 useQuery
, useMutation
과 비슷하게 사용하면서도 내부에서 useApiError
훅을 함께 사용하는 커스텀 useQuery
, useMutation
을 만들기로 했습니다.
useApiError 훅
useApiError
훅에서는 상태 코드별로 에러 핸들러를 등록하거나 기본 에러 핸들러를 등록할 수 있고, Error Boundary를 건너뛸 (에러 페이지로 이동하지 않을) 에러를 확인하는 역할을 합니다.
1const useApiError = <TError = Error>({2errorHandlers,3ignoreBoundaryErrors,4}: {5errorHandlers?: ErrorHandlers;6ignoreBoundaryErrors?: IgnoreBoundaryErrors;7}) => {8const handlers = useMemo(9() => ({ ...defaultHandlers, ...errorHandlers }),10[errorHandlers]11);1213// customErrorHandlers를 우선 처리하고, defaultHandler를 처리합니다.14const handleError = useCallback(15(error: TError) => {16if (isAxiosError(error)) {17const status = error.response?.status;18if (status) {19const customHandler = handlers[status] || handlers.default;20customHandler();21} else {22handlers.default();23}24} else {25handlers.default();26}27},28[handlers]29);3031// ignoreBoundaryErrors에 포함된 에러코드인지 확인합니다.32const shouldThrowError = useCallback(33(error: TError) => {34if (isAxiosError(error)) {35const status = error.response?.status;36if (status) {37return !ignoreBoundaryErrors?.includes(status);38}39}40return true;41},42[ignoreBoundaryErrors]43);4445return { handleError, shouldThrowError };46};
handleError
: 에러가 발생했을 때 상태 코드에 맞는 핸들러를 실행합니다.shouldThrowError
: 에러를 Error Boundary로 넘길 것인지 결정합니다.
이렇게 커스텀 훅을 적용해서 각 API 요청마다 중복되는 에러 처리 코드를 줄이고, 에러 처리를 잊지 않도록 기본 에러 처리 핸들러를 호출할 수 있습니다.
커스텀 useQuery 훅
useQuery
를 useApiError
훅과 함께 래핑한 useQueryWithHandlers
훅입니다. useQuery
의 기존 인자들 뒤에 에러 핸들러(errorHandlers
)와 Error Boundary로 바로 전달하지 않을 에러 코드(ignoreBoundaryErrors
)를 함께 전달할 수 있습니다. 내부적으로 useApiError
훅을 사용해서 에러가 발생했을 때 상태 코드에 따라 등록한 핸들러를 먼저 실행하고, 필요에 따라서는 Error Boundary로 바로 전달되는 에러와 분리해서 관리할 수도 있습니다.
1const useQueryWithHandlers = <2TQueryFnData = unknown,3TError = Error,4TData = TQueryFnData,5TQueryKey extends QueryKey = QueryKey,6>(7options: UseQueryWithHandlersOptions<TQueryFnData, TError, TData, TQueryKey>,8queryClient?: QueryClient9) => {10const { errorHandlers, ignoreBoundaryErrors, ...restOptions } = options;11// 인자로 전달받은 커스텀 에러 핸들러 호출과 ErrorBoundary 예외 처리를 위한 훅12const { handleError, shouldThrowError } = useApiError<TError>({13errorHandlers,14ignoreBoundaryErrors,15});1617// throwOnError 옵션에 shouldThrowError를 연결해서 ErrorBoundary 사용 여부 제어18const query = useQuery(19{ ...restOptions, throwOnError: shouldThrowError },20queryClient21);2223// 쿼리에서 에러가 발생하면 handleError를 호출24// 에러에 해당하는 적절한 에러 핸들러를 호출하는 부분 (useApiError)25useEffect(() => {26if (query.error) {27handleError(query.error);28}29}, [query.error, handleError]);3031return query;32};
이렇게 만든 useQueryWithHandlers
훅은 아래처럼 사용할 수 있습니다.
1const useGetGroupBasicInfo = (2groupToken: string,3errorHandlers: ErrorHandlers,4ignoreBoundaryErrors: IgnoreBoundaryErrors5) => {6return useQueryWithHandlers({7queryKey: ['groupBasicInfo', groupToken],8queryFn: () => group.get(groupToken),9errorHandlers, // ✅10ignoreBoundaryErrors, // ✅11});12};1314const { data, isLoading, isError } = useGetGroupBasicInfo(15groupToken,16{17// 403 에러에 호출할 에러 핸들러 -> 에러 바운더리에서 처리할 커스텀 에러를 던진다.18403: () => {19throw new BoundaryError({20title: '접근 권한이 없어요',21description: '모임의 총무만 참여자를 추가할 수 있어요.',22});23},24},25[403] // 403 에러는 에러 바운더리로 바로 전달하지 않는다. (커스텀 핸들러에서 처리)26);
커스텀 useMutation 훅
useMutation
도 useApiError
훅과 함께 래핑해서 useMutationWithHandlers
훅을 만들었습니다. useQueryWithHandlers
와 동일하게 기존 useMutation
훅의 인자 뒤에 에러 핸들러(errorHandlers
)와 Error Boundary를 건너뛸 에러 코드(ignoreBoundaryErrors
)를 함께 전달할 수 있습니다.
useQueryWithHandlers
와 다른 점은 useMutation
에는 뮤테이션 요청에서 에러가 발생했을 때 즉시 핸들러를 실행할 수 있는 onError
옵션이 있어 에러 감지에 useEffect
를 사용하지 않았다는 점입니다.
1const useMutationWithHandlers = <2TData = unknown,3TError = Error,4TVariables = void,5TContext = unknown,6>(7options: UseMutationWithHandlersOptions<TData, TError, TVariables, TContext>,8queryClient?: QueryClient9) => {10const { errorHandlers, ignoreBoundaryErrors, ...restOptions } = options;11const { handleError, shouldThrowError } = useApiError<TError>({12errorHandlers, // ✅13ignoreBoundaryErrors, // ✅14});1516return useMutation(17{ ...restOptions, onError: handleError, throwOnError: shouldThrowError },18queryClient19);20};
이렇게 정의한 useMutationWithHandlers
는 아래처럼 사용할 수 있습니다.
1const useAddGroupMember = (2groupToken: string,3errorHandlers: ErrorHandlers,4ignoreBoundaryErrors: IgnoreBoundaryErrors5) => {6const queryClient = useQueryClient();78return useMutationWithHandlers({9mutationFn: groupMembers.put,10onSuccess: () => {11queryClient.invalidateQueries({12queryKey: ['groupBasicInfo', groupToken],13});14},15errorHandlers,16ignoreBoundaryErrors,17});18};1920const addMutation = useAddGroupMember(21groupToken,22{23// 409 에러에 호출할 에러 핸들러 -> 에러 토스트를 띄운다.24409: () =>25showToast({26type: 'error',27content:28'이미 같은 이름의 참여자가 있어요. 다른 이름으로 입력해 주세요.',29}),30},31[409] // 409 에러는 에러 바운더리로 바로 전달하지 않는다. (커스텀 핸들러에서 처리)32);
정의되지 않은 API 에러들도 함께 처리하기
useQuery
, useMutation
은 기본적으로 에러를 던지지 않는데요. 정의된 에러들은 useApiError
훅을 이용해서 에러를 던져서 Error Boundary로 넘기거나 그 안에서 적절하게 처리했지만 정의되지 않은 에러들 역시도 기본적으로 Error Boundary로 이동시키기 위해서 QueryClient
에서 throwOnError
옵션의 값을 true
로 설정했습니다.
1new QueryClient({2defaultOptions: {3mutations: {4onError: handleMutationError,5throwOnError: true, // 기본적으로 RouteErrorBoundary로 에러를 던집니다.6},7queries: {8throwOnError: true, // 기본적으로 RouteErrorBoundary로 에러를 던집니다.9},10},11queryCache: new QueryCache({12onError: handleQueryError,13}),14})
Error Boundary
Error Boundary는 하위 컴포넌트 트리에서 랜더링 중 발생한 에러를 감지해서 fallback UI를 보여줄 수 있는 컴포넌트입니다. 여기서는 공식 문서에서 권장하는 함수형 구현인 react-error-boundary를 사용했습니다.
- Catching rendering errors with an error boundary – React
- GitHub - bvaughn/react-error-boundary: Simple reusable React error boundary component
에러의 발생 위치에 따라 직접 정의한 두 가지 Error Boundary와 React-Router의 Error Boundary를 사용합니다.
GlobalErrorBoundary
GlobalErrorBoundary
는 가장 바깥쪽의 Error Boundary인데요. Router 바깥에서 발생하는 에러를 잡습니다. 예를 들어서 Router 외부의 Layout 컴포넌트에서 에러가 발생하면 여기서 처리됩니다.
1<GlobalErrorBoundary> {/* 🚨 여기서 에러 캐치! */}2<QueryClientProvider client={queryClient}>3<Layout> {/* 💥 여기서 에러 발생 */}4<GlobalStyles />5<AppRouter /> {/* 👈 여기에 Router! */}6<ReactQueryDevtools />7<Toast />8</Layout>9</QueryClientProvider>10</GlobalErrorBoundary>
RouteErrorBoundary
RouteErrorBoundary
에서는 GlobalErrorBoundary
에서 잡지 못하는 Router 내부(경로별 페이지 랜더링 중)에서 발생하는 에러를 처리합니다. 커스텀 한 useQueryWithHandlers
, useMutationWithHandlers
에서 던지는 BoundaryError
가 바로 이 Boundary에서 처리됩니다.
1throw new BoundaryError({2title: "접근 권한이 없어요",3description: "모임의 총무만 참여자를 추가할 수 있어요.",4});
loader에서 발생하는 에러
React Router의 loader에서 발생하는 에러는 컴포넌트 랜더링 중에 발생하는 에러가 아니기 때문에 Error Boundary에 잡히지 않습니다. 그래서 이런 경우에는 동일하게 에러를 처리하는 RouteErrorElement
를 errorElement
로 지정해 별도로 처리합니다.
1const router = createBrowserRouter([2{3path: '',4element: (5<RouteErrorBoundary>6<Outlet />7</RouteErrorBoundary>8),9errorElement: <RouteErrorElement />,10children: []11}12]13)
적용한 뒤 개선된 점
이렇게 에러와 처리 방법을 체계화하고, 에러 바운더리를 설정해서 개선된 점을 정리해 보니
- 다양한 에러 상황에서 안정적인 사용자 경험을 제공할 수 있게 되었습니다.
- 공통적인 에러 처리 로직을 중앙화해서 유지보수가 쉬워졌습니다.
- 컴포넌트 코드에서는 에러 처리와 관련한 복잡한 로직 대신 UI 로직에만 집중할 수 있게 되어 개발 효율이 높아졌습니다.
마무리
사실 에러 처리는 기능 개발에 밀려서 간단히 해결하거나 놓치기 쉬운 부분이었는데요. 에러들을 계층별로 정리하고, 커스텀 훅과 Error Boundary를 함께 활용해 일관된 방식으로 처리하면서 코드도, 사용자 경험도 한결 깔끔해졌습니다. 이렇게 구성한 기본적인 에러 처리 구조를 바탕으로 에러 통계를 수집한다거나, 공통 에러를 좀 더 세밀하게 구분한다거나 하는 식으로 좀 더 개선해 볼 수도 있을 것 같아요.