React | 공통 API 에러 처리 제작기(feat. React Query)

JU CHEOLJIN·2022년 6월 15일
6
post-thumbnail

들어가며

회사 프로젝트에 React Query를 도입시켜보면서 그동안 함부로 건들지 못했던 API 핸들링 관련 로직을 건들어야만 했다. React Query 잘 도입해서 팀원들이 편하게 사용할 수 있으면 좋겠다는 마음이 들어 대대적으로 한번 뜯어봤다. 아래는 기존에 사용하던 API 에러 핸들링을 더 잘해보기 위해서 고민한 내용이다.

고민하게 된 계기

React Query만 잘 도입하면 되겠다 싶었는데, 세션에 대한 처리가 필요했다. 기존에는 세션에 관련된 커스텀 훅을 만들고 여기에 API 응답을 넣어서 처리가 될 수 있도록 했는데 React Query를 도입하면서 동일하게 처리할 수 없었다. React Query에서는 이를 onError 옵션을 통해 처리할 수 있도록 하고 있는데 기존 세션 만료 응답은 200으로 오고 있어 에러로 처리할 수 없었다. 이를 select 등의 옵션에서 처리하는 것은 가독성도 헤쳤고 시간이 많이 흐른 뒤에 의도를 파악하기 힘들어 유지보수에도 악영향을 끼칠 것이라는 생각이 들기도 했다. 그래서 여러가지 시도를 통해 문제를 파악하다가 결국 API 핸들러 레벨부터 수정을 해보기로 했다. 

목표

크다면 큰 작업이 될 예정이었기 때문에 간단하게 목표를 통해 제한범위를 정해서 진행해보기로 했다. 

1. 기존 API 핸들러를 그대로 두되, 새로운 API 핸들러를 만들고 이용한다.

2. useApiError를 통해 공통 에러 관련 로직을 관리해 유지보수가 편하도록 한다.

3. 필요한 경우 사용하는 컴포넌트에서 추가 로직을 주입할 수 있도록 한다.

API 핸들러 만들기

기존 API 핸들러의 기본 로직은 아래와 같았다. 

export const receive = (promise: AxiosPromise<any>): Promise<ApiResponse> =>
    new Promise((resolve, reject) => {
        promise
            .then((response: AxiosResponse) => {
                if (
                    응답 결과 === "A" || 
                    응답 결과 === "B" ||
                    응답 결과 === "C" ||
                    응답 결과 === "D" ||
                    응답 결과 === "E" ||
                    !응답 결과
                    
                ) {
                    const data = response.data;

                    const headers = response.headers;

                    const apiResponse: ApiResponse = {
                        result: ApiResult.OK,
                        data,
                        headers,
                    };

                    resolve(apiResponse);
                } else {
                    // 공통 error handle 처리
                    const apiError: ApiError = {
                        result: ApiResult.UNKNOWN_ERROR,
                        message: 'unknown error.',
                        data: response.data,
                    };
                    
                    // 에러 내용을 주입하는 로직
                    
                    reject(apiError);
                }
            })
            .catch((reason: AxiosError<{ additionalInfo: string }>) => {
                const apiError: ApiError = {
                    result: String(reason?.response?.status),
                    message: reason.message,
                    data: reason?.response?.data,
                };

                reject(apiError);
            });
    });

기존 API의 경우 모든 응답이 200으로 왔고 따로, 응답 안에서 코드를 보내주는 형식이었다. 그렇기 때문에 200 응답이 온 경우에 코드의 조건을 비교하고 일치하지 않으면 에러를 발생시키도록 되어 있었다. 하지만, API 응답 구조가 변경되면서 수정이 필요했는데 급하게 프로젝트를 진행하기 위해서 !응답결과 라는 조건을 통해서 사용할 수 있도록 했었다. 

