[리액트] Suspense (feat. useSuspenseInfiniteQuery)

Woonil·2025년 2월 24일
0

리액트

목록 보기
3/4
post-thumbnail

리액트 입문 강의를 들었다면, useEffect 를 사용하여 비동기 데이터 페칭을 수행하고, useState 를 사용하여 로딩 상태를 관리하는 예제를 많이 따라친 경험이 있을 것이다.

🤔개념

Data Fetching Approaches

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

Fetch-on-render

  • 문제
    • 여러 데이터를 받아오는 경우, 데이터 상태에 따른 분기 처리가 많아져 관리가 어려워진다.
    • 폭포수 문제: 자식 컴포넌트에도 데이터를 페칭하는 코드가 있는 경우, 부모 컴포넌트의 데이터 페칭이 끝나기 전까지 렌더링할 수 없으므로 동시성이 보장되지 않는다.

Fetch-then-render

Promise.all() 과 같은 방식으로 동시에 비동기 처리를 함으로써 Fetch-on-render의 동시성 보장 문제를 해결할 수 있다. 하지만, 자식 컴포넌트에서 처리해야 할 데이터를 부모 컴포넌트에 위임함으로써 관심사 분리가 제대로 이루어지지 못하는 문제가 있다.

  • 문제
    • 높은 결합도를 가지고, 컴포넌트 간 역할 분담이 제대로 이루어지지 않는다.
    • all 메서드 사용 시, 페칭 간 소요시간 차이가 심하게 발생할 수 있다.

Render-as-you-fetch (Suspense)

비동기 작업과 렌더링을 동시에 시작하며, 초기에는 fallback 컴포넌트를 렌더링하고 비동기 작업이 완료되면 리렌더링하는 방식을 취한다.

  • 기존 방식의 흐름
    1. 페칭 시작
    2. 페칭 종료
    3. 렌더링 시작
  • Suspense의 흐름
    1. 페칭 시작
    2. 렌더링 시작
    3. 페칭 종료

Suspense

Suspense는 자식 요소를 로드하기 전까지 화면에 대체 UI를 제공하는 React의 컴포넌트이다.

  • 특징

    • 비동기 작업을 수행하는 자식 컴포넌트를 감싸, 비동기 작업 수행 시 fallback에 할당을 받은 특정 컴포넌트(ex. 로딩스피너)를 렌더링한다.
    • fallback 컴포넌트가 렌더링 됨과 동시에 자식 children 컴포넌트가 동시에 렌더링된다.
    • 리액트에게 비동기 작업을 수행하는 자식 컴포넌트를 알려줌으로써, 비동기 작업 종료 시 fallback에 할당된 컴포넌트 대신 자식 컴포넌트를 리렌더링하게 한다.
    • 로딩 상태를 선언적으로 처리할 수 있다.
  • props

    prop설명
    children렌더링하려는 실제 UI
    fallback실제 UI가 로딩되기 전까지 대신 렌더링되는 대체 UI로, 보통 로딩 스피너나 스켈레톤처럼 간단한 Placeholder를 활용한다.

Suspense 여러 개의 Suspense 를 중첩하거나 트리 구조를 사용할 경우, 각 Suspense 가 독립적으로 로딩 상태를 관리하기 때문에 데이터 준비 시점이 다를 수 있다. 그렇게 되면 로딩 화면(fallback)이 여러 번 표시되거나 비일관적인 UI를 제공하여 사용자 경험을 해칠 수 있다. 따라서 트리의 구조와 데이터 로딩 흐름을 적절히 설계해야 할 필요가 있다. 또한 Suspense 는 Promise 기반의 비동기 작업만 지원하므로, 추가적인 라이브러리를 사용하거나 Suspense 와 호환되는 형태로 Promise 를 관리해야 한다.

주의할 점

모든 컴포넌트 주위에 Suspense를 두지 마세요. Suspense는 사용자가 경험하기를 원하는 로딩 순서보다 더 세분화되어서는 안 됩니다. 디자이너와 함께 작업하는 경우 로딩 상태를 어디에 배치해야 하는지 디자이너에게 물어보세요. 디자이너가 이미 디자인 와이어 프레임에 포함했을 가능성이 높습니다. - 리액트 공식문서

Suspense – React

😎실습

Suspense로 컴포넌트 간소화하기 (feat. useSuspenseInfiniteQuery)

사용자의 최근 기록을 조회하는 페이지에서는 데이터를 비동기로 불러오고 화면에 로딩중, 에러에 따른 분기 처리를 해주었다.

기존의 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)

profile
프론트 개발과 클라우드 환경에 관심이 많습니다:)

0개의 댓글