[React] Suspense (feat. useSuspenseInfiniteQuery)

Woonil·2025년 2월 24일
0

리액트

목록 보기
1/1
  • 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

💡 자식 컴포넌트 로드 전 대체 UI를 보여주는 컴포넌트

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

  • 특징
    • 비동기 작업을 수행하는 자식 컴포넌트를 감싸, 비동기 작업 수행 시 fallback에 할당을 받은 특정 컴포넌트(ex. 로딩스피너)를 렌더링한다.
    • fallback 컴포넌트가 렌더링 됨과 동시에 자식 children 컴포넌트가 동시에 렌더링된다.
    • 리액트에게 비동기 작업을 수행하는 자식 컴포넌트를 알려줌으로써, 비동기 작업 종료 시 fallback에 할당된 컴포넌트 대신 자식 컴포넌트를 리렌더링하게 한다.
    • 로딩 상태를 선언적으로 처리할 수 있다.
  • props
    prop설명
    children렌더링하려는 실제 UI
    fallback실제 UI가 로딩되기 전까지 대신 렌더링되는 대체 UI로, 보통 로딩 스피너나 스켈레톤처럼 간단한 Placeholder를 활용한다.

주의할 점

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

Suspense – React

😎실습

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

사용자의 최근 기록을 조회하는 페이지에서는 데이터를 비동기로 불러오고 화면에 로딩중, 에러에 따른 분기 처리를 해주었다. 우선 데이터를 불러오는 코드에서 tanstack query(리액트 쿼리)의 useInfiniteQuery 훅을 useSuspenseInfiniteQuery 훅으로 교체한다. Suspense는 자식 컴포넌트로부터 throw된 promise의 상태에 따라 fallback을 보여줄지를 결정할 수 있다. 기존의 useInfiniteQuery는 기본적으로 promise를 반환하는 것이 아닌 빈 데이터에 점진적으로 데이터를 채워넣는 방식을 취하기 때문에 Suspense를 적용할 수 없다. 따라서, Suspense를 지원하는 useSuspenseInfiniteQuery를 사용해야 한다.

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개의 댓글

관련 채용 정보