이렇게 사용하다보니 조건이 추가되는 경우마다 A,B,C,D 늘어나면서 알아보기 어려워지고 사이드 이펙트가 두려워 쉽게 수정도 하기 어려운 상황이 되고 있었다. 아예 뜯어 고칠 수 있었다면 좋았겠지만 이를 위해서는 여러 영역에서 대대적인 수정이 필요하게 되어 우선 새로운 API 핸들러를 만들어 보았다. 

export const axiosRequest = (
    promise: AxiosPromise<any>
): Promise<AxiosResponse> =>
    new Promise((resolve, reject) => {
        promise
            .then((response: AxiosResponse) => {
                const data = response?.data;

                if (
                   (!응답결과 || 응답결과 === "200") && (세션관련 처리)
                ) {
                    const apiResponse: AxiosResponse = response;

                    resolve(apiResponse);
                } else {
                    // 여기에 공통 error handle 처리
                    const apiError: ApiError = {
                        result: ApiResult.SESSION_OUT,
                        message: '세션 정보가 없습니다.',
                        data: response.data,
                    };

                    reject(apiError);
                }
            })
            .catch((reason: AxiosError<{ additionalInfo: string }>) => {
                const apiError: ApiError = {
                    result: String(reason?.response?.status),
                    message: reason.message,
                    data: reason?.response?.data,
                };

                reject(apiError);
            });
    });

보이는 것처럼 눈에 띄는 엄청난 변화는 없지만, 우선 지저분한 여러 조건들을 삭제하였고 세션 관련 처리 로직을 추가하였다. 이제 세션이 없는 경우에 200이 아닌 403 에러를 받을 수 있게 되었다. 욕심을 부려서 대대적으로 수정을 할 수도 있지만 이번 목표가 모든 영역을 아우르는 것이 아니었기 때문에 이후 작업을 진행했다. 

에러 흐름 파악하기

API 핸들러를 새로 만들었기 때문에 이제 이를 이용하면서 진행할 공통 에러 처리 로직을 만들 차례였다. React를 사용하는 프로젝트이기 때문에 어디서든 사용할 수 있도록 커스텀훅을 이용했다. 

우선, 회사 프로젝트에서 사용되는 에러의 종류는 이렇다. 

1. 기존 API의 경우 모든 응답은 200, 상황에 따라 result를 통해 에러 코드를 보낸다. (10003, 10004 등)

2. 신규 개발되는 API 경우 200, 400 등의 상태로 응답이 오며, 필요한 경우에 errorCode를 보낸다.

3. 필요한 경우, 동일한 errorCode라고 해도 다른 errorMessage를 가지는 경우가 있다.

기본적으로 2번과 3번의 상황에 사용될 수 있도록 아래의 흐름으로 로직을 구성했다. 관련하여 화해(버드뷰)의 블로그에서 아이디어를 얻을 수 있었다. 

그림에서 보이는 것처럼 에러의 처리는 아래 순서로 진행하기로 했다. 

1. API 요청에서 에러가 발생하는 경우 먼저, 상태코드를 확인한다.

2. 해당하는 상태코드에 추가적인 에러코드가 없다면 상태코드로 분기하여 관련 로직을 수행한다.

3. 해당하는 상태코드에 추가적으로 에러코드가 있다면 상태코드로와 에러코드를 이용하여 분기한 후 관련 로직을 수행한다.

실제 구현

useApiError 커스텀 hook

import { useCallback } from 'react';

type HandlersType = {
    [status: number | string]: any;
};

