리액트 입문 강의를 들었다면, useEffect 를 사용하여 비동기 데이터 페칭을 수행하고, useState 를 사용하여 로딩 상태를 관리하는 예제를 많이 따라친 경험이 있을 것이다.
React에서 렌더링 시 비동기 작업 처리를 하는 방법에는 Fetch-on-render, Fetch-then-render, Render-as-you-fetch (Suspense)가 있다. Suspense를 사용하지 않았던 때는 컴포넌트 렌더링을 먼저 수행한 후 componentDidMount(클래스형 컴포넌트), useEffect(함수형 컴포넌트)로 비동기 처리를 하거나(Fetch-on-render), 데이터를 모두 조회한 후 렌더링을 하는(Fetch-then-render) 방식이 있었다. 하지만 이러한 두 방식에는 몇 가지 문제점이 있었다.
Suspense for Data Fetching (Experimental) – React
Promise.all()
과 같은 방식으로 동시에 비동기 처리를 함으로써 Fetch-on-render의 동시성 보장 문제를 해결할 수 있다. 하지만, 자식 컴포넌트에서 처리해야 할 데이터를 부모 컴포넌트에 위임함으로써 관심사 분리가 제대로 이루어지지 못하는 문제가 있다.
비동기 작업과 렌더링을 동시에 시작하며, 초기에는 fallback 컴포넌트를 렌더링하고 비동기 작업이 완료되면 리렌더링하는 방식을 취한다.
Suspense는 자식 요소를 로드하기 전까지 화면에 대체 UI를 제공하는 React의 컴포넌트이다.
특징
props
prop | 설명 |
---|---|
children | 렌더링하려는 실제 UI |
fallback | 실제 UI가 로딩되기 전까지 대신 렌더링되는 대체 UI로, 보통 로딩 스피너나 스켈레톤처럼 간단한 Placeholder를 활용한다. |
Suspense 여러 개의 Suspense 를 중첩하거나 트리 구조를 사용할 경우, 각 Suspense 가 독립적으로 로딩 상태를 관리하기 때문에 데이터 준비 시점이 다를 수 있다. 그렇게 되면 로딩 화면(fallback)이 여러 번 표시되거나 비일관적인 UI를 제공하여 사용자 경험을 해칠 수 있다. 따라서 트리의 구조와 데이터 로딩 흐름을 적절히 설계해야 할 필요가 있다. 또한 Suspense 는 Promise 기반의 비동기 작업만 지원하므로, 추가적인 라이브러리를 사용하거나 Suspense 와 호환되는 형태로 Promise 를 관리해야 한다.
주의할 점
모든 컴포넌트 주위에 Suspense를 두지 마세요. Suspense는 사용자가 경험하기를 원하는 로딩 순서보다 더 세분화되어서는 안 됩니다. 디자이너와 함께 작업하는 경우 로딩 상태를 어디에 배치해야 하는지 디자이너에게 물어보세요. 디자이너가 이미 디자인 와이어 프레임에 포함했을 가능성이 높습니다. - 리액트 공식문서
사용자의 최근 기록을 조회하는 페이지에서는 데이터를 비동기로 불러오고 화면에 로딩중, 에러에 따른 분기 처리를 해주었다.
기존의 useInfiniteQuery
는 기본적으로 promise를 반환하는 것이 아닌 빈 데이터에 점진적으로 데이터를 채워넣는 방식을 취하기 때문에 Suspense
를 적용할 수 없다. 즉, 기존에 사용하던 useInfiniteQuery
훅은 Suspense
와 호환되는 형태가 아니므로, 이를 적절한 훅으로 교체해야 했던 것이다.
이를 위해 tanstack query(리액트 쿼리)의 useInfiniteQuery
훅을 useSuspenseInfiniteQuery
훅으로 교체하였다. Suspense
는 자식 컴포넌트로부터 throw된 promise의 상태에 따라 fallback을 보여줄지를 결정할 수 있다.
useSuspenseQuery
이와 마찬가지로 Suspense를 지원하는 useQuery가 존재한다.
// recent-history.tsx
// COMPONENT: 최근 기록
import { useEffect, useState } from "react";
import OvalLoadingSpinner from "../../../../_components/loading-spinner/oval-loading-spinner";
import { useGetRecentDriverActions } from "../../../../api/action";
import LoadingErrorIcon from "../../../../_components/icon/loading-error-icon";
export default function RecentHistory() {
const {
data: recentDriverActionsPages,
isLoading,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetRecentDriverActions();
useEffect(() => {
if (hasNextPage && inView) {
fetchNextPage();
}
});
// 에러 발생 시 에러 메시지 표시
if (isError)
return (
<ContentBlockWrapper height={"16rem"}>
<LoadingErrorIcon color="red" size="2.4rem" />
</ContentBlockWrapper>
);
return (
<ContentBlockWrapper height={"20rem"} padding={"1rem"}>
<S.Header>최근 기록</S.Header>
<S.RecentHistoryContainer>
<S.RecentHistoryWrapper>
{/* 무한스크롤로 데이터 페칭: page[]>page>item 구조 */}
{isLoading ? (
<OvalLoadingSpinner />
) : (
<>
{recentDriverActionsPages?.pages.map(
(recentDriverActionsPage: IDriverActionResponse["action"][]) =>
recentDriverActionsPage?.map(
(
recentDriverActionItem: IDriverActionResponse["action"],
idx: number
) => <S.HistoryItem key={recentDriverActionItem.id} />
)
)}
</>
)}
{isFetchingNextPage && (
<div>
<OvalLoadingSpinner />
</div>
)}
</S.RecentHistoryWrapper>
</S.RecentHistoryContainer>
</ContentBlockWrapper>
);
}
// recent-history.tsx
// COMPONENT: 최근 기록
import { Suspense } from "react";
export default function RecentHistory() {
return (
<ContentBlockWrapper height={"20rem"} padding={"1rem"}>
<S.Header>최근 기록</S.Header>
<Suspense fallback={<OvalLoadingSpinner />}>
<RecentHistoryContent />
</Suspense>
</ContentBlockWrapper>
);
}
// recent-history-content.tsx
// COMPONENT: 최근 운전자 행위 기록 내용 (데이터)
import { useEffect } from "react";
import { useGetRecentDriverActions } from "../../../../../api/action";
export default function RecentHistoryContent() {
// 데이터 페칭 수행
const {
data: recentDriverActionsPages,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetRecentDriverActions();
return (
<S.RecentHistoryContainer>
<S.RecentHistoryWrapper>
{/* 무한스크롤 가져온 데이터: page[]>page>item 구조 */}
</S.RecentHistoryWrapper>
</S.RecentHistoryContainer>
);
}
개발자 탭의 네트워크 환경을 느린 속도로 바꾸어 진행했다.
참고자료
[10분 테코톡] 클린의 서스펜스와 에러바운더리
React Suspense 소개 (feat. React v18)