-
Notifications
You must be signed in to change notification settings - Fork 3
Suspense+ErrorBoundary
서버 데이터를 처리할 때 로딩 상태와 에러 상태에 대한 처리가 필요했습니다.
리액트를 선언적으로 사용하기 위해서 로딩 상태는 Suspense
가, 에러 상태는 ErrorBoundary
가 관리하도록 역할을 분리하고자 했습니다.
// 1. 에러 상태 관리
<ErrorBoundary
fallback={({ error, reset }) => <SuspenseLotusHistoryList.Error error={error} retry={reset} onChangePage={handleChangePage} />}
>
// 2. 로딩 상태 관리
<Suspense fallback={<SuspenseLotusHistoryList.Skeleton />}>
// 3. 성공 상태 관리
<SuspenseLotusHistoryList id={id} page={page} />
</Suspense>
</ErrorBoundary>
자식 요소가 로드되기 전까지 화면에 대체 UI를 보여줌
<Suspense fallback={<Loading />}>
{" "}
// children의 렌더링이 지연되면 fallback을 대신 렌더링
<Component /> // 성공 시 렌더링
</Suspense>
이번 프로젝트에서는 서버 데이터 관리 라이브러리로 tanstack query를 사용하게 되었습니다.
tanstack query의 경우, react의 suspense와 함께 사용할 수 있는 hook을 제공합니다.
💡위의 hook들은 throw Promise
를 통해 React에게 Promise의 상태를 전달하고 <Suspense>
가 Promise를 감지해서 fallback UI를 표시하게 됩니다. 이후에 Promise가 fulfilled
되면 children인 성공 시 UI를 렌더링합니다.
Skeleton UI를 사용하게 되면 다음과 같은 장점을 가질 수 있습니다.
1️⃣ Layout Shift 문제 방지
- 콘텐츠와 동일한 모양을 가진 Skeleton UI를 이용해서 콘텐츠가 로드되면서 갑작스럽게 레이아웃이 변하는 Layout Shift 문제를 방지할 수 있습니다.
2️⃣ 사용자 경험 개선
- 콘텐츠 레이아웃을 미리 보여주어 콘텐츠가 로드되고 있다는 것을 알릴 수 있습니다.
- 사용자가 페이지 로딩 속도가 더 빠르게 느껴지게 합니다.
따라서 저희 팀은 fallback UI로 Skeleton UI를 적용하게 되었습니다.
shadcn/ui의 Skeleton을 이용해 성공 시 렌더링될 UI와 동일한 모양을 가진 fallback 컴포넌트를 구현했습니다.
- fallback 컴포넌트
function SkeletonLotusCardList() { return ( <div className="w-full grid grid-cols-3 gap-8"> {range(5).map((value) => ( <div key={`card_${value}`} className="max-w-96 p-5 border-2 border-slate-200 rounded-xl"> <Skeleton className="h-6 w-3/4 mb-4" /> <Skeleton className="h-4 w-1/3 mb-4" /> <div className="w-full flex justify-between items-end"> <Skeleton className="h-4 w-1/4 rounded-3xl" /> <Skeleton className="h-10 w-10 rounded-full" /> </div> <Skeleton className="mt-4 w-full h-1" /> <div className="pt-4 min-h-8 space-y-2"> <Skeleton className="h-4 w-1/4" /> </div> </div> ))} </div> ); } SuspenseLotusList.Skeleton = SkeletonLotusCardList;
<Suspense fallback={<SuspenseLotusList.Skeleton />}>
<SuspenseLotusList queryOptions={lotusListQueryOptions} />
</Suspense>
결과적으로 Lotus 목록을 불러올 때 다음처럼 fallback UI가 보여지게 됩니다.
에러 상태에 대한 fallback UI를 보여주는 컴포넌트
export class ErrorBoundary extends Component<ErrorBoundaryProps> {
state = { hasError: false, error: null };
// 하위 컴포넌트가 에러를 throw할 때 호출되는 메서드
static getDerivedStateFromError(error: unknown) {
return { hasError: true, error };
}
// 에러 상태를 초기화한 후 다시 render 메서드가 실행되어 자식 컴포넌트를 렌더링하려고 시도
private resetError() {
this.setState({ hasError: false, error: null });
}
render() {
// 에러 발생 시 fallback UI를 렌더링
if (this.state.hasError) {
this.props.handleError?.(this.state.error);
return this.props.fallback?.({ error: this.state.error, reset: () => this.resetError() });
}
// 자식 컴포넌트를 렌더링
return this.props.children;
}
}
- 사용 예시
<ErrorBoundary fallback={({ error, reset }) => <SuspenseLotusList.Error error={error} retry={reset} />}> <Suspense fallback={<SuspenseLotusList.Skeleton />}> <SuspenseLotusList queryOptions={lotusListQueryOptions} /> </Suspense> </ErrorBoundary>
AsyncBoundary는 ErrorBoundary과 Suspense를 결합해서 비동기 작업의 상태를 효과적으로 처리하기 위해 구현했습니다.
export function AsyncBoundary(props: AsyncBoundaryProps) {
return (
<ErrorBoundary fallback={({ error, reset }) => props.rejected({ error, retry: reset })}>
<Suspense fallback={props.pending}>{props.children}</Suspense>
</ErrorBoundary>
);
}
-
pending
에 정의된 컴포넌트가 로딩 상태인 경우에 렌더링 -
rejected
에 정의된 컴포넌트가 에러 발생한 경우에 렌더링 - 사용 예시
<AsyncBoundary pending={<SuspenseLotusEdit.Skeleton />} rejected={() => <SuspenseLotusEdit.Error />}> <SuspenseLotusEdit id={id} /> </AsyncBoundary>
발생한 에러에 따라 사용자에게 어떤 에러 fallback을 보여주면 좋을지 고민하게 되었습니다..
에러 처리의 범위를 다음의 2가지로 생각했습니다.
1️⃣ 페이지 전체 에러 처리
- 에러가 발생했을 때, 에러 페이지를 보여줌
-
tanstack router
의errorComponent
를 활용하여 전역적인 에러 처리
2️⃣ 영역별 에러 처리
- 페이지의 특정 부분에서만 fallback UI를 보여주고 다른 정상적인 영역은 계속 보여줌
페이지 전체 에러 처리를 하는 경우는 다음의 경우라고 생각했습니다.
- 토큰이 유효하지 않는 등의 로그인이 필요한 경우 → 아예 페이지에 접근 불가능하도록 해야한다고 생각
- 가장 기본적인 정보 조회에서 에러가 발생한 경우 → 게시글 상세 페이지에서 게시글의 가장 기본적인 정보인 제목, 내용 등이 보여지지 않았다면 이것은 페이지 전체적으로 에러로 처리해야된다고 생각
또한 사용자에게 다시 시도할 수 있는 기회를 제공하면 좋을 것이라고 생각했습니다.
이는 ErrorBoundary의 resetError()
메소드와 tanstack query의 useQueryErrorResetBoundary()
훅을 통해 구현이 가능합니다.
useQueryErrorResetBoundary()
이 훅은 가장 가까운 <QueryErrorResetBoundary>
내에서 모든 쿼리 오류를 reset합니다. 정의된 경계가 없으면 전역적으로 reset됩니다.
+) 트러블 슈팅
문제 상황
- pagination이 적용된 Lotus 목록 데이터를 조회할 때 가능한 범위를 벗어난 page를 요청하면 404 status 에러가 발생한다.
- 이때 page를 1의 값으로 다시 Lotus 목록 데이터를 조회하는 재시도 기능을 넣어주었는데 계속 오류가 발생했다.
해결 방법
- tanstack router의 navigate가 실행이 완료되지 않았는데 retry()가 실행되어서 바로 다시 에러 화면이 나타나게 됨!
- navigate 메서드를 사용했을 때 navigate가 제대로 페이지를 갱신한 후 retry()(= ErrorBoundary의 resetError())를 호출해서 다시 render해야 했다!
function ErrorFallbackLotusCardList({ error, retry, onChangePage }: ErrorFallbackProps) {
const { reset } = useQueryErrorResetBoundary();
const handleRetry = async () => {
if (axios.isAxiosError(error) && error?.status === 401) {
throw error;
} else if (axios.isAxiosError(error) && error?.status === 404) {
// ✅ async await를 통해 navigate가 성공한 다음에 retry()를 해주어야 한다!
await navigate({ to: "/lotus", search: { page: 1 } });
} else {
reset();
}
retry();
};
return (
<div className="w-full h-full flex flex-col justify-center items-center">
<DotLottieReact src="/json/errorAnimation.json" loop autoplay className="w-96" />
<Heading className="py-4">Lotus 목록 조회에 실패했습니다</Heading>
<Button onClick={handleRetry}>재시도</Button>
</div>
);
}
1️⃣ 페이지 전체 에러 처리 적용하기
- 예시 코드
-
tanstack router
의errorComponent
를 활용하여 전역적인 에러 처리
export const Route = createLazyFileRoute("/(main)/lotus/$lotusId/")({ component: RouteComponent, errorComponent: ErrorComponent, });
- ErrorComponent
-
handleRetry()
: reset()을 통해 발생한 쿼리 오류를 reset한 후에 ErrorBoundary의 retry()로 에러 상태를 초기화해서 다시 render()를 진행
-
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) { const { reset: retry } = useQueryErrorResetBoundary(); const handleRetry = () => { retry(); reset(); }; return <GlobalError description={getLotusErrorData(error)?.description} handleRetry={handleRetry} />; }
-
예시) Lotus 상세 조회에서 제목 및 기본정보 조회에 실패한 경우
2️⃣ 영역별 에러 처리 적용하기
- 예시 코드
- AsyncBoundary를 이용해서 rejected에 Error fallback UI를 정의
<AsyncBoundary pending={<SuspenseGistFiles.Skeleton />} rejected={({ error, retry }) => <SuspenseGistFiles.Error error={error} retry={retry} />} > <SuspenseGistFiles gistId={formValue.gistUuid} /> </AsyncBoundary>
- Error fallback UI
-
handleRetry()
: reset()을 통해 발생한 쿼리 오류를 reset한 후에 ErrorBoundary의 retry()로 에러 상태를 초기화해서 다시 render()를 진행 - status값이 401인 경우는 다시 로그인이 필요한 경우이므로 throw error를 통해 페이지 전체 에러로 처리
-
function ErrorGistFiles({ error, retry }: ErrorProps) { const { reset } = useQueryErrorResetBoundary(); if (axios.isAxiosError(error) && error?.status === 401) throw error; const handleRetry = async () => { reset(); retry(); }; return ( <div className="w-full h-[600px] mt-20 pb-10 flex flex-col justify-center items-center"> <DotLottieReact src="/json/errorAnimation.json" loop autoplay className="w-96" /> <Heading className="py-4">선택한 Gist의 파일 조회에 실패했습니다</Heading> <Button onClick={handleRetry}>재시도</Button> </div> ); } SuspenseGistFiles.Error = ErrorGistFiles;
예시) 선택한 gist에 대한 파일 조회를 실패한 경우
이전에는 한 컴포넌트 내에서 const { data, isError, error, isLoading } = useQuery({~});
를 이용해서 로딩, 성공, 에러인 경우에 대해 각각 처리해주었습니다.
function Component() {
const { data, error, isLoading } = useQuery({~});
if (isLoading) return <LoadingComponent />;
if (error) {
if (error.status === 401) {
return <UnauthorizedComponent />;
}
if (error.status === 404) {
return <NotFoundComponent />;
}
return <ErrorComponent error={error} />;
}
return <SuccessComponent data={data}/>;
}
그러다보니 한 컴포넌트 안에서 조건이 추가될수록 코드가 복잡해지고 가독성도 떨어지게 되었습니다.
이번 프로젝트에서 Suspense와 ErrorBoundary를 이용해서 각 상태를 처리하는 컴포넌트를 분리해서 사용함으로써 코드 자체의 가독성도 좋아지고 코드의 재사용성도 높아지게 되었습니다.
function Component() {
const { data } = useSuspenseQuery({~});
return (
<AsyncBoundary
pending={<LoadingComponent />}
rejected={({ error, retry }) => <ErrorComponent error={error} retry={retry} />}
>
<SuccessComponent data={data} />
</AsyncBoundary>
);
}
react의 선언형 프로그래밍에 대해 더 알아갈 수 있었고 사용자의 경험을 개선하기 위한 고민도 함께 해볼 수 있는 기회가 되었습니다!
https://fe-developers.kakaoent.com/2021/211127-211209-suspense/