이번에 어드민 페이지의 결제 파트를 맡게 되면서, 결제, 환불, 환불 접수, 환불 접수 취소 등 사용자와의 상호작용이 많은 기능을 개발하게 되었다. 초기에는 기획대로 단순히 "XX가 실패했습니다"라는 에러 메시지만 보여주었으나, QA 과정에서 문제가 발견되었다.
예를 들어 특정 계정의 센터 ID와 환불 대상 센터 ID가 다를 때 "환불이 실패했습니다"라는 모달만 표시되어 원인을 알기 어려웠다. 네트워크 탭을 확인해보니 센터 ID 불일치로 인해 권한이 없어 실패한 경우였고, 같은 센터 ID라면 정상적으로 환불이 이루어지고 있었다. 즉, 환불 프로세스 자체의 문제가 아니라 권한 문제였던 것이다.
이러한 경우 정확한 실패 사유를 사용자에게 전달하는 것이 사용자 경험에 더 좋을 것이라고 판단했고, 기획자와 논의한 끝에 적절한 오류 메시지를 표시하도록 결정하였다.
그렇게 error.message
를 표시하면 되는 줄 알았으나..!! 문제가 있었다.
기존의 에러 응답 타입은 아래와 같았다.
type ApiErrorType = {
detail: {
error_code: string;
message: string;
status_code: number;
};
};
하지만 이번에 결제 개발에 들어가면서 백엔드에서 글로벌 익셉션 핸들러를 도입해 에러 응답 타입이 아래와 같이 바뀐 것이다.
type NewErrorType = {
STATUS_CODE: number;
ERROR_CODE: string;
MESSAGE: string;
ERROR_CLASS: string;
};
detail
이 빠지고 필드명이 대문자로 변경된 것이다!
앞으로 모든 api에 저 포맷이 적용될 예정이라 에러 응답 타입을 받는 apiFetch
(커스텀 fetch 함수)에서 이를 변환해주는 작업이 필요했다.
그래서 apiFetch
함수를 수정하기 위해 자세히 살펴본 결과...
const apiFetch = () => {
// fetch 로직 ...
if (!response.ok) {
if (statusCode >= 500) errorHandler("서버 오류가 발생했습니다.");
switch (errorCode) {
case "토큰 에러": {
errorHandler(errorMessage, true);
break;
}
// 다른 에러들..
default: {
errorHandler(errorMessage);
}
}
return Promise.reject(error);
}
};
const errorHandler = async (message: string, isLogout: boolean) => {
const { setCommonError } = useCommonStore.getState();
setCommonError({
title: message,
isLogout,
});
};
api 요청이 실패했을 경우 전역 상태에 에러 정보를 저장하고, 이를 팝업 컨텍스트 PopupContext
에서 감지해 팝업을 띄우는 방식이었다.
const PopupContextProvider = ({ children }: PopupPropsType) => {
const { commonError, setCommonError } = useCommonStore.getState();
const handleErrorPopup = () => {
if (!commonError) return;
openPopup(commonError);
if (commonError.isLogout) {
navigate("/login");
setCommonError(null);
}
};
useEffect(() => {
handleErrorPopup();
}, [commonError]);
return (
<PopupContext.Provider value={contextValues}>
<Popup />
</PopupContext.Provider>
);
};
아마 함수 안에서 모달을 띄우는 커스텀훅인 usePopup
을 쓰지 못해서 전역으로 에러 상태를 전달해준 뒤에 팝업 컨텍스트에서 useEffect
로 해당 상태를 감지해서 띄워준 것 같았다.
하지만.. 이게 최선인가? 싶었다.
이렇게 에러 처리의 흐름이 분산되어있으면 흐름을 파악하기 어렵고, 새로운 타입의 에러 처리가 필요할 때 어디에 구현해야 할 지 고민이 필요했다.
또한 apiFetch
함수는 api를 호출하는 역할에 집중하고, PopupContext
는 팝업을 렌더링하는데만 집중하고 싶었다.
그래서 글로벌 에러 핸들러를 도입하면 에러의 단일 진입점을 확보해 흐름을 파악하는데 더 용이하지 않을까? 하고 생각했다.
기존 에러 타입과 새로운 에러 응답 타입을 통합하기 위해 변환 함수를 만들었다.
const DEFAULT_MESSAGE = "잘못된 접근입니다.";
export const formatError = (
errorData: ApiErrorType | NewErrorType
): ErrorResponseType => {
const errorInfo =
"detail" in errorData
? {
detail: {
status_code: errorData.detail?.status_code,
error_code: errorData.detail?.error_code,
message: errorData.detail?.message || DEFAULT_MESSAGE,
},
}
: {
detail: {
status_code: errorData.STATUS_CODE,
error_code: errorData.ERROR_CODE,
message: errorData.MESSAGE || DEFAULT_MESSAGE,
},
};
return errorInfo;
};
현재 아주 많은 곳에서 예전 에러 타입을 쓰고 있었기 때문에.. 반환하는 에러 타입을 기존 에러 타입을 기준으로 확장시키기로 했다.
detail
이 불필요하다고 생각해서 없앴더니 기존에 detail로 접근하는 코드가 있어서 에러 메시지가 undefined로 나온 것이였다.
마이그레이션을 할 때 생각보다 많은 것을 고려해야하고 테스트도 많이 해봐야하는구나를 느꼈다..
새로운 QueryClient
를 생성할 때 mutationCache
나 queryCache
속성으로 onError
를 정의해주면 중앙에서 에러 처리를 관리할 수 있다.
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => openPopup(error.message),
}),
mutationCache: new MutationCache({
onError: (error) => openPopup(error.message),
}),
});
우리의 어드민 페이지는 모든 에러 처리를 하나의 공통된 팝업 창으로 해결하도록 기획되어있다. 해당 팝업은 usePopup
hook으로 호출할 수 있는데, 훅은 컴포넌트 외부에서 호출할 수 없어서 QueryClientProvider
를 감싸는 함수 컴포넌트를 만들어주었다.
const CustomQueryClientProvider = ({ children }: { children: ReactNode }) => {
const { openPopup } = usePopup();
// 최초 렌더링 시에만 초기화하기 위해 useState 사용
const [queryClient] = useState(() => {
return new QueryClient({
queryCache: new QueryCache({
onError: (error) =>
openPopup({
title: error.message,
}),
}),
});
});
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
export default CustomQueryClientProvider;
위의 컴포넌트를 기존의 QueryClientProvider
와 교체하면 된다.
// index.tsx
ReactDOM.createRoot(document.getElementById("root")!).render(
<PopupContextProvider>
<CustomQueryClientProvider>
<App />
</CustomQueryClientProvider>
</PopupContextProvider>
);
처음에는 QueryClient를 생성하는 곳에서 바로 에러 처리 로직을 작성하려고 했는데, 에러 종류에 따라 처리 방식이 조금씩 달라서 useErrorHandlers
라는 커스텀 훅을 분리해서 여기서만 에러 처리 로직을 작성하기로 했다.
export const useErrorHandlers = () => {
const { open: openPopup } = usePopup();
return (error: ErrorResponseType | Error) => {
// 서버 에러
if (error.status_code >= 500) {
return openPopup({
title: "서버와 통신 중 에러가 발생했습니다.",
});
}
// 기본 에러 메시지
openPopup({
title: error.message,
});
// 그 외 다양한 처리 로직...
};
};
그리고 useErrorHandlers
을 new QueryClient
생성 시 활용한다.
const queryClient = useMemo(
() =>
new QueryClient({
mutationCache: new MutationCache({
onError: handleError,
}),
queryCache: new QueryCache({
onError: handleError,
}),
}),
[handleError]
);
meta
필드를 사용하면 쿼리 별로 원하는 에러 메시지를 보여줄 수 있다.
useMutation({
mutationFn: payment,
meta: {
errorMessage: "결제에 실패했어요.",
},
});