기존의 에러바운더리 적용 방식은 Global ErrorBoundary 라고 명명할 수 있을 것처럼 전체를 감싸는 에러바운더리를 작성해서, 잘못된 페이지를 접속했거나, 요청이 잘못되었을 때 등 발생하는 에러를 모두 한꺼번에 처리해서 상황에 따른 핸들링을 하지 않는 방식으로 사용했습니다. 이렇게 사용한 에러바운더리는 다음과 같은 유저의 불편함을 만들었습니다.
1. 일부 컴포넌트에서 api 요청을 해서 데이터를 비동기로 가져올때, 여러가지 에러상황이 나올 수 있는데, api 재요청을 통해서 다시 정상적인 UI를 보여줄 수 있는 상황에서도, 공통 로직으로 작성된 errorBoundary로 연결되는 상황이 발생합니다. 이후 설정된 로직에 따라 다르겠지만, 유저는 정상적 UI를 보기위해 다시 에러가 났던 페이지로 접속을 해야하고 이러한 과정이 반복될 수 있는 문제가 생깁니다.
2. 공통 로직으로 처리된 errorBoundary는 유저에게 불친절한 UI일 가능성이 높습니다. 실제로 에러가 어디서 발생했는지 어떻게 해결할 수 있는지 유저가 알지 못하는 것은 유저의 이탈률을 증가시키는 원인으로 작용할 수 있습니다.
- 특히 지금처럼 한 컴포넌트에서 api 요청이 하나정도 이뤄지는 소규모의 프로젝트에서는 복잡도가 덜 할수 있지만, A 컴포넌트에서 여러개의 API를 호출하고, A 컴포넌트의 자식 컴포넌트에서도 여러개의 API를 호출한다고 가정해봅시다.
- 세분화된 ErrorBoundary를 사용하지 않는다면 에러 상황을 핸들링하기 위해 여러 조건들을 비교하여 다루는 로직을 Global ErrorBoundary 코드에 모두 작성해야할 것이고, 더 나아가 자식 컴포넌트로 Props Drilling을 통한 상태 확인을 수행해야하는 문제가 생길 수 있습니다. 컴포넌트의 규모가 커질수록 유지보수가 점점 어려워질 것으로 예상됩니다.
위의 문제를 해결하기 위해...
기존의 errorBoundary를 GlobalErrorBoundary로 설정하고 전체를 감쌉니다. 내부의 api 관련 에러들을 핸들링 하기위해 지역적인 errorBoundary인 ApiErrorBoundary를 생성하고, ApiErrorBoundary로 처리하고 싶은 컴포넌트를 감싸는 형식으로 구성할 수 있을 것이라고 생각했습니다. 간단한 구조로 나타내자면 다음과 같을 것입니다.
<GlobalErrorBoundary>
...
<ApiErrorBoundary>
<Api 요청을 다루는 컴포넌트1/>
</ApiErrorBoundary>
...
<ApiErrorBoundary>
<Api 요청을 다루는 컴포넌트2/>
</ApiErrorBoundary>
...
</GlobalErrorBoundary>
우선 React-query에서 ErrorBoundary를 함께 사용하기 위해 useErrorBoundary 옵션을 true로 설정해줍니다. 이후 GlobalErrorBoundary를 다음과 같이 구현합니다.
import { Component, ElementType, ErrorInfo, ReactNode } from 'react';
import AuthErrorPage from '@src/errorBoundary/AuthErrorPage';
//에러페이지로 보여줄 fallback과 정상적으로 작동할때 보여줄 children을 가진다.
interface Props {
fallback: ElementType;
children?: ReactNode;
}
//에러상태를 구분하고, 에러를 핸들링하기위한 state의 interface이다.
interface State {
hasError: boolean;
error: Error | null;
}
//에러를 가지지 않은 초기상태이다.
const initialState: State = {
hasError: false,
error: null,
};
class GlobalErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = initialState;
}
//error를 매개변수로 받고 갱신될 state 값을 반환한다. getDerivedStateFromError는 렌더링 결과로 수집한 내용으로 Virtual DOM을 생성하고
//이전 Virtual DOM과 비교하는 단계인 렌더 단계에 작동한다.
//내부에 사이드이펙트를 발생시킬 작업을 해서는 안된다.
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
//componentDidCatch는 Virtual DOM을 이용해 계산된 모든 변경사항 실제 DOM에 적용하는 단계인 커밋 단계에서 호출되어 실행된다.
//따라서 이 메서드 내부에는 side effects가 발생해도 된다. 따라서 에러 정보에 대한 로그를 남길 때, 주로 사용된다.
//에러 정보를 기록할 때 주로 사용된다.
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.log(error);
console.log('errorInfo', errorInfo);
}
public render() {
const { hasError, error } = this.state;
const { children } = this.props;
if (error?.message === 'Request failed with status code 401') {
return <AuthErrorPage />;
} else if (hasError) {
return <this.props.fallback />;
}
return children;
}
}
export default GlobalErrorBoundary;
내부 컴포넌트를 감싸는 ApiErrorBoundary는 다음과 같이 GlobalErrorBoundary를 확장해서 사용합니다. 주요하게 볼 부분은 ApiErrorBoundary가 가지는 State인데, shouldHandleError는 현재 ApiErrorBoundary에서 처리가능한 에러를 의미하고, shouldRethrow는 처리하지 못하고 GlobalErrorBoundary에 error를 다시 전파하기 위해 사용하는 변수입니다.
import { ElementType, ReactNode, Component } from 'react';
import { AxiosError } from 'axios';
interface Props {
fallback: ElementType;
children?: ReactNode;
}
interface State {
shouldHandleError: boolean;
shouldRethrow: boolean;
error: Error | AxiosError | null;
}
const initialState: State = {
shouldHandleError: false,
shouldRethrow: false,
error: null,
};
class ApiErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = initialState;
}
//401에러 클라이언트가 인증되지 않았을 때 발생하는 에러로, 흔히 말하는 로그인 에러이다.
//로그인 에러의 경우 상위 GlobalErrorBoundary로 error를 전파하여 공통로직으로 처리한다.
public static getDerivedStateFromError(error: Error | AxiosError): State {
if (error instanceof AxiosError) {
if (error.response?.status === 401) {
return {
shouldHandleError: false,
shouldRethrow: true,
error,
};
}
}
return { shouldHandleError: true, shouldRethrow: false, error };
}
componentDidCatch(error: Error): void {
console.log(error, 'api에러바운더리');
}
public render() {
const { shouldHandleError, shouldRethrow, error } = this.state;
const { children } = this.props;
//shouldRethrow가 true일때 상위 에러바운더리로 에러를 전파한다.
if (shouldRethrow) {
throw error;
}
//shouldHandleError가 false 일때 정상적인 컴포넌트를 렌더링 해준다.
if (!shouldHandleError) {
return children;
}
//shouldRethrow가 false, shouldHandleError가 true 일때, 에러를 처리해줄 fallback UI
//onReset에는 shouldHandleError를 false로 setState하여 children을 re-mount 시켜 정상적인 UI를 다시 렌더링한다.
return <this.props.fallback onReset={() => this.setState({ shouldHandleError: false })} />;
}
}
export default ApiErrorBoundary;
사용부를 살펴보자!
const App = () => {
return (
<GlobalErrorBoundary fallback={ErrorPage}>
<Suspense fallback={<div>로딩중...</div>}>
<ApiErrorBoundary fallback={ApiErrorPage}>
<Router />
</ApiErrorBoundary>
</Suspense>
</GlobalErrorBoundary>
);
};
초기에는 GlobalErrorBoundary 하위에 Suspense를 두고, ApiErrorBoundary를 두어 두개의 에러바운더리를 통해 에러핸들링을 분기하여 처리할 수 있는 구조를 가집니다. 이렇게 설정된 구조를 가지고 처리한 에러 예시는 다음과 같습니다.


