[deep-frontend] Suspense와 Waterfall, Error Boundary -2

Zoey·2023년 11월 9일
0

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

앞에서 살펴본 Suspense와 Error Boundary를 사용하면 선언형 컴포넌트를 구성할 수 있다. 기존의 "어떻게 화면에 보여줄 것이냐"가 아니라 "화면에 무엇을 보여줄 것이냐"를 고려하면 화면을 설계하는 쪽으로 패러다임이 바뀌게 되는 것이다. 그렇다면 어떻게 해야 실제로 Suspense와 Error Boundary를 사용하여 화면을 구성할 수 있을까? 본 아티클의 맥락상 비동기 데이터 불러오기(다시 말해 API 요청)시 로딩중, 에러 발생, 성공 이렇게 3가지 케이스에 대응하는 UI를 각각 구성하여 화면에 보여주겠다는 내용인 것 같은데 우리가 사용하는 axios나 fetch API, Suspense 그리고 Error Boundary를 같이 사용한다는 이야기를 본적은 없다.

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를 활성화하는 옵션
    	suspense: true,
      	// Error Boundary 사용을 위한 옵션,
      	// suspense 옵션이 true인 경우에는 기본값이 true로 설정된다.
      	useErrorBoundary: true,
    });
    
    return (
    	<span>
      		{data.name} / {data.birthDay}
      	</span>
    );
};

export default UserProfile;

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

import { useQuery } from 'react-query';
import { Suspense } from 'react';
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>
    );
};

export default UserProfile;

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

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

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

export default User;

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

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

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

다시 말해, API에서 데이터를 불러와 사용자에게 보여줄 아래 컴포넌트에서는 우리가 평소에 사용하던 다른 API 요청 방법이 아닌 Suspense를 지원할 수 있는 특별한 요청 방법을 사용해야 하고 현재로써 React Query의 Suspense 옵셥을 사용하여 비동기 데이터를 처리하는 것이 가장 편리한 방법 중 하나다.

//... 전략

const UserProfile = () => {
	const { data } = useQuery(
    	queryKey,
      	queryFn,
      	// suspense 옵션을 통해 useQuery Hook을 "Suspense를 지원하는 특별한 객체"로 사용한다.
      	// suspense 옵션이 켜져있는 경우 Error Boundary를 통한 에러 처리도 기본값 true로 에러 처리 가능하다.
      	{ suspense: true },
    );
  
  	return (
    	<span>
      		{data.name} / {data.birthDay}
      	</span>
    );
};

//... 후략

선언형 컴포넌트 아키텍처를 토입하여 "로딩" 상태와 "에러" 상태를 상위 컴포넌트에서 Suspense와 Error Boundary를 통해 처리하기 때문에 UserProfile 컴포넌트에서는 로딩 화면과 에러 화면을 처리하지 않는다.
UserProfile 컴포넌트는 데이터가 항상 존재하는 것처럼 화면에 필요한 내용을 그려주는 역할만 할 뿐이다.

//... 전략

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>
)

UserProfile 컴포넌트의 로딩 상태와 에러 상태는 UserProfile 상위의 User 컴포넌트에서 Suspense와 Error Boundary를 통해 사용자에게 보여진다. 데이터 불러오기가 진행 중인 경우 Suspense fallback Props를 통해 사용자에게 "데이터가 불러오는 중"을 알려주게 되고, 데이터 불러오기가 실패한 경우 Error Boundary FallbackComponent Props를 통해 "데이터 불러오기에 실패하였음"을 보여주게 되는 것이다.

복잡한 UI 구성시 빛이 나는 선언형 컴포넌트

선언형 컴포넌트는 비동기적으로 데이터를 불러와서 화면에 보여주어야 하는 요소가 많으수록 빛을 발한다. 비동기 데이터를 사용하는 요소가 적을 때는 명령형으로 간단하게 UI를 구성할 수 있지만, 비동기 요소가 많고 사용자 경험상 복잡한 UI를 구성해야 할 필요가 있는 경우에는 명령형으로는 한계가 느껴지거나 코드가 과도하게 복잡해지기 때문이다.

  • 비동기 요소가 많은 UI 와이어 프레임 예시
    위 와이어프레임을 예시로 한번 살펴보자. 이 페이지는 총 4개의 비동기 요소를 가지고 있다. Code Splitting을 통해 컴포넌트 자체도 비동기 요소로 생각한다면 총 5개의 비동기 요소를 가지고 있다고 볼 수 있다.

