[react] errorBoundary

bunny.log·2023년 3월 10일
0

errorBoundary 사용하기

과거에 컴포넌트 내부 JavaScript에 발생한 에러가 React의 내부 상태를 훼손하고 다음 렌더링에서 에러 방출을 유발했다. 이런 에러는 어플리케이션 코드의 이전 단계의 에러로 발생했지만 React 컴포넌트 내에서 이런 에러를 처리할 수 있는 방법이 없었다.

ErrorBondaryReact 컴포넌트 트리 하위에 있는 컴포넌트 내부에 JavaScript 에러를 캐치하는 컴포넌트이다.

개발하다 보면 예기치 못한 에러가 발생해 흰 화면만 보여주며 전체 어플리케이션이 중단되는 경우를 경험한다.
이렇게 에러가 발생하면 개발자들은 개발자 도구를 열고 console 창을 확인하여 에러 내용을 확인할 수 있지만 사용자에게 무엇이 잘못된지 알 수 없고 또한 어플리케이션의 모든 작업이 중단되어 좋은 경험을 주지 못한다.
따라서 우리는 적절히 에러를 핸들링해 줘야 한다.

Suspense

Suspense를 사용하면 컴포넌트가 렌더링하기 전에 다른 작업이 먼저 이루어지도록 “대기합니다”.

Suspense는 React Component 내부에서 비동기적으로 다른 요소를 불러올 때 해당 요소가 불러와질 때까지 Component의 렌더링을 잠시 멈추는 용도로 사용할 수 있는 컴포넌트입니다.

이 문장만 가지고는 이해가 쉽지 않죠? 코드 예시를 통해 직관적으로 이해해봅시다.

import { Suspense, lazy } from 'react';

const HugeComponent = lazy(() => import('./HugeComponent'));

const ComponentWithSuspense = () => {
  return (
    <Suspense fallback={<Spinner />}>
      <HugeComponent />
    </Suspense>
  );
};

export default ComponentWithSuspense;

위 ComponentWithSuspense 컴포넌트는 내부적으로 HugeComponent를 화면에 그려주는 역할을 합니다. HugeComponent는 이름과 같이 엄청나게 용량이 큰 컴포넌트여서, 우리는 이 컴포넌트가 화면에 그려져야 할 때 비동기적으로 사용자에게 전달되기를 바랐습니다.

우리가 원하는 바를 이루기 위해 위 코드에서는 lazy를 사용해서 HugeComponent를 비동기적으로 불러오게끔 구성하였습니다.

ComponentWithSuspense컴포넌트에서는 HugeComponent 컴포넌트를 Suspense를 사용해서 컴포넌트 내부에 비동기적으로 불러오고,HugeComponent가 불러와지는 중에는 Suspense의 fallback Prop을 통해 Spinner 컴포넌트를 화면에 보여줍니다.

데이터 불러오기를 위한 Suspense

Suspense를 사용해서 비동기 데이터도 선언적으로 처리할 수 있을까요?

비동기 데이터를 Suspense로 처리하기 위한 예시 코드를 한번 살펴볼까요?

import { Suspense } from 'react';

const User = () => {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile />
    </Suspense>
  );
};

const UserProfile = () => {
  // userProfileRepository는 Suspense를 지원하는 "특별한 객체"
  const { data } = userProfileRepository();

  return (
    // 마치 데이터가 "이미 존재하는 것처럼" 사용합니다.
    <span>
      {data.name} / {data.birthDay}
    </span>
  );
};

export default User;

UserProfile에서 비동기 데이터를 로딩하고 있는 경우 < Suspense />의 fallback을 통해 Spinner를 보여줍니다.

User 컴포넌트에서는 Suspense를 통해 UserProfile 컴포넌트를 불러오고 있습니다.

UserProfile 컴포넌트 내부에서는 Suspense를 지원하는 userProfileRepository객체를 통해 데이터를 비동기적으로 불러오고 있습니다.

이 userProfileRepository는 Promise를 반환하는 일반적인 fetch 함수가 아닙니다. Suspense를 지원하는 특별한 객체로 지금은 이 객체에 대해 크게 신경쓰지 않도록 합니다.