export const useApiError = (handlers?: HandlersType) => {

    const handle403 = () => {
		// 세션 만료 팝업 호출
    };

    const handle500 = () => {
		// 500 상태 관련 로직
    };

    const handleDefault = () => {
		// 기본 에러 처리 로직
    };

	// 기본적으로 처리될 수 있는 에러 핸들러
    const defaultHandlers: HandlersType = {
        403: {
            default: handle403,
        },
        500: {
            default: handle500,
        },
        default: handleDefault,
    };

    const handleError = useCallback(
        error => {
            const httpStatus = error.result;
            const errorCode = error.data.errorCode;
            const errorMessage = error.data.errorMessage;

            switch (true) {
                case handlers &&
                    !!handlers[httpStatus]?.[errorCode]?.[errorMessage]:
                    handlers![httpStatus][errorCode][errorMessage]();
                    break;

                case handlers && !!handlers[httpStatus]?.[errorCode]:
                    handlers![httpStatus][errorCode](error);
                    break;

                case handlers && !!handlers[httpStatus]:
                    handlers![httpStatus].default(error);
                    break;

                case !!defaultHandlers[httpStatus][errorCode]:
                    defaultHandlers[httpStatus][errorCode]();
                    break;

                case !!defaultHandlers[httpStatus]:
                    defaultHandlers[httpStatus].default();
                    break;

                default:
                    defaultHandlers.default();
            }
        },
        [handlers]
    );

    return { handleError };
};

useApiError 라는 커스텀 hook 안에서 공통으로 처리될 수 있는 로직을 정의했고 이를 switch를 이용해서 우선순위에 따라서 처리될 수 있도록 했다. 우선순위는 아래와 같다. 

1. 특정 컴포넌트에서 정의한 핸들러이며, errorMessage에 따라서 분기가 필요한 경우

2. 특정 컴포넌트에서 정의한 핸들러이며, errorCode에 따라서 분기가 필요한 경우

3. 특정 컴포넌트에서 정의한 핸들러이며, 상태 코드에 따라서 분기가 필요한 경우

4. hook에서 정의된 핸들러이며, 상태코드 및 errorCode에 따라서 분기가 필요한 경우

5. hook에서 정의된 핸들러이며, 상태코드에 따라서 분기가 필요한 경우

기본적인 사용법

const useCustomQuery = () => {
	const {handleError} = useApiError();

	return useQuery([queryKey], fetchApi, {
    	onError: handleError
    })
}

export default useCustomQuery;

특별한 추가 로직이 없는 경우에는 handleError를 바로 이용할 수 있다. 이를 통해서 API 통신을 하는 로직에 복잡한 에러 로직이 들어갈 필요가 없고, 굳이 노출할 필요가 없는 부분을 추상화 시킬 수 있다. 만약, 특정 동작을 추가하고 아래처럼 주입할 수 있다.

핸들러 주입하기

const useCustomQuery = () => {
	const {handleError} = useApiError({
    	 400: {
            [에러코드]: () =>
                openFailedToast({ message: '잘못된 정보입니다.' }),
        },
    
    });

	return useQuery([queryKey], fetchApi, {
    	onError: handleError
    })
}

export default useCustomQuery;

기본적으로 handleError 의 경우에 defaultHandler에 정의 된 로직을 처리하도록 되어있지만 hook을 호출하는 시점에서 핸들러를 주입하면 이를 이용할 수 있다. 따라서, 추상화 레벨은 유지하면서 사용하는 컴포넌트의 상황에 따라서 필요한 로직을 주입할 수 있게 된다. 

마치며

기존에는 API 관련 함수를 볼 때 여러가지 에러 처리 로직이 뒤섞여 있었고 이 때문에 복잡한 구현 내용을 파악해야만 했다. 특히, 한 API에서 여러가지 에러 코드를 다뤄야 하는 경우에는 복잡하다보니 실수도 자주 발생했다. 때문에 이를 쉽게 만들어 줄 수 있는 방법을 고민하고 있어 찾는 도중 화해(버드뷰)에서 작성해주신 글을 보고 좋은 아이디어를 얻을 수 있었다. 기존의 API에도 모두 적용할 수는 없었지만 앞으로의 개발에서는 이를 사용해서 팀원들이 편하게 사용할 수 있으면 좋겠다. 그리고 앞으로의 유지보수에 기여가 됐으면 한다. 


참고 : 화해(버드뷰) - React Query와 함께하는 API에러 처리 설계하기

profile
사회에 도움이 되는 것은 꿈, 바로 옆의 도움이 되는 것은 평생 목표인 개발자.

0개의 댓글