이 페이지를 담당하는 기획자가 사용자 경험 향상을 위해 다음과 같은 조건을 함께 부여하였다고 가정해보자.
1. 컴포넌트를 불러오는 중에는 전체 UI 요소를 포함하는 스켈레톤 UI가 노출되어야 한다.
2. 배너 영역과 커스텀 메뉴 영역의 데이터가 불러와지는 중에는 적졀한 스켈레톤 UI가 노출되어야 한다.
3. 사용자 정보와 알림 영역은 둘 다 불러와졌을 때에만 화면이 노출되며 둘 중 하나라도 로딩 중일 때는 적절한 스켈ㄹ톤 UI가 노출되어야 한다.
4. 배너 영억의 데이터 불러오기가 실패하였을 경우 사용자 경험을 위해 "사전에 정의된 자체 배너를 화면에 노출한다."
5. 커스텀 메뉴 영역, 사용자 정보와 알림 영역의 데이터 불러오기가 실패하였을 경우 "데이터 불러오기를 다시 시도할 수 있는 UI"를 화면에 보여주어야 한다. 단, 이 UI는 모든 화면을 덮지 않고 해당 영역만을 덮으면서 화면에 보인다.
6. 데이터를 불러올 때 서버에서 HTTP ErrorCode 500, Error Response 메시지가 "CRITICAL_ERRROR"인 경우 "화면 전체를 덮는 에러 화면"을 표시한다. 이 에러 화면은 다음과 같은 조건을 만족해야 한다.
- 사용자에게 "에러가 지속되면 고객센터로 문의하세요." 라는 텍스트를 노출해주어야 한다.
- 고객센터 인입 시 정확한 사용자 확인을 위해 AEM에서 추적할 수 있는 UUID를 발급하고 화면에 노출해주어야 한다.
- 단, 배너 API 호출 시 발생한 에러는 "기본 배너" 화면을 보여주는 것으로 갈음한다.

컴포넌트를 불러올 때 스켈레톤 UI 노출시키기

Code Splitting을 통해 분리되어 있는 번들을 불러오는 동안 사용자에게 스켈레톤 UI를 표시하는 건 아주 간단하다. 위 본문에서 언급했던 바와 같이 lazy를 통해 Coda Splitting을 적용하고 fallback Props을 갖는 Suspense를 통해 해당 컴포넌트를 감싸주는 것 만으로 적용이 가능하다.

// app.tsx

import { lazy, Suspense } from 'react';
import Skeleton from './MainPage/index.skeleton';

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

const App = () => (
	<Suspense fallback={<Skeleton />}>
  		<MainPage />
  	</Suspense>
);

export default App;

배너 영역과 커스텀 메뉴 영역에서 스켈레톤 UI 노출시키기

Suspense를 사용해서 컴포넌트 내부의 특정 영역에 스켈레톤 UI를 표시해보자
Suspense를 사용해서 Fallback UI를 구성할 경우 데이버를 보여주는 컴포넌트에서 비동기 데이터 불러오기 상태에 따른 화면 분기를 완전히 분리할 수 있다.

// MainPage/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';


const MainPage = () => (
	<div>
  		<header>
  			<div className="title"> Simple Demo App </div>
  		</header>
  		<section className="banner__container">
  			<Suspense fallback={<BannerSkeleton />}>
  				<Banner />
  			</Suspense>
  		</section>
		<main>
  			<nav>
  				<Suspense fallback={<CustomMenuSkeleton />}>
                  	<CustomMenu />
                </Suspense>
  			</nav>
  		</main>
  	</div>
);

export default MainPage;

