Error Handle Architecture (SPA)

Ryan Cho·2025년 9월 11일

React Error Handle Architecture (SPA)

React SPA 한정으로 프로젝트의 에러처리를 위 그림처럼 구성해보려고 한다.

사전 세팅

우선 Axios, Tanstack-Query 를 기본적으로 사용한다.
프로젝트의 규모에 따라 이 아키텍쳐는 과할 수 있지만, 기본적으로 api에러 발생시 보여줄 여러 형태의 UI가 정의되어있거나, 여러 케이스에 따라 다른페이지로 리다이렉트가 필요하다면 도입하기 충분하다.

Error Handling Flow

1) axios의 response interceptor를 통해 우선 에러(커스텀 에러)를 감지한다.
2) throw 된 커스텀에러는 Tanstack-Query의 기본 QueryClient 객체에 error객체로 먼저 등록된다.
3) client state로 에러 객체를 등록한다. (context api)
4) Error Handler 훅, 컴포넌트를 통해 요구사항에 맞게 에러 처리를 최종적으로 진행한다.

프로젝트 요구사항 가정

여기서 말하는 에러처리는 1회성으로 사용되는 단순 에러 UI 처리를 위한것이 아니다.
그런건 api 호출부에서 바로 처리하면된다.
예를들어,

const {isError} = useQuery({queryFn:..., queryKey:[...]})
if (isError) return <>1회성 에러 UI</>

여기서 처리할 전역 에러처리는 재사용 가능한 UI 혹은, 리다이렉트 처리를 뜻하며 다음과 같이 가정한다.

  • 공통 Modal UI
  • 공통 Toast UI
  • 기본 에러컴포넌트
  • 아무 처리 안함 (사용자에게 에러처리를 하면 안되는 api, 호출부에서 따로 적용할 에러처리가 존재하는 경우 등)

1. 커스텀 에러 클래스 생성

커스텀 에러 객체 인스턴스를 위한 클래스를 생성한다.
BE의 에러 정의 등에 따라 달라질 수 있다.

// types/error.ts
export type ErrorMode = 'page' | 'modal' | 'toast' | 'none';

export interface CustomErrorData {
  resultCode?: string;
  message?: string;
  errorMode?: ErrorMode;
  statusCode?: number;
}

// errors/CustomError.ts
export class CustomError extends Error {
  public readonly resultCode: string;
  public readonly errorMode: ErrorMode;
  public readonly statusCode: number;

  constructor(data: CustomErrorData = {}) {
    const message = data.message || '알 수 없는 오류가 발생했습니다.';
    super(message);
    
    this.name = 'CustomError';
    this.resultCode = data.resultCode || 'UNKNOWN_ERROR';
    this.errorMode = data.errorMode || 'page';
    this.statusCode = data.statusCode || 0;
  }
}

2. Axios 타입 확장 및 Interceptor 설정

axios response interceptor 에서 정의한 커스텀 에러를 발생시킨다.

import { ErrorMode } from './error';

declare module 'axios' {
  export interface AxiosRequestConfig {
    errorMode?: ErrorMode; // 커스텀 에러 옵션 config 등록
  }
}

export interface ApiErrorResponse {
  error: {
    message: string;
  };
  resultCode: string;
}

// axios 기본 설정
...

// Response Interceptor 설정
axios.interceptors.response.use(
  (response: AxiosResponse) => {
	// 에러처리를 200응답에서 처리한다면 여기서도 CustomError 처리 필요
	return response
},
  (error: AxiosError<ApiErrorResponse>) => {
    const responseData = error.response?.data;
    
    throw new CustomError({
      statusCode: error.response?.status,
      resultCode: responseData?.resultCode,
      errorMode: error.config?.errorMode,
      message: responseData?.error?.message
    });
  }
);

3. Error Context Provider 구성

app을 ErrorProvider로 구성하여 커스텀 에러 컨텍스트를 생성한다.

import { createContext, useContext, useState, ReactNode } from 'react';
import { CustomError } from '../errors/CustomError';

interface ErrorContextValue {
  error: CustomError | null;
  setError: (error: CustomError | null) => void;
}

const ErrorContext = createContext<ErrorContextValue | undefined>(undefined);

