혹시 개발 중에 앱이 에러를 잡지 못해 발생하는 아래의 에러 화면을 보신 적이 있으신가요?
위 에러의 내용을 살펴보면 React Router는 더 나은 UX를 위해 ErrorBoudary를 제공할 것을 권장하고 있습니다. 그렇다면 ErrorBoundary는 어떻게 동작하고, 어떤 방식으로 사용하면 좋을까요?
최근에 진행한 개인 프로젝트와 과제에서 더 나은 사용자 경험을 위해 ErrorBoundary를 통한 에러 핸들링을 하는 과정에서 가졌던 의문점과 고민, 그리고 해결 방법을 공유하고자 합니다. 해당 포스팅은 React 공식 문서와 React-Query 공식 블로그를 바탕으로 ErrorBoundary의 역할과 ErrorBoundary가 잡을 수 있는 에러 유형, React Query에서의 에러 핸들링 전략을 다룰 것이고, 마지막으로 React Query에서 ErrorBoundary를 적용하는 방법을 살펴볼 예정입니다.
ErrorBoundary란?
React 공식 문서에서는 ErrorBoundary를 다음과 같이 정의합니다.
기본적으로 애플리케이션이 렌더링 동안에 에러를 발생시키면, React는 화면에서 해당 UI를 제거합니다. 이를 방지하기 위해 UI 일부를 ErrorBoundary로 감쌀 수 있습니다. React의 ErrorBoundary는 에러가 발생한 영역 대신 fallback UI를 보여주도록 하는 React 컴포넌트입니다.
즉, ErrorBoundary는 하위에서 에러가 발생할 시 지정한 fallback UI를 대신 표시하여 쾌적한 사용자 경험을 제공합니다. react-error-boundary를 사용하면 ErrorBoundary를 함수형 컴포넌트로 작성할 수 있습니다.
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
const App = () => {
return (
<ErrorBoundary fallback={<p>에러가 발생했습니다</p>}>
<SomeComponent />
</ErrorBoundary>
);
};
ErrorBondary를 사용할 때 유의해야 할 점은 아래와 같습니다.
- 트리에서 ErrorBoundary 컴포넌트 하위에서 발생하는 에러만 잡을 수 있습니다.
- 에러가 발생한 UI에서 가장 가까운 ErrorBoundary가 에러를 처리하고, 실패하거나 존재하지 않는다면 에러가 상위로 전파됩니다. 이를 통해 원하는 단위로 fallback UI를 계층적으로 구성할 수 있습니다.
- 런타임에 발생하는 에러만 잡을 수 있습니다.(아래 ErrorBoundary가 잡을 수 있는 에러 유형에서 자세히 다루겠습니다)
ErrorBoundary의 의미
React 컴포넌트는 “무엇을” 렌더해야하는지를 나타내는 선언형 컴포넌트입니다. ErrorBoundary는 이러한 React의 특성을 잘 반영하여 에러가 발생했을 시 “무엇을” 보여줄 지를 결정합니다. 이는 “어떻게” 화면을 구성해야할지를 나타내는 명령형 코드와 대비됩니다.
React Query를 사용해 데이터를 가져올 경우, 아래와 같은 차이가 있습니다. useCourse 훅은 서버에서 강의 리스트를 요청해 data에 반환한다고 가정합니다.
- 명령형
// 명령형: 어플리케이션 상태에 따라 어떻게 화면을 구성할지에 집중
const CourseList = () => {
const { data: courses, isError } = useCourses();
if (isError) {
return <p>에러 발생 🤯</p>
}
return (
<div>
{courses?.map((course) => (
<Course key={course.id} course={course} />
))}
</div>
);
};
export default CourseList;
- 선언형
// 선언형: 어플리케이션 상태에 따라 무엇을 보여줄지에 집중
const CourseList = () => {
const { data: courses } = useCourses();
return (
<ErrorBoundary fallback={<p>에러 발생 🤯</p>}>
<div>
{courses?.map((course) => (
<Course key={course.id} course={course} />
))}
</div>
</ErrorBoundary>
);
};
export default CourseList;
ErrorBoundary가 잡을 수 있는 에러 유형
ErrorBoundary는 렌더링 동안에 발생하는 에러만 잡을 수 있습니다. 이를 통해 에러가 발생하면 fallback UI를 대신 보여줍니다.
React 공식 문서에 따르면 ErrorBoudary는 잡지 못하는 에러는 다음과 같습니다.
- 이벤트 핸들러
- 비동기 코드(setTimeout, 또는 비동기 api 요청)
- 서버 사이드 렌더링
- ErrorBoundary 자신이 던진 에러
여기에서 눈여겨 볼 것은 비동기 코드인데, 비동기 에러는 렌더링 중에 발생하지 않기 때문에 ErrorBoundary에서 잡지 못합니다. 그러면 서버에 데이터를 요청하는 코드에서 발생하는 에러는 다 잡지 못한다는 건데, 무슨 의미가 있을까요? 분명히 React Query에서는 ErrorBoundary를 사용했을 때 동작했던 것 같은데, 어떻게 된 일일까요?
React Query 공식 블로그에 따르면, React Query는 ErrorBoundary가 에러를 잡을 수 있도록 내부적으로 렌더링동안 발생하는 에러를 잡아 다음 렌더 사이클에 다시 던집니다. 즉, ErrorBoundary가 서버 요청 시 발생하는 에러도 잡을 수 있게 됩니다. 그 덕에 우리는 걱정할 필요 없이 에러 처리가 필요한 부분에 ErrorBoundary를 감싸주기만 하면 됩니다.
React Query에서 에러 핸들링하기
React Query의 메인테이너 Tkdodo 씨가 제시하는 React Query에서 에러를 처리하는 방식은 크게 3가지입니다. 하나씩 살펴봅시다
1. useQuery가 반환하는 error 프로퍼티
2. ErrorBoundary
3. onError 콜백
📌 React Query의 useErrorBoundary 옵션
[v4 이하] React Query에서 ErrorBoundary를 사용하려면 useErrorBoundary 또는 suspense 옵션을 true로 주어야 합니다.
[v5] 버전 5에서는 useErrorBoundary 대신 throwOnError를 사용합니다.
이렇게 ErrorBoundary의 사용 가능 여부 기본값이 suspense 사용 여부에 묶여있는 이유는 무엇일까요? 이는 React가 ErrorBoundary를 Suspense와 함께 사용해 에러를 처리하는 방식이라고 정의했기 때문이라고 합니다.즉, Suspense 없이도 ErrorBoudary를 사용할 수 있지만, ErrorBoundary 없이 Suspense를 사용하는 것은 불가능합니다. (참고)
import React from 'react';
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true, // 이것만 써도 사용 가능
useErrorBoundary: true, // ErrorBoundary 사용
retry: 0,
},
},
});
const App = () => (
<>
<QueryClientProvider client={queryClient}>
<MainComponent />
</QueryClientProvider>
</>
);
export default App;
- useQuery가 반환하는 error 프로퍼티: 명령형 UI를 구성하는 방법으로, 상태에 따라 “어떻게” 화면을 구성할지 나타냅니다. useQuery가 반환하는 isLoading, isError 등의 상태에 따라 보여줄 UI를 반환합니다.
- ErrorBoundary: 선언형 UI를 구성하는 방법으로, “무엇을” 보여줄지를 나타냅니다. useQuery를 사용하는 컴포넌트를 감싸고 에러 발생 시에 보여줄 fallback UI를 지정합니다.
- onError 콜백
onError 콜백은 에러가 발생할 때 호출되는 함수로, useQuery와 QueryClient의 옵션으로 지정할 수 있습니다.
- useQuery의 옵션으로 사용하기
useQuery의 onError는 해당 쿼리에서 에러가 발생할 때 실행됩니다.
이는 직관적인 것처럼 보이지만, useCourse 훅을 어플리케이션 내에서 여러 번 사용한다면, 네트워크 요청이 한 번 실패하는 경우에도 onError가 여러 번 실행된다는 문제점이 있습니다. 위의 예에서는 토스트가 여러 번 표시될 것입니다. 그렇다면 네트워크 요청이 실패할 때 한 번만 콜백함수를 실행하려면 어떻게 하면 될까요?
- useQuery의 옵션으로 사용하기
const useCourse = () => useQuery({
queryKey: ['courses'],
queryFn: fetchCourses,
onError: (error) => {
toast.error(`에러 발생 😣 : ${error.message}`)
console.error(error)
}
});
- QueryCache의 옵션으로 사용하기
QueryClient를 생성 시에 암묵적으로 생성되는 QueryCache의 onError는 각 쿼리에서 에러가 발생할 때 한 번 실행됩니다.
어떻게 보면 의미 상 같아 보이지만, QueryClient를 통해 생성된 queryClient는 전역에 공유되기 때문에 위의 useCourse 훅처럼 재호출되어 여러 번 실행되지 않습니다. 쿼리 실패 시에 네트워크 요청 당 한 번만 실행하도록 보장되며, defaultOptions처럼 덮어 씌워질 수 없기 때문에 이러한 global 콜백은 에러를 추적하고 확인하는 최선의 방법입니다.
const queryErrorHandler = error => {
toast.error(`${error?.message}
다시 시도해 주세요.`);
console.error(error);
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
useErrorBoundary: true,
retry: 0,
},
},
queryCache: new QueryCache({
onError: queryErrorHandler, // global callback
}),
});
React Query와 ErrorBoundary 사용하기 (Feat. ErrorFallback 컴포넌트)
알게 된 내용을 바탕으로 React Query와 ErrorBoundary를 사용해 에러 핸들링을 할 수 있는 ErrorFallback 컴포넌트를 작성해보겠습니다.
먼저, 서버에서 다음과 같이 응답을 보낸다고 가정합니다.
app.get('/api/articles', async (req, res) => {
try {
const articles = await fetchArticles();
if (newArticles.length === 0) {
// 뉴스 기사가 없는 경우
res.status(404).json({
success: false,
error: {
code: 'NO_NEW_ARTICLES',
message: 'No new articles found',
}
});
} else {
// 요청 성공!
res.status(200).json({ success: true, articles });
}
} catch (error) {
// 에러 발생
console.error(error);
res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'Internal Server Error',
}
});
}
});
뉴스 기사가 없는 경우 404, 이외 에러가 발생한 경우 500 에러를 던지고, 요청이 성공하면 200으로 응답합니다.
서버에서 넘겨받는 에러 메시지의 구조에 따라 다르겠지만, 이 경우 다음처럼 fallback UI에서 표시할 에러 메시지를 구성할 수 있습니다.
const ErrorFallback = ({ error, resetErrorBoundary }) => {
const { data } = error.response;
const handleResetButtonClick = () => {
resetErrorBoundary();
};
return (
<Container>
<Title>서비스에 접속할 수 없습니다.</Title>
<Message>{data.error.message}</Message>
<ResetButton onClick={handleResetButtonClick}>Reset</ResetButton>
</Container>
);
};
뉴스 기사가 없는 경우, 위와 같은 fallback UI를 표시합니다.
실제 프로젝트에서 사용한 ErrorFallback 컴포넌트는 아래와 같습니다. error 응답에 status 코드가 있는 경우와 없는 경우를 처리합니다.
import React from 'react';
import styled from 'styled-components';
const getErrorMessage = (status: number) => {
switch (status) {
case 409:
case 500:
default:
return {
title: '서비스에 접속할 수 없습니다.',
content: '새로고침을 하거나 잠시 후 다시 접속해 주시기 바랍니다.',
};
}
};
const getErrorContent = error => {
// status 코드 있는 경우
if (error?.response) {
const { status } = error.response;
return getErrorMessage(status);
}
// 없는 경우는 api 요청 함수에서 throw new Error() 필요
return {
title: '서비스에 접속할 수 없습니다.',
content: error.message,
};
};
const ErrorFallback = ({ error, resetErrorBoundary }) => {
const { title, content } = getErrorContent(error);
const handleResetButtonClick = () => {
resetErrorBoundary();
};
return (
<Container>
<Title>{title}</Title>
<Message>{content}</Message>
<ResetButton onClick={handleResetButtonClick}>Reset</ResetButton>
</Container>
);
};
const Container = styled.div`
display: flex;
min-height: 100%;
flex-direction: column;
justify-content: center;
align-items: center;
`;
const Title = styled.h2`
font-size: 24px;
padding: 12px;
`;
const Message = styled.p`
font-size: 20px;
`;
const ResetButton = styled.button`
width: 100px;
height: 48px;
color: #fff;
background: #000;
font-weight: 500;
font-size: 22px;
border-radius: 8px;
margin: 20px;
`;
export default ErrorFallback;
요약
React의 ErrorBoundary는 에러가 발생한 영역 대신 fallback UI를 보여주는 컴포넌트입니다. ErrorComponent는 에러를 선언적으로 핸들링하는 방식으로 React의 선언형 UI와 일맥상통하고, 쾌적한 사용자 경험을 제공한다는 장점이 있습니다. ErrorComponent는 렌더링 동안 발생하는 에러만 잡을 수 있기 때문에 네트워크 요청 실패와 같은 비동기 에러는 잡지 못합니다.
React Query는 렌더링 동안 발생하는 에러를 잡아 다음 렌더 사이클에 던짐으로써 ErrorBoundary를 사용할 수 있도록 합니다. React Query에서 에러를 핸들링하는 방식은 1. useQuery가 반환하는 error 프로퍼티, 2. ErrorBoundary, 3. onError 콜백 방식의 세 가지가 있고, 이를 적절히 섞어 사용하는 것이 좋습니다. 예를 들어, 데이터를 background refetch할 경우는 UI를 유지하면서 토스트로 에러 메세지를 표시하고, 이외에는 해당 컴포넌트 또는 ErrorBoundary를 이용해 fallback UI를 보여줄 수 있습니다.
현재로서 내린 선언형 UI를 구현하기 위한 결론은 다음과 같습니다. useQuery의 경우, 데이터를 가져올 때는 Suspense로 스켈레톤 UI를 보여주고, 로딩 시 에러가 발생하면 ErrorBoundary를 통해 fallback UI를 표시합니다. 이후 background refetch할 때는 UI를 유지하면서 토스트로 에러 메세지를 띄웁니다. useMutation의 경우, 낙관적 업데이트를 통해 UI를 즉각적으로 변경하되 에러가 발생할 경우 마찬가지로 UI를 유지하고 토스트로 에러 메세지를 표시합니다. 이는 mutation이 실패할 경우에도 onError에서 setDataQuery를 통해 롤백하고 이전 데이터를 표시할 수 있기 때문입니다.
REF
https://tanstack.com/query/latest/docs/react/guides/query-functions?from=reactQueryV3&original=https://tanstack.com/query/v3/docs/guides/query-functions#usage-with-fetch-and-other-clients-that-do-not-throw-by-default
https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundaryhttps://tanstack.com/query/v5/docs/react/guides/migrating-to-v5#the-useerrorboundary-option-has-been-renamed-to-throwonerror
https://tkdodo.eu/blog/react-query-error-handling
https://www.datoybi.com/error-handling-with-react-query/
'FrontEnd > React' 카테고리의 다른 글
크롬 DevTools 리로드 문제 해결하기([Error] Looks like this page doesn't have React, or it hasn't been loaded yet.) (18) | 2024.03.25 |
---|---|
TanStack Query를 활용하여 Route 기반 Prefetching하기(feat.Render-as-you-fetch) (25) | 2024.02.27 |
[React-Query] React Query로 페이지네이션하기(keepPreviousData 옵션) (0) | 2023.07.04 |
[React-Query] 쿼리 키 정복하기 (3) | 2023.07.03 |
[React-Query] useMutation 활용편(낙관적 업데이트, custom hook으로 활용하기) (0) | 2023.07.01 |