사용자 정보 영역과 알림 영역은 "둘 다 불러와지기 전에는" 스켈레톤 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">
      		{userIndoData && 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를 어떻게 표현할 것인지, 그리고 하위 컴포넌트들에서 사용할 데이터들을 불러오기를 담당하느냐의 기준으로 생각해보면 컴포넌트의 복잡도가 한층 낮아지고 관심사 분리를 더 명확하게 달성할 수 있다.

위 MainPage 컴포넌트에서 User 컴포넌트도 다음과 같이 추가해주면 이 요구사항을 포함한 기능 구현이 마무리된다.

// MainPage/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';

const MainPage = () => (
  <div>
    <header>
      <div className="title"> Simple Demo App </div>
    </header>
    <section className="banner__container">
      <Suspense fallback={<BannerSkeleton />}>
        <Banner />
      </Suspense>
    </section>
    <main>
      <nav className="custom-menu__container">
        <Suspense fallback={<CustomMenuSkeleton />}>
          <CustomMenu />
        </Suspense>
      </nav>
      <User />
    </main>
  </div>
);

export default MainPage;

참고

Suspense는 계층(Hierarchy) 관계를 갖는다. A Suspense가 있고 A Suspense 하위에 B Suspense가 존재한다고 가정했을 때, B Suspense 하위의 컴포넌트가 아직 준비되지 않은 경우 A Suspense 하위의 컴포넌트도 당연히 아직 준비되지 않은 것드로 간주한다. 이러한 Suspense의 특성을 사용하면 복잡한 React Component의 비동기 데이터 불러오기 상태 관리를 간단하게 수행할 수 있다.

하지만 이런 Suspense의 동작이 장점만을 갖고 있는 것은 아니다. 이러한 Suspense의 동작 방식 때문에 적재적소에 꼼꼼히 Suspense를 추가해주아야 한다. 만약 위 예시 코드에서 Suspense를 실수로 누락했다면 사용자에게는 어떤 화면이 보여질까? Hierarchy를 갖는 Suspense의 특성에 따라 상위 Suspense를 찾아 Component Tree의 상단으로 거슬러 올라가게 된다. 이렇게 Suspense가 누락된 경우 User 컴포넌트와 가장 가까운 Suspense는 App 컴포넌트에 있는 "비동기로 MainPage 번들을 불러올 때 처리를 위한 Skeleton"이므로, 위 예시 코드에 Suspense가 누락될 경우 사용자는 전체 화면 Skeleton을 보게 된다.

만약 이 서비스를 실제로 운영하게 되었다고 가정해보자. 서브시 운영 이후 여러 지표와 피드백을 통해 Alarm 컴포넌트에 사용되는 데이터 API의 Latency가 너무 높아서 사용자 이탕이 발생하고 있음을 알게되었고, 이를 막기 위해 UserInfo와 Alarm 중 하나라도 중비되면 일반 화면을 보여주게끔 변경하자는 요청이 충분히 들어올 수 있다.

명령형으로 UI를 구성할 경우 이러한 변경을 위해 "2번의 삼항연산자"를 사용하여야 한다. 물론 단순한 컴포넌트 이기 떄문에 간단히 변경할 수 있고, 코드가 그리 복잡해지지도 않는다. 하지만 컴포넌트의 규모가 커지게되면 어떨까? 여러 API를 사용하고 조작해서 화면에 그려줘야 하는 비지니스 로직이 들어가 있기라도 한다면 마냥 가벼운 마음으로 변경을 진행하기 어려울 것이다.

선언현 컴포넌트를 사용하면 User 컴포넌트는 정말 단순히 화면에 무엇을 그려줄지에 대한 관심만 있기 때문에, 다음과 같은 단순한 변경만으로 위 케이스에 대응이 가능하다. 만약 여러 개의 API를 사용하고 데이터를 조작해야 하는 등 복잡한 비지니스 로직이 포함되어 있더라도 모든 내용은 자식 컴포넌트에서 담당하고 있으므로 이 컴포넌트에서는 신경 쓸 필요 없는 일이 되어버린다.

// 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">
    <Suspense fallback={<UserInfoSkeleton />}>
      <UserInfo />
    </Suspense>
    <Suspense fallback={<AlarmSkeleton />}>
      <Alarm />
    </Suspense>
  </section>
);

export default User;

배너 영역 데이터 불러오기 실패 시 "사전 정의된 자체 배너" 화면 노출

지금까지는 Suspense를 이용한 선언형 컴포넌트 이야기를 주로 다뤘다면, 이제부터는 ErrorBoundary를 이용한 선언형 컴포넌트와 관련된 이야기를 해볼 차례다.

위에서 언급한 바와 같이 react-query를 사용해 지동기 데이터를 불러올 때 suspense 옵션, 또는 useErrorBoundary 옵션을 사용해서 React ErrorBoundary를 사용한 에러 화면 구성이 가능하다.

배너 영역 데이터 불러오기가 실패하였을 경우 '사전 정의된 자체 배너'노출 요건은 ErrorBoundary를 사용하면 그리 어렵지 않게 구현할 수 있을 것이다. 위에 언급된 react-error-boundary 컴포넌트를 사용하여 간단하게 구현해보자.

// MainPage/index.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

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

