ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [React] 이제는 써야한다.Suspense , ErrorBoundary feat:(Apollo Clients)
    front-end/React 2024. 3. 20. 23:32

    개요

    현재 회사에서 맡고있는 프로덕트는 어드민 페이지를 관리 하고있는데, 어드민의 UI/UX가 대부분 filtering 하는 검색박스와, 이를 결과로 보여주는 AgGrid 라는 라이브러리의 table을 사용하고있습니다.

     

    추가로 GraphQL을 사용하기에 , Apollo-client를 사용하고 , Gql query 통해 error , loading  그리고 data state를 관리하고 있습니다. 위에 말했다시피 data를 AgGrid table로 그리는 페이지가 대다수였기에, Aggrid table 컴포넌트에서 제공하는 error, loading props를 넘겨주면 각 상황에 맞는 UI가 유저에게 그려지고 있습니다.

     

    하지만 어느 프로젝트나 시간이 지나면 그렇듯 프로덕트의 범위가 커지고, 어드민의 페이지가 늘어나고 기획 및 디자인 범위가 늘어날수록, 단순히 table만 그리는게 아닌 새로운 컴포넌트도 많이 도입이 되고 있었습니다.

     

    국내 및 해외 빅테크의 비동기 통신처리를 리액트에서 어떻게 하는지 유튜브나 블로그 포스팅글을 자주 보곤했는데, React 18의 Suspense 그리고 ErrorBoundary를 통해 비동기 통신 하는것을 추천 하는 글을 많이 보고 Suspense의 매력을 알아채고 기회가 되면 회사 프로젝트에 사용을 해봐야지 라는 생각을 갖고 있었습니다. 

    자 그럼 우선 Suspense가 비동기 통신에 어떤 이점이 있고 , React 16 , 17 version 의 Suspense과 18 version 의 Suspense의 차이를 알아보고, Apollo에서 제공하는 useSuspenseQuery와 어떻게 사용하는지 작성해보겠습니다.

     

    1. Why Suspense ?

    React의 Suspense는 컴포넌트의 로딩 상태를 React 자체에서 처리할 수 있게 해주는 기능입니다.

    이로 인해 데이터를 불러오는 비동기 로직을 쉽게 관리할 수 있으며, 이는 컴포넌트의 로딩 상태나 오류 상태를 처리하는 새로운 패턴을 제공합니다. Suspense는 React 16.6에서 처음 도입되었고, React 18에서는 Suspense 기능이 확장되어 더 많은 유형의 비동기 작업을 처리할 수 있게 되었습니다.

     

    Suspense의 장점

    1. 코드 분할을 쉽게 관리: Suspense를 사용하면 코드 분할(Code-Splitting)과 같은 비동기 작업을 쉽게 처리할 수 있습니다. 이는 애플리케이션의 로딩 시간을 줄이고 성능을 향상시킵니다.

     

    2.일관된 로딩 상태 처리: Suspense는 로딩 인디케이터 같은 UI를 쉽게 통합할 수 있게 해줍니다. 이를 통해 비동기 로딩 상태를 일관되게 관리할 수 있습니다.

     

    3.컴포넌트 추상화: 데이터 로딩 로직을 컴포넌트 외부로 분리함으로써 컴포넌트를 더 깨끗하고 관리하기 쉽게 만들 수 있습니다.

     

    4. 동시성 모드(Concurrent Mode)와의 통합: React 18에서는 Suspense가 Concurrent Mode와 결합하여, 여러 비동기 작업을 동시에 처리하면서도 사용자 인터페이스를 즉시 반응하게 할 수 있습니다.

     


    2. React 16~17 의 Suspense와 React 18 Suspense

    React 16, 17 의 Suspense

    주로 코드 분할을 위한 것으로 사용되었습니다. 컴포넌트가 로딩 중일 때 다른 컴포넌트로 대체하는 방식으로 작동했습니다. 주로 React.lazy와 import()를 사용하여 동적으로 컴포넌트를 로드하는 데 사용되었습니다.

     

    Suspense의 핵심은 Promise를 throw 하는 것입니다 . Promise가 resolve(성공) 하거나 reject(실패) 할 때까지 컴포넌트 트리의 실행을 연기 시킵니다.

     

    컴포넌트 트리의 실행이 연기되는 동안 컴포넌트 트리는 UI에서 숨겨집니다. DOM 트리에서 삭제되는 것이 아니라 해당 컴포넌트에 display: none 스타일을 추가하는 것입니다.

     

    React 16,17 에서의 Suspense 사용 예제

    const OtherComponent = React.lazy(() => import('./OtherComponent'));
    
    function MyComponent() {
      return (
        // Displays <Spinner> until OtherComponent loads
        <React.Suspense fallback={<Spinner />}>
          <div>
            <OtherComponent />
          </div>
        </React.Suspense>
      );
    }

     

     

    React 18 의 Suspense

    컴포넌트 트리의 실행이 연기되는 동안 해당 컴포넌트는 DOM 트리에 존재하지 않습니다.

    실행이 완료되지 않는 컴포넌트는 커밋 되지 않아, 따라서 컴포넌트가 완료되고 DOM 트리에 배치되며 브라우저 화면에 업데이트되기 때문에 라이프사이클 이벤트가 불일치하는 일이 발생하지 않습니다.

     

    Suspense는 데이터 로딩을 포함한 더 광범위한 비동기 작업을 지원합니다. 동시성 모드와의 통합을 통해 더 부드러운 사용자 경험을 제공합니다. Suspense는 이제 렌더링을 "중단"할 수 있으며, React는 우선순위에 따라 다른 작업으로 전환할 수 있습니다. 새로운 데이터 가져오기 라이브러리와의 통합을 위한 API가 도입되었습니다. 이를 통해 컴포넌트가 데이터를 필요로 할 때까지 데이터 로딩을 지연시킬 수 있으며, 이는 서버 사이드 렌더링(SSR)에서도 유용하게 사용될 수 있습니다. 더 나은 에러 핸들링을 제공하며, Suspense boundary 내에서 발생하는 오류를 쉽게 관리할 수 있습니다.

     

    기존 SSR 방식 문제점 

    1. HTML을 렌더링 하기 전에 app에 필요한 모든 데이터를 가져와야한다. 서버에서 HTML 렌더링을 시작하려면 모든 데이터가 준비되어 있어야 한다. 즉, 데이터가 수집되지 않으면 HTML 렌더링을 시작하지 못해 클라이언트에 어떠한 HTML로 전송할 수 없다는 것을 의미한다. 만약 초기 화면 일부에 필요한 데이터가 준비되는 시간이 늦어지면 초기 화면의 나머지 HTML 렌더링도 늦어지며 전송도 지연된다.

     

     2. hydrate를 시작하기 전에 필요한 모든 JavaScript 코드가 로드되어야 한다. 서버에서 렌더링된 HTML이 전달되면 정적인 HTML을 상호작용 가능하게 하기 위해 이벤트 핸들러를 연결해야 한다. 이때 브라우저 컴포넌트에 의해 생성된 컴포넌트 트리가 서버에서 생성된 트리와 일치해야 한다. 일치하지 않는다면 React는 HTML과 JavaScript 코드를 매치할 수 없다. 이 때문에 hydrate를 진행하기 전 모든 JavaScript를 로드해야 하는 것이다. 화면의 일부분(컴포넌트)에 상호작용할 로직이 많이 존재할 때, 이에 따른 JavaScript 로직을 다운로드하는데 시간이 많이 걸릴 경우 비교적 상호작용 로직이 적은 화면(컴포넌트) HTML에도 hydrate를 시작할 수 없다. 

     

    3. 상호작용을 시작하기 전, 모든 HTML에 hydrate가 완료되어야 한다. hydrate가 시작되면 React는 hydrate가 완료될 때까지 멈출 수 없으며 다른 작업을 할 수 없다. 즉, 화면 일부에 필요한 코드 다운로드가 느리다면 navigation 바 또는 사이드 바와 같은 부분도 hydrate가 되지 않아 해당 페이지에서 벗어나거나 다른 컨텐츠와 상호작용할 수 없다. 만약 느린 네트워크를 사용하는 사용자가 있다면 그 사용자는 HTML만 보고 있을 뿐 어떠한 작업도 할 수 없다는 것을 의미한다.

     


    3. Apollo client (useSuspenseQuery)와 함께 쓰는법

    이제 실제프로젝트에서 어떻게 Apollo와 어떻게 사용했는지 작성해보려 합니다.

     

    프로젝트내 아폴로 버전이 3.7 이였습니다. 공식문서상 리액트의 Suspense 기능을 사용하려면 기존에 자주 사용하던 useQuery , useLazyQuery가 아닌 useSuspenseQuery를 사용해야하는데 , 3.8 버전부터 사용이 가능하여 package 업그레이드를 우선 진행했습니다.

     

    우선 Apollo 공식문서 예제 코드입니다.

    import { Suspense } from 'react';
    import {
      gql,
      TypedDocumentNode,
      useSuspenseQuery
    } from '@apollo/client';
    
    interface Data {
      dog: {
        id: string;
        name: string;
      };
    }
    
    interface Variables {
      id: string;
    }
    
    interface DogProps {
      id: string
    }
    
    const GET_DOG_QUERY: TypedDocumentNode<Data, Variables> = gql`
      query GetDog($id: String) {
        dog(id: $id) {
          # By default, an object's cache key is a combination of
          # its __typename and id fields, so we should always make
          # sure the id is in the response so our data can be
          # properly cached.
          id
          name
          breed
        }
      }
    `;
    
    function App() {
      return (
        <Suspense fallback={<div>Loading...</div>}>
          <Dog id="3" />
        </Suspense>
      );
    }
    
    function Dog({ id }: DogProps) {
      const { data } = useSuspenseQuery(GET_DOG_QUERY, {
        variables: { id },
      });
    
      return <>Name: {data.dog.name}</>;
    }

     

    위 코드예시를 보면 useQuery나 useLazyQuery에서 제공하는 boolean 타입의 loading state는 useSuspenseQuery 에서 반환하지 않습니다. 

    useSuspenseQuery를 호출하는 컴포넌트가 데이터를 가져올 때 항상 suspends 상태가 되기 때문입니다. 이에 대한 함의는 렌더링될 때, 데이터가 항상 정의되어 있다는 것입니다! Suspense 패러다임에서는, 기존에 컴포넌트가 스스로 렌더링해야 했던 로딩 상태를 대체하는 fallback 들이 대기 중인 컴포넌트의 바깥에 존재합니다.

     

    useSuspenseQuery hook 은 네트워크 요청을 시작하고 요청이 진행되는 동안 호출한 컴포넌트를 대기 상태로 만듭니다. 이를 사용하는 동안 렌더링하는 동안 React의 Suspense 기능을 활용할 수 있게 해주는 useQuery의 Suspense 준비된 대체재로 생각할 수 있습니다.

     


    ErrorBoundary와 함께 사용하는법

    class ErrorBoundary extends React.Component {
      constructor(props) {
        super(props);
        this.state = { hasError: false };
      }
    
      static getDerivedStateFromError(error) {
        return { hasError: true };
      }
    
      render() {
        if (this.state.hasError) {
          return this.props.fallback;
        }
    
        return this.props.children;
      }
    }

     

    function App() {
      const { data } = useSuspenseQuery(GET_DOGS_QUERY);
      const [selectedDog, setSelectedDog] = useState(
        data.dogs[0].id
      );
    
      return (
        <>
          <select
            onChange={(e) => setSelectedDog(e.target.value)}
          >
            {data.dogs.map(({ id, name }) => (
              <option key={id} value={id}>
                {name}
              </option>
            ))}
          </select>
          <ErrorBoundary
            fallback={<div>Something went wrong</div>}
          >
            <Suspense fallback={<div>Loading...</div>}>
              <Dog id={selectedDog} />
            </Suspense>
          </ErrorBoundary>
        </>
      );
    }

     

    여기에서, 우리는 ErrorBoundary 컴포넌트를 사용하고 Dog 컴포넌트의 바깥에 배치합니다. 이제, Dog 컴포넌트 내부의 useSuspenseQuery 훅이 에러를 던질 때, ErrorBoundary가 이를 잡아내고 우리가 제공한 fallback 요소를 표시합니다.

     

    useSuspenseQuery는 데이터를 가져오는 동안 대기 상태가 되기 때문에, useSuspenseQuery를 사용하는 컴포넌트 트리는 각각의 useSuspenseQuery 호출이 이전 호출이 완료될 때까지 시작할 수 없는 "waterfall"을 일으킬 수 있습니다. 이는 부모 컴포넌트에서 useBackgroundQuery로 데이터를 가져오고, 자식 컴포넌트에서 useReadQuery로 데이터를 읽음으로써 피할 수 있습니다.

     

     

    도움 될만한글: Suspense 를 무분별하게 사용하고 있진 않는지?

    https://happysisyphe.tistory.com/54

Designed by Tistory.