suspenseQuery와 suspense/ErrorBoundary
useSuspenseQuery와 Suspense, ErrorBoundary를 직접 사용하면서 선언적 비동기 처리의 장단점을 정리했다.
평소에 useQuery만 사용하다가 suspenseQuery를 직접 사용해보면서 어떠한 점이 좋았는지 알아보았다. 그리고 함께 사용할 수 있는 여러 방법들을 배우게되었다.
현재 진행중인 프로젝트의 마지막으로 개발해야하는 페이지는 suspense를 적극적으로 사용해볼 계획이다. 이 경험을 토대로 다음 장/단점을 명확하게 구분하고 다음 프로젝트를 진행할 때도 도움이 되었으면 한다.
useSuspenseQuery
useSuspenseQuery는 항상 데이터를 반환한다. 기존의 useQuery는 undefined를 반환해서 옵셔널 처리가 수도 없이 생기게 된다.
// useQuery 사용
function UserProfile({ userId }) {
const { data, isLoading, isError } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
});
// 각 상태에 대한 처리
if (isLoading) return <Spinner />;
if (isError) return <ErrorMessage />;
if (!data) return null;
return (
<div>
{/* 옵셔널 체이닝 */}
<h1>{data?.name}</h1>
<p>{data?.email}</p>
</div>
);
}
// userSuspenseQuery 사용
function UserProfile({ userId }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
});
// 로딩, 에러는 부모 컴포넌트에서 관리
return (
<div>
{/* 데이터가 100% 보장되므로 옵셔널 체이닝(?) 불필요 */}
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
// 부모 컴포넌트
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<Loading />}>
<UserProfile />
</Suspense>
</ErrorBoundary>;
이렇게 선언적으로 관리할 수 있다는게, 큰 장점이다.
Suspense
<Suspense>는 자식 요소가 렌더링 되기 전까지 화면에 대체 UI를 보여준다. 기본적으로 React에서 제공하고있다.
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
자식 컴포넌트의 데이터가 아직 로딩 중이라는 건 어떻게 알까?
useSuspenseQuery 훅은 컴포넌트가 호출되어 렌더링이 되는 도중에 실행이 되는데, 이 때 내부적으로 아직 데이터를 불러오기 전이라면 Promise 객체 자체를 throw 해버린다. <Suspense />는 이를 감지하고 fallback을 렌더링 시키게 된다.
ErrorBoundary
<ErrorBoundary>는 자식 요소 렌더링 중 에러가 발생했을 때, 대체 UI를 보여준다.
suspensive/react, react-error-boundary, @sentry/react에서 제공하고 React 클래스 컴포넌트로 사용할 수 있다.
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
</ErrorBoundary>
에러가 났다는 건 어떻게 알까?
Suspense와 원리는 똑같다. useSuspenseQuery는 내부적으로 API 요청이 실패(Reject)하면, 이번에는 Promise가 아니라 Error 객체 자체를 throw 해버린다.
자바스크립트 실행 흐름이 멈추고 에러가 상위로 전파되는데, 이를 가장 가까운 <ErrorBoundary>가 catch한다. 덕분에 에러가 발생한 부분만 우아하게 처리하고 나머지 앱은 정상적으로 유지할 수 있게 된다.
그럼 무조건 useSuspenseQuery가 더 좋은건가?
모든 기술에는 Trade-off가 있듯이, useSuspenseQuery도 치명적인 단점이 하나 있다. 바로 Waterfall 현상이다.
useSuspenseQuery의 직렬 처리
useSuspenseQuery는 데이터가 없으면 throw를 하고 렌더링을 중단해버린다. 만약 하나의 컴포넌트에서 두 개의 데이터를 불러와야 한다면 어떻게 될까?
function Dashboard() {
// 1. 유저 정보를 요청하고, 데이터가 올 때까지 멈춤
const { data: user } = useSuspenseQuery({ queryKey: ['user'], ... });
// 2. 위 요청이 끝나고 다시 렌더링 될 때까지 이 코드는 실행조차 되지 않음
const { data: posts } = useSuspenseQuery({ queryKey: ['posts'], ... });
return ...
}
첫 번째 쿼리가 완료될 때까지 두 번째 쿼리는 시작조차 하지 못한다. 이를 Waterfall 현상이라고 하며, 전체 로딩 시간이 길어지는 원인이 된다. 반면에, useQuery로 데이터를 2개 이상 불러올 경우에는 병렬적으로 처리가 가능하다.
그럼 Suspense를 쓰면서 병렬 처리는 못 하는 걸까? 다행히 react-query는 이를 위해 useSuspenseQueries를 제공한다.
function Dashboard() {
// 배열로 묶어서 요청하면 병렬로 처리됨!
const [{ data: user }, { data: posts }] = useSuspenseQueries({
queries: [
{ queryKey: ['user'], ... },
{ queryKey: ['posts'], ... },
],
});
return ...
}
컴포넌트 depth가 깊어진다
useSuspenseQuery의 훅은 자식 컴포넌트에서 사용해야한다. 그래서 의도적으로 컴포넌트를 분리시켜야한다.
이 때, 더 많은 데이터를 제공하기 위해 더 많은 컴포넌트를 렌더링 해야한다면?
// 1. 부모 컴포넌트
const UserProfileContainer = ({ id }) => {
return (
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loader />}>
{/* 반드시 자식으로 분리해야 동작함 */}
<UserProfile id={id} />
<Dashboard />
</Suspense>
</ErrorBoundary>
);
};
// 2. 자식 컴포넌트
const UserProfile = ({ id }) => {
const { data } = useSuspenseQuery(...);
return <div>{data.name}</div>;
};
const Dashboard = () => {
const { data } = useSuspenseQuery(...);
return <div>{data}</div>;
};
추가되는 컴포넌트마다 계속해서 분리를 해줘야한다.
이 문제도 해결할 수 있는데, Suspensive 라이브러리에서 제공하는 <SuspenseQuery>를 사용하면 해결이 가능하다.
import { SuspenseQuery } from '@suspensive/react-query';
// 수정 제안 코드
const Page = ({ userId }) => (
<ErrorBoundary fallback="error">
{/* 유저 프로필 영역 로딩 */}
<Suspense fallback={<ProfileSkeleton />}>
<SuspenseQuery {...userQueryOptions(userId)}>
{({ data: user }) => <UserProfile {...user} />}
</SuspenseQuery>
</Suspense>
{/* 대시보드 영역 로딩 (위와 동시에 병렬로 로딩됨!) */}
<Suspense fallback={<DashboardSkeleton />}>
<SuspenseQuery {...dashboardQueryOptions(...)}>
{({ data }) => <Dashboard {...data} />}
</SuspenseQuery>
</Suspense>
</ErrorBoundary>
);
마치며
react는 철저한 선언형 아키텍처를 지향하며, 비동기 데이터 처리조차 UI 선언의 일부로 통합하기 위해 <Suspense>를 도입했다. react-query 또한 이 흐름에 동참하여 v5부터 useSuspenseQuery를 공식 지원하기 시작했고.
결국 react가 생각하는 방향은 개발자가 오직 '데이터'와 그에 매핑되는 'UI'를 선언하는 데에만 집중하게 만드는 것을 추구하고있다.
END OF ARTICLE