const MainPage = () => (
	<div>
  		<header>
      		<div className="title"> Simple Demo App </div>
    	</header>
  		<section className="banner__container">
  			<ErrorBoundary fallback={<FallbackBanner />}>
  				<Suspense fallback={<BannerSkeleton />}>
                  <Banner />
                </Suspense>
  			</ErrorBoundary>
  		</section>
		<main>
          <nav className="custom-menu__container">
            <Suspense fallback={<CustomMenuSkeleton />}>
              <CustomMenu />
            </Suspense>
          </nav>
          <User />
        </main>
  	</div>
);

export default MainPage;

ErrorBounday를 통해 API 에러를 처리함으로써 Banner 컴포넌트는 데이터를 불러오고 그 데이터를 화면에 보여주는 것에 오롯이 관심을 갖게 되고, 데이터를 불러오는 상황이나 에러가 발생한 상황에서의 화면은 Banenr 컴포넌트를 화면에 보여주는 MainPage 컴포넌트에서 책임지게 된다. Banner 컴포넌트 내부에 별토의 로직이 포함되지 않기 때문에 복잡도가 줄어들어 유지보수에 장점을 갖게 된다.

구체적으로 생각해보자! 만약 Banner 컴포넌트에서 여러개의 API를 호출한다고 가정해보자. 더 나아가 Banner 컴포넌트의 자식 컴포넌트에서도 여러개의 API를 호출한다고 생각해보자. 만약 ErrorBoundary를 사용하지 않는다면 에러 에러 상황을 확인하기 위해 여러 조건들을 비교하는 로직을 추가해야 하고, 더 나아가 자식 컴포넌트로 Props Drilling을 통한 상태 확인을 수행하거나 Global State로 API들의 상황을 관리해야 이 요구사항을 수행할 수 있다. 컴포넌트의 규모가 커질수록 유지 보수가 점점 어려워진다.

이번에 ErrorBoundary를 사용해 복잡한 에러 처리와 관련된 요구사항을 어떻게 달성할 수 있을지 알아보자!

일부 영역 데이터 불러오기 실패 시 "재시도 UI 표시"

정확한 요구사항은 커스텀 메뉴 영역, 사용자 정보와 알림 영역의 데이터 불러오기가 실패하였을 경우 "데이터 불러오기를 다시 시도할 수 있는 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를 사용해서 선언적으로 바꾸면 어떻게 될까?

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 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

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

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

위에서 살펴본 요구사항은 "재시도를 통해 사용자가 올바른 API 응답을 받을 수 있음"을 상정한 시나리오에 맞춰진 요구사항이었다. 하지만 어떤 케이스에는 서버에 심각한 이슈가 있어서 재시도를 진행하여도 사용자가 올바은 API 응답을 받을 수 없는 경우가 있을 수 있다. 만약 이 "심각한 이슈"가 특정한 시나리오 하에 있는 사용자에게만 발생하는 에러라면 CS가 인입되더라도 재현이 어렵거나 불가능해 이슈의 해소가 쉽지 않을 것이다.

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

  • 데이터를 불러올 서버에서 HTTP ErrorCode 500, Error Response 메시지가 "CRITICAL_ERROR"인 경우 "화면 전체를 덮는 에러 화면"을 표시한다. 이 에러 화면은 다음과 같은 조건을 만족해야 한다.
  • 사용자에게 "에러가 지속되면 고객센터로 문의하세요"라는 텍스트를 노출해주어야 한다.
  • 고객센터 인입 시 정확한 사용자 확인을 위해 AEM에서 추적할 수 있는 UUID를 발급하고 화면에 노출해주어야 한다.
  • 단, 배너 API 호출 시 발생한 에러는 "기본 배너"화면을 보여주는 것으로 갈음 한다.

우선 하위 ErrorBoundary에서 특정 에러를 처리하지 않고 위로 올리게끔 구성해야 상위 ErrorBoundary에서 처리할 수 있다.

// 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 에러 발생 시 ErrorBoundary를 사용하여 사전 정의된 배너를 보여주게 작업했던 부분을 생각해보자. 컴포넌트 하위에서 발생하는 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를 사용한다면 굳이 상태 관리를 위해 전역 상태 관리 라이브러리를 사용하는 게 어색하다.

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

지금까지 작업을 통해 비동기 데이터를 불러오는 과정에서 "로딩 화면"에 이미 Suspense를 사용하고 있고, 에러 케이스 처리를 위해 ErrorBoundary를 이미 사용하고 있는 상황이라면 이 특별항 요구사항의 처리를 위해 ErrorBoundary를 더 활용하는 방향이 좋을 것 같다.

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

참고 (kakao pay tech)

profile
프론트엔드 개발자가 되기위해 기록하고 공유하는 Zoey 블로그입니다.

0개의 댓글