서비스를 개발하고 운영하다보면 개발자의 입장에서는 참 많은 에러를 마주치고 해결하고는 합니다. 규모가 큰 서비스일수록 사용자에게 높은 UX를 제공하려면 고려해야하는 상황들이 커지기 마련이고 그에 따라 예외 처리를 해야하는 부분도 늘어나는게 당연하게 되죠.
최대한 에러 처리를 완벽하게 하려고 개발자들은 노력하지만 아무리 완벽하게 서비스를 제작해도 사용자는 에러를 맞닥뜨리기 마련입니다. 다만 에러 과정에서 사용자에게 어떤 UI를 제공하고 대처 방안을 어떻게 제공하는지에 따라 UX 영향이 매우 커진다고 생각합니다.
이번 포스팅에서는 Error boundary
를 통해 사용자에게 제공될 클라이언트 에러 관련 UI 개선 및 에러 처리를 다뤄보려합니다.
Error boundary(에러 경계)
는 React 16에서 새로 도입된 개념으로 하위 컴포넌트 트리의 어디에서든 자바스크립트 에러를 기록하며 깨진 컴포넌트 트리 대신Fallback UI
를 보여주는 React 컴포넌트입니다. - React 공식 문서 -
공식 문서에서도 나와있듯 컴포넌트 일부에서 발생한 JavaScript 에러가 전체 어플리케이션을 중단시키면 안됩니다. 그렇기에 에러가 발생시 에러가 발생한 컴포넌트를 보여주면서 서비스를 멈추는게 아닌 Fallback UI
를 보여줌으로써 중단을 방지합니다.
여기서 Fallback
은 어떤 기능이 제대로 동작하지 않을 때, 이에 대처하는 기능 또는 동작을 지칭하며 실패에 대한 예비 기능 처리를 설정해놓는다고 생각하시면 됩니다. 그러므로 fallback ui는 에러시 사용자에게 제공되는 예비 UI입니다.
Suspense
를 활용해보신분이라면 fallback 개념에 이미 익숙할 것이라고 생각합니다.
React 공식 문서에 나와있는 ErrorBoundary 예제 코드는 아래와 같습니다.
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
간단히 코드 설명을 해드리겠습니다.
error를 매개변수로 받아 갱신될 state값을 반환합니다. 하위 컴포넌트에서 발생한 에러를 감지하고, state 업데이트를 통해 에러 발생 여부를 컴포넌트에 반영하고 다음 렌더링에서 fallback UI를 표시합니다.
이 함수는 렌더 단계에서 호출되기 때문에 함수 내부에서는 side effects가 발생할만한 작업을 해서는 안됩니다.
Side Effect
는React 컴포넌트가 화면에 렌더링된 이후에 비동기로 처리되어야 하는 부수적인 효과들을 얘기합니다. 화면 렌더링적인 부분이 아닌 데이터나 다른 기능처리들을 말하며 렌더링이 먼저 이루어져야 UX 측면에서 유리하기 때문입니다.
렌더 단계는 렌더링 결과로 수집한 내용을 Virtual DOM으로 새로 만들고 이전 Virtual DOM과 비교하는 단계이기 때문입니다.
componentDidCatch
는 렌더 단계가 아닌 커밋 단계에서 호출되어 실행됩니다. 커밋 단계는 Virtual DOM을 이용해 계산된 모든 변경사항을 실제 DOM에 적용하는 단계입니다.
그렇기에 해당 함수 내부에서는 side effects가 발생해도 괜찮은 단계입니다. 보통 에러 정보에 대한 로그를 남길때 주로 사용합니다.
컴포넌트에서 발생한 에러를 캐치하고 처리하는 역할을 주로 맡아서 하며 에러에 대한 정보를 받는error
와 그에 관한 info
를 파라미터로 받습니다.
위 코드를 수정 및 커스텀을 통해 Error boundary의 최소한의 처리는 충분히 가능합니다. 다만 문제점이 몇 가지 존재합니다.
공식 예제의 ErrorBoundary는 위에 나와있는 상황에서는 에러를 포착하지 못합니다. 그렇다면 가장 큰 문제는 비동기 코드
를 잡아내지 못하는 부분에 있습니다.
대부분의 api는 비동기 처리로 이루어지는데 특히 제가 현재 사용하고 있는 React-query
는 모든 데이터 fetching을 비동기로 작동되고 있습니다. 그렇기에 api 과정에서 일어나는 오류는 공식 예제 코드로는 잡아낼 수 없습니다.
그리고 Fallback UI가 고정되어 있기 때문에 에러 상태 코드마다 다른 UI에 대한 커스텀에 어려움이 있습니다.
추가적으로 class형 컴포넌트
이기 때문에 최근 React의 장점을 살린 함수형 hook 컴포넌트로써 사용이 불가능하고 생명 주기 cycle등이 다르기 때문에 커스텀 및 활용에 어려움을 느끼는 개발자분들도 있습니다.
react-error-boundary
라이브러리를 사용하면 위에서 언급한 단점들을 보완하고 함수형 컴포넌트로 error boundary를 더 쉽게 사용할 수 있습니다.
react-error-boundary 공식문서에 나와있는 공식 예제 코드는 아래와 같습니다.
import { ErrorBoundary } from "react-error-boundary";
function FallbackComponent({ error, resetErrorBoundary }) {
return (
<div>
<p>에러 메시지: {error.message}</p>
<button onClick={() => resetErrorBoundary()}>다시 시도</button>
</div>
);
}
<ErrorBoundary
fallbackRender={fallbackRender}
// 리셋 함수
onReset={() => console.log("error")}
// 에러 발생 시 작동 코드
onError={() => console.log("error")}
// Fallback UI 컴포넌트
FallbackComponent={FallbackComponent}
>
<Component />
</ErrorBoundary>;
react-error-boundary를 사용하면 에러 발생 시 실행할 로직과 에러별 Fallback UI도 상황에 맞게 렌더링 시킬 수 있고 Fallback 컴포넌트 내부에서의 재시도, 라우팅 등의 기능들도 편리하게 구현할 수 있습니다.
FallbackComponent props에는 error와 resetErrorBoundary가 넘어오는데 error는 발생한 에러 정보에 대한 객체가, resetErrorBoundary는 reset시 작동하는 함수가 넘어오게 됩니다.
react-query
는 fetching, caching, 서버 데이터와의 동기화를 지원해주는 라이브러리로 서버측에 api 요청을 통해 데이터를 받아올때 많이 사용하는 라이브러리입니다.
왜 react-query와의 연관성이 좋냐고 물어보시면 위 코드를 보면 onReset
props에 fallback component 이벤트 처리를 해주어야 하는데 react-query에서 제공하는 useQueryErrorResetBoundary
를 활용하면 쉽게 처리가 가능합니다.
QueryErrorResetBoundary
는 공식 문서를 확인해보면 suspense
또는 throwOnError
를 사용할 때 오류 발생 시 리렌더링을 통해 경계 내의 모든 쿼리 오류를 재설정할 수 있다고 나와있습니다.
공식 문서에 나와있는 코드는 다음과 같습니다.
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
const App = () => (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
에러 발생
<Button onClick={() => resetErrorBoundary()}>다시 시도</Button>
</div>
)}
>
<Page />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
)
QueryErrorResetBoundary 내부 요소들에 대한 query 에러를 재설정할 수 있으며 아래 코드와 같이 여러 ErrorBoundary를 사용할 수도 있습니다.
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallback={ErrorFallback}
message="사용자 정보 조회 실패"
>
<User />
</ErrorBoundary>
<ErrorBoundary
onReset={reset}
fallback={ErrorFallback}
message="게시물 정보 조회 실패"
>
<Post />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
useQueryErrorResetBoundary
는 QueryErrorResetBoundary 컴포넌트 하위에 있는 모든 쿼리 에러를 재설정합니다. 만약 정의된 QueryErrorResetBoundary가 없으면 전역으로 설정됩니다.
공식 문서에 나와있는 예제 코드는 아래와 같습니다.
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
const App = () => {
const { reset } = useQueryErrorResetBoundary()
return (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
에러
<Button onClick={() => resetErrorBoundary()}>다시 시도</Button>
</div>
)}
>
<Page />
</ErrorBoundary>
)
현재 제작하고 있는 프로젝트는 React, React-Query 기반 프로젝트로 현재까지는 Suspense로 데이터 fetching시 SKeleton UI만 사용자에게 제공되고 있었습니다.
그럼 지금까지 알아본 react-error-boundary
와 QueryErrorResetBoundary
를 사용해 에러 처리 및 코드 리팩토링을 진행해보았습니다.
우선 프로젝트에서 공통적으로 사용될 Error
컴포넌트를 만들어주었습니다.
const Error = ({ error, resetErrorBoundary }: FallbackProps) => {
const statusCode = error.response.status;
const isHTTPError = hasKeyInObject(HTTP_ERROR_MESSAGE, statusCode);
const { handleTokenError } = useTokenError();
if (!isHTTPError) return null;
if (error.response.code > ERROR_CODE.TOKEN_ERROR_RANGE) {
handleTokenError();
return null;
}
return (
<Box>
<Flex css={layoutStyle}>
<Logo width={300} height={300} />
<Heading css={headingStyle} size="small">
{HTTP_ERROR_MESSAGE[statusCode].HEADING}
</Heading>
<Text css={textStyle}>{HTTP_ERROR_MESSAGE[statusCode].BODY}</Text>
<Button onClick={resetErrorBoundary}>{HTTP_ERROR_MESSAGE[statusCode].BUTTON}</Button>
</Flex>
</Box>
);
};
export default Error;
위 코드는 ErrorBoundary FallbackComponent props에 전달될 컴포넌트로 기능 및 UI마다 재시도 관련 UI는 따로 구성하겠지만 프로젝트 전체에서 default로 사용될 컴포넌트라고 생각하시면 됩니다. UI는 저는 디자인 없이 제작한거라 조금 조잡하지만 디자이너에게 요청해서 각자 맞는 UI를 구성하시는게 좋을 거 같습니다.
그후 react-error-boundary를 사용한 공통 Error boundary
컴포넌트를 만들어 주었습니다.
import { ErrorBoundary } from "react-error-boundary";
import { useQueryErrorResetBoundary } from "@tanstack/react-query";
import Error from "@/components/common/Error/Error";
const RootErrorBoundary = ({ children }: { children: React.ReactNode }) => {
const { reset } = useQueryErrorResetBoundary();
return (
<ErrorBoundary onReset={() => reset} FallbackComponent={Error}>
<Suspense fallback={<Loading />}>
{children}
</Suspense>
</ErrorBoundary>
);
};
export default RootErrorBoundary;
이후 api 요청을 통해 에러를 확인해야 하는데 저는 현재 api 개발이 다 되어있는 상태로 억지로 에러 체크를 하긴 어려움이 있으니 msw를 사용해서 에러를 던져주게 설정했습니다.
export const testHandlers = [
rest.get("/api/hello", (_, res, ctx) => {
return res(ctx.status(500), ctx.delay(1000), ctx.json({ data: "오류" }));
}),
];
이후 테스트용 Query 들을 작성해주었습니다.
const getData = async () => {
const { data } = await axios({
method: "get",
url: "/api/hello",
});
return data;
};
const getDataQuery = () => {
const { data: storyData } = useSuspenseQuery({
queryKey: ["data"],
queryFn: () => getData(),
retry: false,
});
return { storyData };
};
참고로 저는 react-query v5를 사용하고 있고 suspenseQuery를 서비스에 사용하고 있기 때문에 queryClient의 별다른 설정 없이도 error-boundary 기능이 작동되지만 v4를 사용하시거나 useQuery를 사용하시는 분들은 queryClient의 설정이 필요합니다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
useErrorBoundary: true,
suspense: true,
},
},
});
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onError: handleError,
},
queries: {
throwOnError: true,
},
},
queryCache: new QueryCache({
onError: handleError,
}),
});
이제 서비스를 동작시키면 에러가 발생하면서 Fallback UI가 정상적으로 표시되는걸 확인할 수 있습니다.
400 에러로 바꿔봐도 이에 맞는 UI를 확인할 수 있습니다.
이제 Global한 error 처리는 완료했으니 디자인 요청을 통해 각 api 및 기능 별로 Fallback UI를 상황에 맞게 구성할 수 있게 되었습니다.
사용자의 UX 경험은 서비스에서 매우 중요할 수 밖에 없다고 생각합니다. 그런 사용자에게 서비스를 사용하면서 불편함들을 최소화 시키기 위해서는 개발도 중요하지만 개발된 서비스를 사용해보고 직접 사용자의 입장으로 피드백을 진행하면서 개선을 이어나가야 한다고 생각합니다.
UX 경험을 크게 상향시킬 수 있는 요소들이 fallback UI
와 Loading UI
이고 Skeleton UI를 사용한 Loading fallback 처리는 이미 서비스에 적용되어 있기에 error boundary fallback UI를 구성해보았습니다.
React 공식 문서에서 제공하는 class형 error-boundary로도 커스텀을 연습해볼 예정이지만 class형 컴포넌트에 대한 지식이 많이 부족해서 학습이 더 필요하다고 생각됩니다.
다음에는 기회가 된다면 suspense에도 자세히 다뤄보도록 하겠습니다.
감사합니다.
error boundary 공식 문서
https://ko.legacy.reactjs.org/docs/error-boundaries.html
react-error-boundary 공식 문서
https://www.npmjs.com/package/react-error-boundary
QueryErrorResetBoundary 공식 문서
https://tanstack.com/query/latest/docs/framework/react/reference/QueryErrorResetBoundary
카카오 페이 React Query와 함께 Concurrent UI Pattern을 도입하는 방법
https://tech.kakaopay.com/post/react-query-2/#%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EB%B3%B4-%EC%98%81%EC%97%AD%EA%B3%BC-%EC%95%8C%EB%A6%BC-%EC%98%81%EC%97%AD%EC%9D%80-%EB%91%98-%EB%8B%A4-%EB%B6%88%EB%9F%AC%EC%99%80%EC%A7%80%EA%B8%B0-%EC%A0%84%EC%97%90%EB%8A%94-%EC%8A%A4%EC%BC%88%EB%A0%88%ED%86%A4-ui%EB%A5%BC-%EB%85%B8%EC%B6%9C%ED%95%9C%EB%8B%A4
카카오 엔터테이먼트 React의 Error Boundary를 이용하여 효과적으로 에러 처리하기
https://fe-developers.kakaoent.com/2022/221110-error-boundary/
[React] ErrorBoundary를 통한 선언적인 에러 핸들링, react-query를 이용한 재호출 방법
https://lasbe.tistory.com/183
React ErrorBoundary를 사용하여 에러 처리 개선하기 (with react-query)
https://velog.io/@suyeon9456/React-Query-Error-Boundary-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0#%EF%B8%8F-react-query%EC%97%90-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0