Skip to content

Suspense+ErrorBoundary

nakyong82 edited this page Dec 1, 2024 · 1 revision

📌 사용 배경

서버 데이터를 처리할 때 로딩 상태와 에러 상태에 대한 처리가 필요했습니다.

리액트를 선언적으로 사용하기 위해서 로딩 상태는 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>

📌 Suspense

React의 <Suspense>


자식 요소가 로드되기 전까지 화면에 대체 UI를 보여줌

<Suspense fallback={<Loading />}>
  {" "}
  // children의 렌더링이 지연되면 fallback을 대신 렌더링
  <Component /> // 성공 시 렌더링
</Suspense>

Tanstack query와 함께 Suspense 사용하기


이번 프로젝트에서는 서버 데이터 관리 라이브러리로 tanstack query를 사용하게 되었습니다.

tanstack query의 경우, react의 suspense와 함께 사용할 수 있는 hook을 제공합니다.

💡

위의 hook들은 throw Promise를 통해 React에게 Promise의 상태를 전달하고 <Suspense>가 Promise를 감지해서 fallback UI를 표시하게 됩니다. 이후에 Promise가 fulfilled되면 children인 성공 시 UI를 렌더링합니다.

Skeleton 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가 보여지게 됩니다.

389183535-514738e3-0afc-4b9d-8ea5-004b3ba050e8-ezgif com-video-to-gif-converter

📌 ErrorBoundary

ErrorBoundary


에러 상태에 대한 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


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 구현하기


발생한 에러에 따라 사용자에게 어떤 에러 fallback을 보여주면 좋을지 고민하게 되었습니다..

에러 처리의 범위를 다음의 2가지로 생각했습니다.

1️⃣ 페이지 전체 에러 처리

  • 에러가 발생했을 때, 에러 페이지를 보여줌
  • tanstack routererrorComponent를 활용하여 전역적인 에러 처리

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 routererrorComponent를 활용하여 전역적인 에러 처리
    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 상세 조회에서 제목 및 기본정보 조회에 실패한 경우

389984769-20269bc6-2358-4b2b-87ca-c577a1c912a8-ezgif com-video-to-gif-converter

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에 대한 파일 조회를 실패한 경우

389984787-ddf72037-c76f-475d-a8aa-baa2dd47f054-ezgif com-video-to-gif-converter

📌 결론

이전에는 한 컴포넌트 내에서 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의 선언형 프로그래밍에 대해 더 알아갈 수 있었고 사용자의 경험을 개선하기 위한 고민도 함께 해볼 수 있는 기회가 되었습니다!

📌 Reference

https://fe-developers.kakaoent.com/2021/211127-211209-suspense/

https://www.notion.so/Suspense-Error-Boundary-Fallback-1399038c617380ecb1a8ed579b9e59cc?pvs=4#14e9038c6173816cbf8decbdfe9aae7f

Clone this wiki locally