<ApiErrorBoundary fallback={ApiErrorPage}>
<PostList postFilterObj={postFilterObj} searchKeyword={searchKeyword} />
<div className="postAddDiv">
<Link to="/addpost">
<FiPlusCircle />
</Link>
</div>
</ApiErrorBoundary>
기존의 PostListPage에서 PostList 컴포넌트를 분리하고, 컴포넌트 상위에 ApiErrorBoundary로 감싸줍니다. 이로써 일부 컴포넌트만 에러바운더리 처리를 통한 에러핸들링이 가능하게 되었습니다.

2. 하단의 풋터부분은 라우팅을 위한 버튼들이 위치되어 있는데, 이런 라우팅에 에러상태가 영향을 주지않을까라는 부분이었습니다.
- 1의 경우 새로고침을 통해 에러페이지를 정상적인 페이지로 렌더링할때까진 작동하지 않고, 새로고침 버튼 클릭시 설정된 필터링 대로 api요청을 하는 것을 10번 이상의 테스트를 통해 확인했습니다.
- 2의 경우 에러상태와 상관없이, 페이지 라우팅이 동작하는 것을 확인했습니다. 이후 원래의 에러페이지로 돌아왔을때 기존 에러 페이지가 unmount 된 후 다시 mount 되면서 정상적인 컴포넌트를 다시 보여주는 것을 확인했습니다. (ApiErrorBoundary가 결국 Api 오류를 해결하고 다시 정상적인 페이지를 보여주려는 목적을 가지기에 이는 정상적인 작동을 하는 것으로 판단했습니다.)
이번 에러바운더리를 적용하면서 GlobalErrorBoundary로 Rethrow 할 error 케이스를 세분화 하지 못한 점이 아쉽습니다. 실제 서비스를 하고 데이터를 모으면 이러한 에러 상황에 대한 세분화가 가능할 것 같아 이후 인사이트를 얻는다면 다시 한번 에러바운더리 부분은 리펙토링을 해볼 계획입니다. 하지만 이번 구현을 하면서 많은 레퍼런스들을 참고하고, 하나하나 디버깅하거나 적용하면서 비교를 통해 좀 더 나은 해결책을 찾으려고 했다는 점에서 고무적입니다. 에러바운더리에 대해 이후 많은 동료들과 토론하며 좀 더 좋은 방법, 인사이트를 얻게 되면 덧붙이겠다는 각오를 마지막으로 이 시리즈를 마쳐볼까 합니다. 읽어주셔서 감사합니다.
고민했던 부분들
useErrorBoundary 옵션을 true로 설정했을때, isError은 작동하지 않는다. 옵션이 true일때 에러는 useInfiniteQuery나 useQuery 단계에서 처리가 되어 상위 에러바운더리로 전파가 되기 때문에, isError를 사용해 Error를 전파하는 방식은 동작하지 않는다.
const {
data: postListData,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
isError,
} = useInfiniteQuery(
[queryKeys.postList, postFilterObj.category, postFilterObj.area, searchKeyword],
({ pageParam = 0 }) => getPosts(pageParam),
{
getNextPageParam: (postListData) => (!postListData.last ? postListData.nextPage : undefined),
},
);
if (isError) {
console.log('에러발생');
throw Error('데이터 불러오기에 실패했습니다.');
}

