아래 글은 현재 우리 회사 프로젝트의 에러처리에 관련한 고민을 하다가 찾게된 화해 기술블로그이다.
화해의 기술들이 우리 회사의 기술들과 유사한 부분이 많아서 같이 고민하는 느낌이든다!
왜 API 에러 처리에 대한 고민을 하게 되었나?
현재 제가 담당하고 있는 프로젝트 중에 하나가 오래전에 만들어진 제품을 유지보수하는 프로젝트입니다. 해당 프로젝트는 흔히 말해 레거시 개선 프로젝트였습니다. 이 프로젝트는 설계에 대한 깊은 고민보다는 필요한 기능들을 우선적으로 추가해야만 했던 시기에 만들어진 것으로 보입니다. 그래서인지 여러 부분에서 체계가 미흡한데, API 에러 처리 체계도 그중 하나였습니다.
여러 군데에서 비슷한 에러 처리 로직을 개별적으로 작성하거나 공통적으로 다루어져야 하는 로직도 반복해서 작성되어 있는 경우가 있었습니다. 게다가 Alert을 노출시킬 때의 메시지나 스타일도 제각각이었습니다. 이런 상황은 개발자로 하여금 에러 처리 흐름을 파악하기 어렵게 만들었습니다. 또한 새로운 에러 처리 로직이 추가될 때 필수적으로 함께 작성해야 하는 로직이 누락되거나 개발자들이 각자 다른 생각을 가지고 로직을 작성하는 상황이 반복되면서 점점 유지보수가 어려워졌습니다.
해당 프로젝트는 지금 개편을 하기 위해서 새로운 프로젝트 형태로 만들고 있는데요. 신규 프로젝트는 유지보수성을 높여 지속 가능하도록 만들기 위해 기존에 느꼈던 유지보수성을 저해하는 요소를 최대한 개선하고 API 에러 처리도 체계를 잡아보려 합니다.
마침 프론트엔드 플랫폼 동료가 저와 똑같은 고민을 하며 개선했던 프로젝트가 있어 설계 내용을 공유해주셨습니다. 덕분에 많은 참고가 되었습니다.
목표
API 에러가 발생했을 때 에러를 처리하는 흐름을 정리하고 체계를 갖춥니다. 이를 통해서 모든 에러에 공통적으로 적용되는 규칙과 처리는 전역적으로 관리하여 매번 개발자가 따로 고민하지 않도록 합니다. 그리고 API를 사용하는 컴포넌트에서는 해당 컴포넌트에서 필요한 만큼의 에러 처리 로직에 집중할 수 있게 합니다. 궁극적으로는 프로젝트에 참여하는 모든 개발자가 일관되게 에러 처리를 할 수 있도록 하여 지속 가능한 프로젝트로 한 걸음 더 나아갑니다.
목표가 아닌 것
- React Query의 사용법을 설명하지 않습니다.
- 센트리 등으로 에러 로그를 발송하는 부분의 설계는 제외합니다. 단, 어느 부분에서 로그 발송 코드를 작성해야 하는지는 명시합니다.
- 에러 메시지를 띄우는 Alert의 설계는 제외합니다. 단, 어느 부분에서 에러 메시지를 띄우는 코드를 작성 해야하는지는 명시합니다.
- ErrorBoundary나 Suspense의 역할까지는 다루지 않습니다.
환경
프로젝트는 React 기반으로 개발하고 있고 API 요청 관리를 위해서 React Query를 이용합니다. 그래서 API 에러는 React Query에서 지원하는 onError 이벤트 리스너를 이용하여 감지할 수 있습니다.
에러 처리 흐름
API 에러가 발생하면 아래 흐름을 거쳐서 에러 상황이 종료되도록 합니다.
- 에러 발생: 에러가 발생하면 React Query의 onError 이벤트 리스너를 통해서 API 에러 상황을 감지하고 에러 객체를 획득합니다.
- 에러 종류 파악: 에러를 파싱하여 서비스의 표준 에러 응답인지, 네트워크 에러인지를 판단합니다.
- 상황별 에러 처리: HTTP Status와 서비스 표준 에러 Code로 분기하여 상황별 에러 처리 로직을 수행합니다. 만약 Alert을 띄워야 하는 에러 상황이 있다면 상황별 에러 처리 로직에서 적절한 메시지를 지정하여 Alert을 띄웁니다.
- 공통 처리: 모든 에러 상황에서 동일하게 수행해야 하는 로직을 수행합니다. 센트리 등으로 에러 로그를 발송하는 코드가 이 단계에서 다뤄질 수 있습니다.
API 에러 처리 흐름
구현 설계 – 에러 처리 Hook
위의 에러 처리 흐름을 일관적으로 잘 유지하기 위해서 관련 코드들을 분산시키지 않고 가급적 모아서 작성합니다. 특히 React를 이용하고 있으므로 Hook으로 에러 처리 흐름의 주요 로직을 작성합니다.
에러 처리 Hook은 아래처럼 사용합니다. 에러 처리 흐름의 주요한 부분들은 Hook에서 모두 담당하고 개별적인 컴포넌트에서는 정말 딱 추가적으로 정의해야 하는 로직에만 집중하게 합니다. 아래 예시 코드에서는 만들게 될 커스텀 Hook의 이름을 useApiError라고 하였습니다.
- React Query의 QueryClient를 초기화할 때 Default Error Handler를 전달하기 위해서 Hook을 사용합니다.
-
import { QueryClient } from 'react-query'; // ... const { handleError } = useApiError(); const queryClient = new QueryClient({ defaultOptions: { onError: handleError, }, }) // ...
- 개별 컴포넌트에서 특정 HTTP Status와 서비스 표준 에러 Code에 실행할 로직을 지정하고 싶다면 Hook을 사용할 때 인자로 Handler 함수를 전달합니다. 아래 코드는 (HTTP Status: 409, 서비스 표준 에러 Code: 10001)과 (HTTP Status: 500) 상황에서 개별 핸들러를 사용하는 예시입니다.
import { useQuery } from 'react-query'; // ... // HTTP Status가 409이면서 서비스 표준 에러 Code가 10001일 때 실행할 핸들러 함수 const errorHandler40910001 = () => { // 컴포넌트의 상황에 맞게 처리 로직을 작성 } // HTTP Status가 500일 때 실행할 핸들러 함수 const errorHandler500 = () => { // 컴포넌트의 상황에 맞게 처리 로직을 작성 } const { handleError } = useApiError({ 409: { 10001: errorHandler40910001, }, 500: { default: errorHandler500, }, }); const { isLoading, error, data, isFetching } = useQuery('key', fetchData, { onError: handleError, }); // ...
React Query의 onError의 값으로는 기본적으로 QueryClient를 생성할 때 설정한 핸들러가 사용되지만 useQuery나 useMutation Hook을 사용할 때 option으로 onError에 새로운 핸들러를 설정하면 기본값을 덮어씁니다. 그래서 위와 같이 에러 처리 Hook을 사용하면 모든 에러에 기본적인 에러 처리 흐름을 적용하면서 재정의가 필요한 부분만 상황에 따라 추가할 수 있습니다.
Hook 내부에서는 상황별로 어떤 에러 핸들러를 수행할지 결정하는 부분이 가장 주요합니다. 그 결정은 아래 조건에 따릅니다.
- 에러 발생 시 실행할 가능성이 있는 핸들러는 5가지가 있습니다. 그리고 나열 순서가 실행 우선순위입니다.
- 컴포넌트에서 (HTTP Status, 서비스 표준 에러 Code) Key 조합으로 재정의한 핸들러
- 컴포넌트에서 (HTTP Status) Key로 재정의한 핸들러
- Hook에서 (HTTP Status, 서비스 표준 에러 Code) Key 조합으로 정의한 핸들러
- Hook에서 (HTTP Status) Key로 정의한 핸들러
- 어디에서도 정의되지 못한 에러를 처리하는 핸들러
위의 조건을 예시 코드로 작성해보면 아래와 같습니다.
/** useApiError.ts */
// 예시 코드로, 주요한 부분만 추출하였습니다.
// 기본 핸들러 예시. 특정 HTTP Status와 서비스 표준 에러 Code 일 때 전역적으로 적용하기로 사전 정의한 핸들러들입니다.
const defaultHandlers = {
common: commonHandler,
default: defaultHandler,
401: {
default: handler401,
},
403: {
default: handler403,
},
409: {
10001: handler40910001,
10002: handler40910002,
},
500: {
default: handle500,
},
};
// 매개변수 handlers: 컴포넌트에서 재정의한 핸들러 모음
const useApiError = (handlers) => {
// ...
// 우선순위에 따른 핸들러의 선택과 실행
const handleError = useCallback((error) => {
const httpStatus = error.status; // HTTP Status
const serviceCode = error.response.meta.code; // 서비스 표준 에러 Code
switch(true) {
case handlers && handlers[httpStatus][serviceCode]:
// 우선순위 1. 컴포넌트에서 (HTTP Status, 서비스 표준 에러 Code) Key 조합으로 재정의한 핸들러
handlers[httpStatus][serviceCode]();
break;
case handlers && handlers[httpStatus]:
// 우선순위 2. 컴포넌트에서 (HTTP Status) Key로 재정의한 핸들러
handlers[httpStatus].default();
break;
case defaultHandlers[httpStatus][serviceCode]:
// 우선순위 3. Hook에서 (HTTP Status, 서비스 표준 에러 Code) Key 조합으로 정의한 핸들러
defaultHandlers[httpStatus][serviceCode]();
break;
case defaultHandlers[httpStatus]:
// 우선순위 4. Hook에서 (HTTP Status) Key로 정의한 핸들러
defaultHandlers[httpStatus].default();
break;
default:
// 우선순위 5. 어디에서도 정의되지 못한 에러를 처리하는 핸들러
defaultHandlers.default();
}
// 공통 처리 로직 수행
defaultHandlers.common();
}, [handlers]);
// ...
return { handleError };
};
마무리
지금까지 제가 이번에 고민했던 문제를 어떻게 풀어나가고 있는지 정리해보았습니다. 이후로는 함께하는 동료들과 설계에 대해서 논의하고 실제 프로젝트에 적용하는 과정이 남은 것 같습니다. 다음 포스트에서는 그 과정에 대해서 다루고 어떤 모습으로 프로젝트에 녹여냈는지 소개해드리려 합니다.
이런 과정들은 통일된 에러 처리 방법을 만들고 이전에 겪었던 개발 과정에서의 어려움을 해결하여 개발 경험을 지속적으로 나아지게 만들 것입니다. 이는 유지보수성을 높이고 지속 가능한 프로젝트를 만들어나가는 좋은 방법인 것 같습니다.
하지만 어떤 설계든지 언제나 완벽할 수는 없다고 생각합니다. 비즈니스 요구사항이나 라이브러리의 발전, 개발 경험 등으로 언제든지 ‘좋은 설계’의 기준은 바뀔 수 있습니다. 이번 설계를 기반으로 앞으로도 꾸준히 고민하고 발전시키려는 노력이 따라야 할 것이고, 언제나 그랬듯이 좋은 동료들과 함께 재밌게 할 수 있을 것 같아서 기대됩니다.
'언어공부 > JS&TS&React' 카테고리의 다른 글
[Redux] 리덕스 간단 정리 (0) | 2022.06.11 |
---|---|
use-query-params 로 queryString 관리하기 (0) | 2022.04.03 |
URLSearchParams, 주소 쿼리스트링, 파라미터 변경 (0) | 2022.03.27 |
nodejs 타입스크립트 환경설정, jsconfig.json 활용 (0) | 2022.03.09 |
[Javascript] ag-grid 로 그룹 테이블 만들기 (0) | 2021.08.27 |
댓글