Suspense를 지원하는 특별한 객체를 사용하면 비동기 데이터 불러오기도 Suspense를 통해 처리할 수 있다가 핵심입니다.

비동기 데이터 불러오기를 Suspense를 통해 처리할 경우 상태에 따라서 어떤(WHAT) 화면을 보여줄지에 집중할 수 있습니다.

[UserProfile 컴포넌트]는 이미 데이터가 불러와져 있음을 전제로 작성되어 있고, 비동기 요청이 진행 중인 상태에서 사용자에게 보일 화면에 대해서는 일체 관심이 없습니다.

대신 UserProfile 컴포넌트를 불러오는 User 컴포넌트가 비동기 요청 상태에 따라 어떤(WHAT) 화면을 보여줄지를 관리합니다.

만약 UserProfile 컴포넌트 내부의 userProfileRepository 객체에서 “데이터를 불러오는 중” 이라면 UserProfile 컴포넌트를 렌더하지 않고 Suspense의 fallback으로 지정된 Spinner 컴포넌트를 화면에 보여주는 것이죠.

Error Boundary

에러 경계는 하위 컴포넌트 트리의 어디에서든 자바스크립트 에러를 기록하며 깨진 컴포넌트 트리 대신 폴백 UI를 보여주는 React 컴포넌트입니다.

Error Boundary는 React Component 내부에서 에러가 발생한 경우 사용자에게 잘못된 UI나 빈 화면을 보여주는 대신 미리 정의해 둔 Fallback UI를 화면에 보여주기 위한 컴포넌트입니다.

Error Boundary의 기본적인 사용 방법은 다음과 같습니다.

import { Component } from 'react';

class MyCustomErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트 합니다.
    return { hasError: true };
  }
  componentDidCatch(error, errorInfo) {
    // 에러 리포팅 서비스에 에러를 기록할 수도 있습니다.
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 폴백 UI를 커스텀하여 렌더링할 수 있습니다.
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

const App = () => {
  return (
    <MyCustomErrorBoundary>
      <MyApp />
    </MyCustomErrorBoundary>
  );
};

export default App;

Error Boundary는 getDerivedStateFromError 또는 componentDidCatch (혹은 둘 다) 멤버 함수를 갖는 React Component입니다.

하위 컴포넌트 렌더링 과정에서 에러가 발생할 경우 (마치 catch {} 구문처럼) 상위 Error Boundary에서 에러를 받아 Fallback UI를 처리하거나 Error Tracker로 에러 리포팅을 할 수 있습니다.

react-error-boundary

저희는 Error Boundary를 더 쉽게 사용하기 위해 react-error-boundary라는 Component를 사용하고 있습니다.

react-error-boundary는 getDerivedStateFromError나 componentDidCatch를 사용하여 직접 에러 UI 상태를 구현해야 하는 Error Boundary를 추상화하여 아래와 같이 사용할 수 있게 정리한 컴포넌트입니다.

import { ErrorBoundary } from 'react-error-boundary';

import { sendErrorToErrorTracker } from '../utils';

const UserProfileFallback = ({ error, resetErrorBoundary }) => (
  <div>
    <p> 에러: {error.message} </p>
    <button onClick={() => resetErrorBoundary()}> 다시 시도 </button>
  </div>
);

const handleOnError = (error) => sendErrorToErrorTracker(error);

const User = () => (
  <ErrorBoundary
    FallbackComponent={UserProfileFallback}
    onError={handleOnError}
  >
    <UserProfile/>
  </ErrorBoundary>,
);

export default User;

react-error-boundary를 사용하면 컴포넌트에서 제고하는 FallbackComponent나 onError 같은 Props를 사용하여 사용자에게 Fallback UI를 편리하게 보여주고 AEM에 에러 리포팅을 수행하는 등의 기능을 편리하게 구현할 수 있습니다.

더 나아가 resetErrorBoundary 함수를 FallbackComponent 컴포넌트의 Props로 제공하므로 “다시 시도” 등의 UI 요소도 쉽게 추가할 수 있으니, Error Boundary 사용이 필요한 상황에서 선택지 중 하나로 고려하지 않을 이유가 없겠죠?

선언형 컴포넌트를 위한 비동기 데이터 불러오기

앞에서 살펴본 Suspense와 Error Boundary를 사용하면 선언형 컴포넌트를 구성할 수 있습니다. 화면에 무엇을(WHAT) 보여줄 것이냐 를 고려하며 화면을 설계하는 쪽으로 패러다임이 바뀌게 되는 것이죠.

어떻게 해야 실제로 우리가 Suspense와 Error Boundary를 사용하여 화면을 구성할 수 있을까요?

  1. 비동기 데이터 불러오기 (다시 말해 API 요청)시 로딩 중
  2. 에러 발생
  3. 성공

위 3가지 케이스에 대응하는 UI를 각각 구성하여 화면에 보여줄 수 있을까요?

React Query와 함께 Suspense와 Error Boundary 사용하기

React에서 비동기 데이터 관리를 위해 사용되는 라이브러리 React Query에서는 비동기 데이터 요청 시 Suspense와 Error Boundary를 활용할 수 있는 옵션을 제공합니다.

import { useQuery } from 'react-query';

const queryKey = 'user';
const queryFn = () => axios('/user').then((res) => res.data);

const UserProfile = () => {
  const { data } = useQuery(queryKey, queryFn, {
    suspense: true, // 데이터 불러오기를 위한 Suspense를 활성화하는 옵션
    useErrorBoundary: true,// Error Boundary 사용을 위한 옵션.suspense 옵션이 true인 경우에는 기본값이 true로 설정된다.
  });

  return (
    <span>
      {data.name} / {data.birthDay}
    </span>
  );
};

export default UserProfile;

suspense 옵션을 선택할 경우 useQuery hook은 위에서 언급한 Suspense를 지원하는 “특별한 객체”로써 동작하여 데이터 불러오기를 위한 Suspense, 그리고 Error Boundary를 통한 에러 Fallback UI 처리 사용이 가능해집니다.

데이터 불러오기를 위한 Suspense와 React Query의 Suspense 옵션은 아직 실험적 기능입니다. 운영 환경에서는 사용 전 신중한 확인이 필요합니다.

선언형 컴포넌트를 사용한 React Component

바로 위에서 살펴본 React Query, Suspense, Error Boundary를 사용해 구성한 React Component에 대해서 자세히 들여다보겠습니다.

앞에서 이야기 한 바와 같이 데이터 불러오기를 위한 Suspense 사용을 위해서는 우리가 기존에 사용하던 Promise기반의 API 요청이 아닌 Suspense를 지원하는 특별한 객체를 통한 API 요청이 필요합니다.

다시 말해, API에서 데이터를 불러와 사용자에게 보여줄 아래 UserProfile 컴포넌트에서는 우리가 평소에 사용하던 다른 API 요청 방법이 아닌

[Suspense를 지원할 수 있는 특별한 요청 방법]을 사용해야 하고 현재로써는 React Query의 suspense 옵션을 사용하여 비동기 데이터를 요청하는 것이 가장 편리한 방법 중 하나입니다.

[선언형 컴포넌트 아키텍처]를 도입하여 로딩 상태에러 상태

  • 상위 컴포넌트(User)에서 Suspense와 Error Boundary를 통해 처리하기 때문에
  • UserProfile 컴포넌트에서는 로딩 화면과 에러 화면을 처리하지 않습니다.

UserProfile 컴포넌트의 로딩 상태와 에러 상태는 UserProfile 상위의 User 컴포넌트에서 Suspense와 Error Boundary를 통해 사용자에게 보여집니다.

  • 데이터 불러오기가 진행 중인 경우 Suspense fallback Props를 통해 사용자에게 “데이터 불러오는 중”을 알려주게 되고,
  • 데이터 불러오기가 실패한 경우 Error Boundary FallbackComponent Props를 통해 “데이터 불러오기에 실패하였음”을 보여주게 되는 것이죠.

UserProfile 컴포넌트는 데이터가 항상 존재하는 것처럼 화면에 필요한 내용을 그려주는 역할만 할 뿐입니다.

import { Suspense } from 'react';
import { useQuery } from 'react-query';
import { ErrorBoundary } from 'react-error-boundary';

const queryKey = 'user';
const queryFn = () => axios('/user').then((res) => res.data);

const UserProfile = () => {
  const { data } = useQuery(queryKey, queryFn, { suspense: true });

  return (
    <span>
      {data.name} / {data.birthDay}
    </span>
  );
};

const UserProfileFallback = ({ error, resetErrorBoundary }) => (
  <div>
    <p> 에러: {error.message} </p>
    <button onClick={() => resetErrorBoundary()}> 다시 시도 </button>
  </div>
);

const UserProfileLoading = () => <div> 사용자 정보를 불러오는 중입니다. </div>;

const User = () => (
  <ErrorBoundary FallbackComponent={UserProfileFallback}>
    <Suspense fallback={<UserProfileLoading />}>
      <UserProfile />
    </Suspense>
  </ErrorBoundary>
);

export default User;

실제 운영시 조건

이 페이지를 담당하는 기획자가 사용자 경험 향상을 위해 다음과 같은 조건을 함께 부여하였다고 가정해 보겠습니다.

  • 컴포넌트를 불러오는 중에는 전체 UI 요소를 포함하는 스켈레톤 UI가 노출되어야 합니다.

  • 배너 영역과 커스텀 메뉴 영역의 데이터가 불러와지는 중에는 적절한 스켈레톤 UI가 노출되어야 합니다.

  • 사용자 정보와 알림 영역은 둘 다 불러와졌을 때에만 화면이 노출되며 둘 중 하나라도 로딩 중일 때는 적절한 스켈레톤 UI가 노출되어야 합니다.

  • 배너 영역의 데이터 불러오기가 실패하였을 경우 사용자 경험을 위해 “사전 정의된 자체 배너”를 화면에 노출합니다.

  • 커스텀 메뉴 영역, 사용자 정보와 알림 영역의 데이터 불러오기가 실패하였을 경우 “데이터 불러오기를 다시 시도할 수 있는 UI”를 화면에 보여주어야 합니다. 단, 이 UI는 모든 화면을 덮지 않고 해당 영역만을 덮으면서 화면에 보입니다.

  • 데이터를 불러올 때 서버에서 HTTP ErrorCode 500, Error Response 메시지가 “CRITICAL_ERROR”인 경우 “화면 전체를 덮는 에러 화면”을 표시합니다. 이 에러 화면은 다음과 같은 조건을 만족해야 합니다.

사용자에게 “에러가 지속되면 고객센터로 문의하세요”라는 텍스트를 노출해주어야 합니다.
고객센터 인입 시 정확한 사용자 확인을 위해 AEM에서 추적할 수 있는 UUID를 발급하고 화면에 노출해주어야 합니다.
단, 배너 API 호출 시 발생한 에러는 “기본 배너” 화면을 보여주는 것으로 갈음합니다.

“둘 다 불러와지기 전에는” 스켈레톤 UI를 노출한다

기획 의도상 사용자 정보 영역과 알림 영역은 둘 다 불러온 다음에만 화면이 보여져야 합니다. 둘 중 하나라도 로딩 중일 때에는 스켈레톤 UI가 보여야 하는 것이죠. 명령형으로 UI를 구성한다면 아마 다음과 같이 코드가 구성되어야 할 것입니다.

// User/index.tsx
import { useState } from 'react';
import { useQuery } from 'react-query';

import UserInfo from './components/UserInfo';
import UserInfoSkeleton from './components/UserInfo/index.skeleton';
import Alarm from './components/Alarm';
import AlarmSkeleton from './components/Alarm/index.skeleton';

import { getUserInfo, getAlarm } from '../fetches';

const User = () => {
  const { data: userInfoData } = useQuery(['userInfo'], getUserInfo);
  const { data: alarmData } = useQuery(['alarm'], getAlarm);

  return (
    <section className="user__container">
      {userInfoData && alarmData ? (
        <>
          <UserInfo data={userInfoData} />
          <Alarm data={alarmData} />
        </>
      ) : (
        <>
          <UserInfoSkeleton />
          <AlarmSkeleton />
        </>
      )}
    </section>
  );
};

export default User;

물론 이렇게 컴포넌트를 구성하여도 우리가 원하는 기획 요구사항을 충실하게 달성할 수 있고, 그리 어색해 보이지도 않는 코드입니다. 하지만 이런 Presentational - Container 컴포넌트 구조는 코드의 복잡성을 증가시켜 유지보수를 어렵게 만듭니다. UserInfo 컴포넌트에 새로운 기능이 추가될 때 Props Drilling을 통해 추가 데이터를 받아야 할 수도 있고, 새로운 데이터를 보여주기 위한 컴포넌트를 추가해야 할 경우 User 컴포넌트의 화면 구조나 로직을 추가적으로 신경 써야 할 수도 있죠.

이 컴포넌트를 선언형 UI로 바꾸면 어떻게 구성될까요?

User와 UserInfo에서 필요로 하는 데이터는 각 컴포넌트에서 알아서 불러오고, User 컴포넌트는 아래와 같이 “하위 컴포넌트의 UI를 구성하기 위한 컴포넌트”로 꾸밀 수 있을 것 같습니다.

만약 .user__container 클래스에 flex를 사용한 스타일을 적용한다면 더더욱 “UI 구성하기 위한 컴포넌트”로써의 역할을 수행할 수 있겠죠.

// User/index.tsx
import { Suspense } from 'react';

import Banner from '../Banner';
import BannerSkeleton from '../Banner/index.skeleton';
import CustomMenu from '../CustomMenu';
import CustomMenuSkeleton from '../CustomMenu/index.skeleton';
import User from '../User/index';

const User = () => (
  <>
    {/* 
      Suspense 하위에 비동기 데이터 불러오기가 여러 개 있을 경우, Suspense는 마치 Promise.all 처럼 동작합니다.
      이 컴포넌트에서는 UserInfo와 Alarm 컴포넌트가 모두 로딩되기 전까지 fallback이 사용자에게 노출됩니다.
    */}
    <Suspense
      fallback={
        <>
          <UserInfoSkeleton />
          <AlarmSkeleton />
        </>
      }
    >
      <>
        <UserInfo />
        <Alarm />
      </>
    </Suspense>
  </>
);

export default User;

이 예제의 컴포넌트는 그리 복잡하지 않아 명령형과 선언형의 차이가 크게 느껴지지 않을 수 있습니다. 하지만 User 컴포넌트의 역할이 이 컴포넌트 (및 하위 컴포넌트)를 UI로 어떻게 표현할 것인가만 담당하느냐, 아니면 이 컴포넌트의 UI를 어떻게 표현할 것인지, 그리고 하위 컴포넌트들에서 사용할 데이터들을 불러오기를 담당하느냐의 기준으로 생각해보면 컴포넌트의 복잡도가 한층 낮아지고 관심의 분리를 더 명확하게 달성할 수 있음이 느껴집니다.

“사용자 정보와 알림 영역”만 가지고 살펴보겠습니다. 먼저 명령형 컴포넌트로 이 요구사항을 구현해볼까요?

// shared/Retry/index.tsx
interface Props {
  handleRetry: () => void;
}

const Retry = ({ handleRetry }) => (
  <div>
    <p> 데이터를 불러오는데 실패하였습니다. </p>
    <button onClick={handleRetry}> 다시 시도 </button>
  </div>
);

export default Retry;
// User/index.tsx
import { useState } from 'react';
import { useQuery } from 'react-query';

import UserInfo from './components/UserInfo';
import UserInfoSkeleton from './components/UserInfo/index.skeleton';
import Alarm from './components/Alarm';
import AlarmSkeleton from './components/Alarm/index.skeleton';

import Retry from '../shared/Retry';
import { getUserInfo, getAlarm } from '../fetches';

const User = () => {
  const {
    data: userInfoData,
    isLoading: userInfoIsLoading,
    error: userInfoError,
    refetch: userInfoRefetch
  } = useQuery(['userInfo'], getUserInfo);
  const {
    data: alarmData,
    isLoading: alarmIsLoading,
    error: alarmError,
    refetch: alarmRefetch
  } = useQuery(['alarm'], getAlarm);

  return (
    <section className="user__container">
    {
      userInfoIsLoading && alarmIsLoading && (
        <>
          <UserInfoSkeleton/>
          <AlarmSkeleton/>
        </>
      ) : (
        <>
          {
            userInfoError ? (
              <Retry handleRetry={refetchUserInfo}/>
            ) : (
              <UserInfo data={userInfoData!}/>
            )
          }
          {
            alarmError ? (
              <Retry handleRetry={refetchAlarm}/>
            ) : (
              <Alarm data={alarmData!}/>
            )
          }
        </>
      )
    }
    </section>
  )
}

export default User;

위에서 소개되었던 “명령형 컴포넌트를 사용한 데이터 불러오기 표시” 때랑은 조금 상황이 다릅니다.

삼항 연산자가 많이 늘어났고 화면을 처리하기 위해 사용되는 값들도 늘어났습니다. 에러와 관련된 데이터들을 하위 컴포넌트로 전달하여 하위 컴포넌트에서 처리하는 방법도 있겠지만, 이러나저러나 복잡한 건 매한가지입니다. 로딩과 관련된 요건 때문에 데이터의 상태와 화면의 상태를 다르게 가져가야 하기 때문입니다.

이 화면을 ErrorBoundary를 사용해서 선언적으로 바꾸면 어떻게 될까요?

// shared/RetryErrorBoundary/index.tsx
import { PropsWithChildren } from 'react';
import { useQueryErrorResetBoundary } from 'react-query';
import { ErrorBoundary } from 'react-error-boundary';

const RetryErrorBoundary = ({ children }: PropsWithChildren<unknown>) => {
  
  const { reset } = useQueryErrorResetBoundary();

  return (
    <ErrorBoundary
      onReset={reset}
      fallbackRender={({ resetErrorBoundary }) => (
        <div>
          <p> 데이터를 불러오는데 실패하였습니다. </p>
          <button onClick={() => resetErrorBoundary()}> 다시 시도 </button>
        </div>
      )}
    >
      {children}
    </ErrorBoundary>
  );
};

export default RetryErrorBoundary;
// User/index.tsx
import { Suspense } from 'react';

import Banner from '../Banner';
import BannerSkeleton from '../Banner/index.skeleton';
import CustomMenu from '../CustomMenu';
import CustomMenuSkeleton from '../CustomMenu/index.skeleton';
import User from '../User/index';

const User = () => (
  <section className="user__container">
    <RetryErrorBoundary>
      <Suspense fallback={<UserInfoSkeleton />}>
        <UserInfo />
      </Suspense>
    </RetryErrorBoundary>
    <RetryErrorBoundary>
      <Suspense fallback={<AlarmSkeleton />}>
        <Alarm />
      </Suspense>
    </RetryErrorBoundary>
  </section>
);

export default User;

// 참고: https://react-query.tanstack.com/reference/useQueryErrorResetBoundary

명령형에 맞추어 작성한 컴포넌트와 비교했을 때 내부 구성이 많이 간단해진 것 같습니다. 삼항 연산자가 사용되지도 않았고, Suspense와 ErrorBoundary에 대한 이해가 있는 사람이라면 User 컴포넌트 내부의 RetryErrorBoundary, Suspense 그리고 실제로 데이터를 받아 화면을 구성하는 UserInfo 및 Alarm 컴포넌트가 각자 어디에 관심을 두고 있는지를 한눈에 이해할 수 있겠죠.

특정 에러 발생 시 “화면 전체를 덮는 에러 화면”을 사용자에게 노출

위에서 살펴본 요구사항은 “재시도를 통해 사용자가 올바른 API 응답을 받을 수 있음”을 상정한 시나리오에 맞춰진 요구사항이었습니다.

하지만 어떤 케이스에는 서버에 심각한 이슈가 있어서 재시도를 진행하여도 사용자가 올바른 API 응답을 받을 수 없는 경우가 있을 수 있습니다. 만약 이 “심각한 이슈”가 특정한 시나리오 하에 있는 사용자에게만 발생하는 에러라면 CS가 인입되더라도 재현이 어렵거나 불가능해 이슈의 해소가 쉽지 않겠지요.

이러한 케이스의 시나리오를 커버하기 위해 기획이 제시한 요구사항은 다음과 같았습니다.

데이터를 불러올 때 서버에서 HTTP ErrorCode 500, Error Response 메시지가 “CRITICAL_ERROR”인 경우 “화면 전체를 덮는 에러 화면”을 표시합니다. 이 에러화면은 다음과 같은 조건을 만족해야 합니다.

  • 사용자에게 “에러가 지속되면 고객센터로 문의하세요”라는 텍스트를 노출해주어야 합니다.
  • 고객센터 인입 시 정확한 사용자 확인을 위해 AEM에서 추적할 수 있는 UUID를 발급하고 화면에 노출해주어야 합니다.
  • 단, 배너 API 호출 시 발생한 에러는 “기본 배너” 화면을 보여주는 것으로 갈음합니다.
    명령형으로는 작성할 자신이 도저히 없네요. 😂 선언형 컴포넌트를 사용한 화면만 구성해봅시다. 배너 API 호출 시 발생한 에러는 처리하지 않아도 된다고 하니 배너 부분은 건드리지 않고 위에서 처리했던 사용자 정보 영역과 알람 영역만 한번 처리해봅시다.

우선 하위 Error Boundary에서 특정 에러를 처리하지 않고 위로 올리게끔 구성해야 상위 Error Boundary에서 처리할 수 있을 것 같습니다.

우선 하위 Error Boundary에서 특정 에러를 처리하지 않고 위로 올리게끔 구성해야 상위 Error Boundary에서 처리할 수 있을 것 같습니다.

// shared/RetryErrorBoundary/index.tsx
import { PropsWithChildren } from 'react';
import { useQueryErrorResetBoundary } from 'react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { isAxiosError } from 'axios';

const RetryErrorBoundary = ({ children }: PropsWithChildren<unknown>) => {
  // ...전략
  return (
    <ErrorBoundary
      onError={({ error }) => {
        if (
          isAxiosError(error) &&
          error?.response?.status === 500 &&
          error?.response?.data === 'CRITICAL_ERROR'
        ) {
          // 조건에 맞는 에러인 경우 이 ErrorBoundary에서 처리하지 않고
          // 상위 ErrorBoundary 위임을 위해 Throw
          throw error;
        }
      }}
      {/* 후략.. */}
    >
      {children}
    </ErrorBoundary>
  );
};

위와 같이 ErrorBoundary를 구성하면 특정 조건에 해당하는 에러는 이 ErrorBoundary에서 처리되지 않고 다시 Throw 됩니다. Throw 된 에러는 (너무 자연스럽게도) 상위 ErrorBoundary에서 처리되겠죠.

// shared/CriticalErrorBoundary/index.tsx
import { PropsWithChildren, useState } from 'react';
import { useQueryErrorResetBoundary } from 'react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { nanoid } from 'nanoid';

import { sendErrorToErrorTracker } from '../utils';

const CriticalErrorBoundary = ({ children }: PropsWithChildren<unknown>) => {
  const { reset } = useQueryErrorResetBoundary();
  const [errorUuid, setErrorUuid] = useState();

  return (
    <ErrorBoundary
      onReset={() => {
        reset();
        setErrorUuid(undefined);
      }}
      onError={({ error }) => {
        if (
          !(
            isAxiosError(error) &&
            error?.response?.status === 500 &&
            error?.response?.data === 'CRITICAL_ERROR'
          )
        ) {
          // 이 ErrorBoundary에서 처리하면 안되는 오류의 경우 상위 ErrorBoundary로 위임
          throw error;
        } else {
          // 이 ErrorBoundary에서 처리되는 오류의 경우 UUID 부여 후 사용자에게 노출
          const uuid = nanoid(5);
          setErrorUuid(uuid);
          sendErrorToErrorTracker(uuid);
        }
      }}
      fallbackRender={({ resetErrorBoundary }) => (
        <div>
          <h1> 데이터를 불러오는데 실패하였습니다. </h1>
          <p> 에러가 지속되면 고객센터로 문의하세요. </p>
          <footer> {errorUuid} </footer>
        </div>
      )}
    >
      {children}
    </ErrorBoundary>
  );
};

export default CriticalErrorBoundary;

여기까지 구성하고 나면 다 끝난것 같네요. 새로 만든 CriticalErrorBoundary만 적절한 위치에 넣어주면 깔끔하게 처리되겠죠. 애플리케이션 전체의 API 요청을 커버하기 위해 최상단 컨포넌트인 App 컴포넌트에 CriticalErrorBoundary를 넣어주도록 하겠습니다.

<Banner/> 컴포넌트에서 API 에러 발생 시 ErrorBoundary를 사용하여 사전 정의된 배너를 보여주게 작업했던 부분 기억나시나요? <Banner/> 컴포넌트 하위에서 발생하는 API 에러는 모두 해당 ErrorBoundary에서 처리되기 때문에 CriticalErrorBoundary가 최상단 App 컴포넌트에 존재하고 있어도 문제가 없습니다.
// app.tsx
import { lazy, Suspense } from 'react';

import MainPage from './MainPage/index.skeleton';
import CriticalErrorBoundary from './shared/CriticalErrorBoundary';

const MainPage = lazy(() => import('./MainPage'));

const App = () => (
  // RootErrorBoundary: Runtime Error 등 일반적인 에러를 처리하기 위한 ErrorBoundary
  <RootErrorBoundary>
    <CriticalErrorBoundary>
      <Suspense fallback={<Skeleton />}>
        <MainPage />
      </Suspense>
    </CriticalErrorBoundary>
  </RootErrorBoundary>
);

export default App;

만약 이러한 구성을 명령형 컴포넌트의 기조로 처리하려 했다고 생각해봅시다. 우선 일반적인 방법부터 생각해볼까요?

별도의 Global State Management Library를 사용하지 않는 경우, App 또는 MainPage 컴포넌트에 에러 화면을 보여주기 위한 별도의 State를 두고 Props Drilling을 통해 setStateHandler를 계속 내려서 처리해야 합니다. 10초만 생각해봐도 이건 아닌 것 같죠.

Global State Management Library를 사용한다면 어떨까요? react-query를 사용하지 않는다면 이 요구사항을 구현하기 그리 어렵거나 어색하지 않았겠지만, react-query를 사용한다면 굳이 상태 관리를 위해 Global State Management Library를 사용하는 게 어색하게 느껴질 수 있습니다.

react-query에서 사용하는 QueryClient의 DefaultOption.onError를 사용하는건 어떨까요? 이것도 충분히 유효한 방법일 것 같습니다. 하지만 만약 처리해야 하는 공통 에러의 종류가 다양해질 수 있다는 생각을 해보면 우아한 방법은 아닐 수 있을 것 같다는 생각이 듭니다.

지금까지의 작업을 통해 비동기 데이터를 불러오는 과정에서의 “로딩 화면”에 이미 Suspense를 사용하고 있고, 에러 케이스 처리를 위해 ErrorBoundary를 이미 사용하고 있는 상황이라면 이 특별한 요구사항의 처리를 위해 ErrorBoundary를 더 활용하지 못할 이유가 없다는 생각이 듭니다.

더 나아가서 일반적으로 우리가 어떤 요구사항의 처리를 위해 프로젝트를 살펴볼 때는 “해당 요구사항을 위해 화면에 뿌려지는 컴포넌트의 코드”에서부터 거슬러 올라가며 검토하는 상황을 상상해보면 react-query의 defaultOption이나 Global State Management Library를 통해 “컴포넌트의 생명주기”4와 별개로 화면을 처리하는 방식은 프로젝트의 규모가 커질수록 이슈 해결을 위해 “방탈출 힌트 찾기”를 하는 것과 비슷한 상황을 만들게 됩니다.

참고

https://tech.kakaopay.com/post/react-query-2/#user-content-fn-2

https://velog.io/@suyeon9456/React-Query-Error-Boundary-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0

profile
나를 위한 경험기록

0개의 댓글