=> 실제로 브라우저 환경에서 디버깅을 해봤을때, 로그아웃 상태나 에러를 일부러 발생시킨 상황에서 isError는 false값으로 그대로 유지된다.
useQuery나 useInfinityQuery의 호출함수에서 try catch문을 통하거나, onError 옵션을 활용하여 throw Error를 하는 방식으로 에러를 처리하자.
+ 컴포넌트 분리하기전 했던 바보짓...
와 이건 진짜 쓰기 부끄러웠지만 반성하자는 의미에서 써본다...지금은 분리를 해놓은 postList에 그냥 ApiErrorBoundary를 감싸놓고 왜 안되지? 를 반복했다...당연히 안되는게 맞았다. 그치만 그런생각은 못하고 왜 에러를 외부로 전파하지 못할까를 고민했다는 사실. hook을 사용해서 React-Query로 데이터 패칭을 해왔다면 훅 사용부인 postListPage 외부에다가 ApiErrorBoundary를 배치하셨어야죠...이 문제를 깨닫는데 근 3일이 걸렸다는 슬픈 전설. 그래도 진행하면서 useErrorBoundary 옵션을 true로 설정했을때 isError가 작동하지 않는 등의 깨달음을 얻었기에 좋은 부끄러움이었다고 스스로를 위로해본다.

참고자료
https://ko.reactjs.org/docs/error-boundaries.html
에러 경계(Error Boundaries) – React
A JavaScript library for building user interfaces
ko.reactjs.org
https://fe-developers.kakaoent.com/2022/221110-error-boundary/
React의 Error Boundary를 이용하여 효과적으로 에러 처리하기
카카오엔터테인먼트 FE 기술블로그
fe-developers.kakaoent.com
React Query와 함께 Concurrent UI Pattern을 도입하는 방법 | 카카오페이 기술 블로그
카카오페이에서 React Query를 활용하여 Concurrent UI Pattern을 도입한 사례에 대해 소개합니다. 이 글은 연작 중 2편에 해당합니다. 1편: 카카오페이 프론트엔드 개발자들이 React Query를 선택한 이유, 2
tech.kakaopay.com
본문의 예시코드들은 다음 레퍼지토리에서 확인할 수 있습니다.
https://github.com/jong6598/daangn_FE_refactoring
GitHub - jong6598/daangn_FE_refactoring: 당근마켓 프로젝트 리펙토링 버전
당근마켓 프로젝트 리펙토링 버전. Contribute to jong6598/daangn_FE_refactoring development by creating an account on GitHub.
github.com
'개발 > 개발일지' 카테고리의 다른 글
"Skeleton UI" 와 "사용자 경험 개선"의 은밀한 관계 (0) | 2023.02.20 |
---|---|
왜 queryClient 설정에 useState를 사용할까? (1) | 2023.02.10 |
지역 중고거래 및 정보교류 커뮤니티 프로젝트 마이그레이션 후기(1차) (0) | 2023.01.30 |
Optimistic UI(낙관적 UI) (2) | 2022.12.27 |
[Trouble Shooting] useMutation을 활용한 생성, 수정, 삭제 (0) | 2022.08.30 |