서비스를 구현함에 있어서 에러처리는 굉장히 중요한 부분이다. 실제로 유저들이 서비스를 사용하면서 에러가 발생했을 때 그에 따른 처리를 구현해놓지 않는다면 사용자는 에러로 인한 터진 애플리케이션을 보게되고, 현재 서비스에 어떤 문제가 있는것인지 유저들은 확인할 수 있는 방법이 없다. 따라서 고스란히 서비스에 대한 신뢰성 하락과 유저 이탈로 이어질 수 있다.
여러 프로젝트를 진행하면서 에러처리에 신경을 썼다고 생각했지만, 에러가 발생할 만한 컴포넌트 내에서 에러 처리를 해준다거나 try catch를 남발하는 등,, 지금 돌이켜보면 효과적으로 에러처리를 진행하지는 못한 것 같다. 따라서 덕풀 서비스를 구현하면서 에러처리 중앙화에 대해서 고민했던 상황들을 소개해보고자 한다.
발생 가능한 에러 상황
나는 덕풀 프로젝트를 진행하면서 애플리케이션에 발생가능한 에러 상황을 크게 두 가지로 추려봤다.
1️⃣ 렌더링 에러
2️⃣ mutation (post, patch, delete) 에러
에러의 상황이 이거 밖에 없어? 라고 할 수도 있겠다. 서버 에러, 잘못된 파라미터 전달 에러, 인증 에러와 같은 것들은 에러 상황에 대한 이유에 해당하는 것이라했을 때, 내가 생각하기에 "덕풀" 서비스에서 발생 가능한 에러 상황은 위와 같이 두 가지였다. 물론 서비스의 범위, 환경, 사용하는 기술 스택등에 따라 더 다양한 에러 상황이 존재할 수 있다고 생각한다.
에러 핸들링 방법
위의 두가지 발생 가능한 에러 상황에 대해서 알아보았으니, 에러 핸들링을 처리하는 방법에 대해서 소개해보려고 한다.
1️⃣ 렌더링 에러
렌더링 진행 도중 에러가 발생한다면 애플리케이션 앱 자체가 터져버린다.
예시를 하나 보자.
const ArticleDetail = () => {
...
const { data } = useArticle(articleId);
return (
<StyledArticle>
<PostImage title={data.title} images={data.img} />
<ArticleDescription {...data} />
<ArticleComment contentId={data.id} comments={data.comment} />
</StyledArticle>
);
};
위의 코드 예시는 useQuery를 사용하여 서버 데이터를 비동기로 가져오고, 해당 데이터를 통해서 렌더링을 진행하고 있는 코드다. 만약에 서버 데이터를 정상적으로 가져오지 못하는 경우에는 렌더링 오류가 발생하게 된다.
위와 같이 렌더링 오류가 발생해서 애플리케이션이 터지지 않도록 하기 위해서는 useQuery가 반환하는 isError 값에 따라 렌더링 분기처리를 진행해야하지만, 서버 데이터를 통해 렌더링을 진행해야하는 모든 컴포넌트에서 분기처리를 진행한다면, 중복된 에러처리 코드로 인해 유지보수와 가독성이 저하된다. 해당 에러를 애플리케이션 전역으로 중앙화하여 catch하는 방법으로는 react-error-boundary가 존재한다.
✅ react-error-boundary
현재 react에서 제공하는 에러바운더리는 클래스형 컴포넌트를 통해서 구현이 되어있다. 라이프사이클 메서드인 getDerivedStateFromError를 통해서 에러를 잡고 componentDidCatch를 통해 에러 로깅을 담당한다. 하지만 함수형 컴포넌트를 통해서 에러바운더리를 구성하고 싶은 경우에는 react-error-boundary 라이브러리를 통해 구현할 수 있다. 덕풀 프로젝트 또한 react-error-boundary 라이브러리를 사용하여 에러바운더리를 구성했다.
const RootPage = () => {
...
const { reset } = useQueryErrorResetBoundary();
return (
...
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<Header />
<Outlet />
{isMobile ? <MobileNavbar /> : <Footer />}
</ErrorBoundary>
...
);
};
덕풀에서는 루트페이지 컴포넌트에 ErrorBoundary 컴포넌트를 두어 전역으로 렌더링 에러에 대한 에러처리 중앙화를 구현했다.
여기서 한가지 알고 넘어가야하는 내용으로 자바스크립트의 에러는 해당 호출 스택에서 처리되지 않는다면 상위 호출 스택으로 전파된다. 따라서 전역으로 에러처리를 담당하지 않는다면 다음과 같이 좀 더 좁게 ErrorBoundary를 구성하는 것이 가능하다.
const ArticlePost = () => {
const { reset } = useQueryErrorResetBoundary();
return (
<Layout>
<Suspense fallback={<ArticlePostSkeleton />}>
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<ArticleDetail />
</ErrorBoundary>
</Suspense>
</Layout>
);
};
위와 같이 에러바운더리가 구성되어 있다면 ArticleDetail 컴포넌트에서 발생한 렌더링 에러는 중앙화된 에러바운더리가 아닌 더 가까운 에러바운더리에 걸리게된다. 이제 에러가 발생하는 경우에는 ErrorBoundary 컴포넌트의 FallbackComponent prop으로 전달한 컴포넌트가 렌더링되고, 해당 컴포넌트 내부에서 에러에 대한 처리를 진행하면 된다.
const ErrorFallback = memo(({ error, resetErrorBoundary }: FallbackProps) => {
...
const navigate = useNavigate();
const { showToast } = useContext(ToastContext);
useEffect(() => {
if (error instanceof UnAuthorizedError) {
navigate('/login');
resetErrorBoundary();
}
if (error instanceof ExpiredRefreshTokenError) {
showToast('토큰이 만료되었습니다. 다시 로그인해주세요');
resetErrorBoundary();
}
}, []);
if (error instanceof UnAuthorizedError) return null;
if (error instanceof ExpiredRefreshTokenError) return null;
return (
...
);
});
FallbackComponent는 위와 같이 구성되어있다. 일반적으로 에러 코드를 통해서 처리를 진행할 수 있지만, 덕풀에서는 api 요청에 대한 1차처리를 axios interceptor에서 처리를 진행하고 에러를 throw한다. 따라서 ErrorBoundary의 FallbackComponent에서는 에러의 인스턴스에 따라서 렌더링 오류에 대한 처리를 진행하고 특정 에러 인스턴스에 부합하지 않는다면 기본적인 에러 UI를 보여주도록했다. 쉽게 말해 ErrorBoundary 컴포넌트는 try, FallbackComponent는 catch에 해당한다고 볼 수 있다. 여기서 중요한 점은 react-query를 사용하여 데이터 페칭을 진행한다면 v5 기준으로 throwOnError, v3 기준으로 suspense 옵션을 true로 설정해야 react-error-boundary가 정상적으로 동작한다.
2️⃣ mutation 에러
mutation 에러에 대해서는 다른 방식으로 처리했다. 여기서 생각해볼 수 있는게, 그럼 ErrorBoundary에서는 mutation 에러에 대해서는 감지하지 못할까? 답을 먼저하자면 감지하지 못한다. mutation을 통해 발생하는 에러는 단순히 해당 mutation이 실패해서 발생하는 것이고, 렌더링에는 영향을 미치지 못한다. 따라서 mutation에 대한 오류는 ErrorBoundary가 아닌 다른곳에서 처리해주어야 한다.
✅ react-query defaultOptions
덕풀 프로젝트에서는 react-query를 사용하여 API를 모듈화해서 서버 데이터 페칭을 수행하고 있다. react-query에서는 기본적으로 queryClient의 defaultOptions를 설정함으로써 에러에 관한 기본 세팅을 수행할 수 있기 때문에, mutation에 관한 에러 중앙화는 defaultOptions로 구현했다.
서버 데이터 페칭을 수행하는데 useQuery를 사용하는 경우 렌더링 에러도 defaultOptions에 세팅할 수 있지 않을까라는 의문을 가질 수도 있을것이다.
react-query v5를 사용하는 경우 useQuery의 onError 옵션이 사라졌기 때문에, useQuery의 에러에 대한 에러처리를 defaultOptions에서 수행할 수 없을뿐더러 react-query의 버전을 낮춘다해도 queries의 onError옵션은 데이터 페칭에 대한 오류 처리와 관련이 있을 뿐, 렌더링 오류를 처리하는데에는 적합하지않다.
const ClientProvider = ({ children }: Props): JSX.Element => {
const { defaultMutationHandler } = useApiError();
const queryClient = useMemo(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
throwOnError: true,
refetchOnWindowFocus: false,
},
mutations: {
onError: (err) => defaultMutationHandler(err),
},
},
}),
[],
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
const useApiError = () => {
const { showToast } = useContext(ToastContext);
const { openModal } = useContext(ModalContext);
const defaultMutationHandler = (error: Error) => {
if (error instanceof ExpiredRefreshTokenError) {
showToast('토큰이 만료되었습니다. 다시 로그인해주세요');
}
if (error instanceof ServerError) {
showToast('네트워크 요청에 실패했습니다. 잠시후 다시 시도해주세요.');
}
if (error instanceof UnAuthorizedError) {
openModal(<LoginModal />).catch(() => false);
}
};
return { defaultMutationHandler };
};
export default useApiError;
API를 모듈화해서 사용하고 있다고 했는데, 때로는 특정 mutation에서 낙관적 업데이트를 진행해야 하는경우 에러 발생 시 이전의 데이터 값으로 원상복구 시켜야하는 상황처럼 defaultOptions에 정의한 에러 이외에도 다른 에러처리가 필요할 수 있다. 따라서 위와 같이 mutation의 onError에 들어가는 기본적인 에러 핸들링 처리는 훅으로 분리하여 추가적인 에러처리가 필요한 mutation에서 간단하게 추가하여 사용할 수 있다.
마무리
이 포스팅에서는 크게 두 가지의 에러상황을 중앙화하여 처리하는 방법에 대해서 소개했다. 존재하지 않는 페이지에 대한 에러처리에 대해서도 소개하려고 했지만, react-router-dom의 errorElement를 통해서 간단하게 처리할 수 있어 이 포스팅에서는 언급하지 않았다.
위에서 소개한 방법이 정답일수는 없겠지만, 애플리케이션의 에러처리를 중앙화했다는 점에서 유지보수와 관리 용이성을 확보했고, 추가적인 에러 상황의 경우에도 중앙화된 에러 처리 시스템을 수정하는 방식으로 구현하여 유연성과 확장성 또한 올라갔다.
react-error-boundary, axios interceptor, react-query 등등 여러 라이브러리에서 에러 처리에 관한 도구들을 제공하기 때문에, 자신의 프로젝트에 맞게 더 좋은 방향으로 조합하여 에러처리 중앙화를 진행해보면 좋겠다. 이후에 더 좋은 에러처리 방법이 있다면 다시 한 번 소개하는 시간을 가져보면 좋을것 같다.
'개발' 카테고리의 다른 글
이미지 Lazy Loading을 통한 페이지 로딩 시간 감소 (0) | 2024.04.01 |
---|---|
애플리케이션 유저 로그인 상태 및 인증 로직 관리 (feat. Jotai) (1) | 2024.03.11 |
promise를 사용한 전역 모달 관리 (feat. contextAPI) (0) | 2024.03.05 |
[개발] Nextjs에서 SEO 최적화하기 (0) | 2023.06.02 |
[개발] Nextjs Link 컴포넌트와 <a> 태그 (feat. 이메일 링크걸기) (0) | 2023.05.28 |