export const useError = (): ErrorContextValue => {
  const context = useContext(ErrorContext);
  
  if (!context) {
    throw new Error('useError must be used within an ErrorProvider');
  }
  
  return context;
};

interface ErrorProviderProps {
  children: ReactNode;
}

export const ErrorProvider = ({ children }: ErrorProviderProps) => {
  const [error, setError] = useState<CustomError | null>(null);

  return (
    <ErrorContext.Provider value={{ error, setError }}>
    	// 'react-error-boundary'에서 import함
 		<ErrorBoundary fallback={<기본 에러페이지 컴포넌트/>}>
      		{children}
            <ErrorHandler/> // 에러핸들러
     	</ErrorBoundary>
    </ErrorContext.Provider>
  );
};

4. Tanstack-Query Provider 구성

등록한 컨텍스트의 setError 객체를 이용해 QueryClient에 에러가 감지되면 실행시킨다.

import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider, MutationCache, QueryCache } from '@tanstack/react-query';
import { useError } from '../contexts/ErrorContext';
import { CustomError } from '../errors/CustomError';

export const QueryProvider:FC<PropsWithChildren> = ({ children }) => {
  const { setError } = useError();

  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
      mutations: {
        retry: false,
        onError: (error) => {
          if (error instanceof CustomError) {
            setError(error);
          }
        },
      },
    },
    queryCache: new QueryCache({
      onError: (error) => {
        if (error instanceof CustomError) {
          setError(error);
        }
      },
    }),
    mutationCache: new MutationCache({
      onError: (error) => {
        if (error instanceof CustomError) {
          setError(error);
        }
      },
    }),
  });

  return (
    <QueryClientProvider client={queryClient}>
      // 로컬env로 Devtools 쓰면 좋음
      {children}
    </QueryClientProvider>
  );
};

5. ErrorHandler 생성

// components/ErrorHandler.tsx
import { useEffect } from 'react';
import { useError } from '../contexts/ErrorContext';
import { errorMessageMap } from '../constants/errorMessages';
import { useModal } from '../hooks/useModal';
import { useToast } from '../hooks/useToast';

export const ErrorHandler = () => {
  const { error, setError } = useError(); // from context
  const { openModal } = useModal(); // 있다고 가정
  const { showToast } = useToast(); // 있다고 가정

  useEffect(() => {
    if (!error) return;

    const handleError = () => {
      const errorMessage = errorMessageMap[error.resultCode]
	 || errorMessageMap.UNKNOWN_ERROR; // 요구사항이 이렇다고 가정

      if (error.errorMode === 'page') {
        throw error;
        // 기본 처리 상태 -> ErrorBoundary의 fallback으로 처리됨
      }

      if (error.errorMode === 'modal') { // 모달 에러처리
        openModal({
          title: errorMessage.title,
          content: errorMessage.description,
          actionText: errorMessage.actionText,
          onClose: () => setError(null)
        });
        return;
      }

      if (error.errorMode === 'toast') { // 토스트 에러처리
        showToast({
          type: 'error',
          message: errorMessage.description,
          duration: 3000
        });
        setError(null);
        return;
      }

      if (error.errorMode === 'none') { // 에러처리 하지 않음
        setError(null);
        return;
      }
    };

    handleError();
  }, [error, setError, openModal, showToast]);

  return <></>;
};

6. Provider 구성

...

<ErrorProvider>
	<QueryProvider>
    	<RouterProvider router={router}/>
	</QueryProvider>
</ErrorProvider>

7. 사용

axios request config에 errorMode 를 등록하고 해당 값에 따라 최종 에러핸들러에서 모달, 토스트, 에러페이지, 처리안함 을 처리한다.

따라서 다음과 같이 api 호출부에 옵션으로 넣어주면 된다.

useQuery({
	queryKey:[...],
    queryFn: () => getSomething({errorMode: 'toast'})
})

useMutation({
	mutationFn: () => postSomething({errorMode: 'modal'})
})

useQuery({
	queryKey:[...],
    queryFn: () => getSomething2({errorMode: 'none'})
}) // 이 경우는 useQuery반환값의 error 객체를 직접 처리하던, 아예 에러를 처리안해도 되고
profile
From frontend to fullstack

0개의 댓글