본문 바로가기

프론트엔드/React

React 18 Suspense 기능 알아보기(feat. SWR, msw)

  • 키워드: react v18, suspense, ErrorBoundary, SWR, msw, mock API
  • 상황
    1. React 18에 새로 추가된 Suspense와 기존의 ErrorBoundary 기능을 조합하여 사용해보고, 그 장점을 파악한다.
  • 해결과정
    1. Suspense를 사용하기 앞서, data fetching과 렌더링을 수행하는 Block 컴포넌트를 작성한다.
        // Block.tsx
      import useSWR from 'swr';
      import React from 'react';
      
      const fetcher = (url: string) => fetch(url).then((res) => res.json());
      
      function Block({ label }: { label: string }) {
        const { data } = useSWR(['/delay', label], fetcher, { suspense: true });
      
        if (data.errorMessage) {
          throw new Error(data.errorMessage);
        }
        return (
          <p className="blockContent">
            {label} <span style={{ color: 'blue' }}>{data.message}</span>
          </p>
        );
      }
      export default Block;

      Block 컴포넌트는 SWR을 이용하여 '/delay'라는 GET API를 호출한다.
      data fetch에 성공한다면 'Good'이라는 message가 출력되고, 실패한다면 errorMessage를 throw한다.

      delay mock API는 msw 라이브러리를 사용하여 간단하게 구현했다. (https://mswjs.io/)
      '/delay'는 임의의 시간을 기다린 후에 '200 success' 또는 '500 error'를 return 한다.
        // mocks/handlers.js
      import { rest } from 'msw';
      
      export function sleep(ms) {
        return new Promise((resolve) => setTimeout(resolve, ms));
      }
      
      export const handlers = [
        rest.get('/delay', async (req, res, ctx) => {
          await sleep(Math.random() * 2000);
      
          const isSuccess = Math.random() > 0.2;
          if (isSuccess) {
            return res(ctx.status(200), ctx.json({ message: 'Good' }));
          } else {
            return res(ctx.status(500), ctx.json({ errorMessage: '서버 오류입니다.' }));
          }
        }),
      ];


      React v18의 Suspense를 이용하기 위해서 SWR의 suspense mode를 설정해주었다. ({ suspense: true })
      이제 Block Component의 부모 컴포넌트에서 Suspense를 걸어줘야한다. 

    2. 첫번째 상황으로 Block 4개를 일렬로 각각 렌더링하는 'Individual.tsx' 부모 컴포넌트를 작성한다.
      Block 컴포넌트 바로 위에 Suspense와 ErrorBoundary를 적용한 것을 확인할 수 있다.
        // Individual.tsx
      import React, { Suspense } from 'react';
      import ErrorBoundary from '../ErrorBoundary';
      import Block from './Block';
      
      function Individual() {
        return (
          <section className="blocks">
            {['ONE', 'TWO', 'THREE', 'FOUR'].map((label) => (
              <div className="blockWrapper" key={label}>
                <ErrorBoundary fallback={<p style={{ color: 'red' }}>Error</p>} key={label}>
                  <Suspense fallback={<p>loading...</p>}>
                    <Block label={label} />
                  </Suspense>
                </ErrorBoundary>
              </div>
            ))}
          </section>
        );
      }
      export default Individual;

      ErrorBoundary는 리액트 공식문서에서 제공하는 코드를 가져왔다.
      (https://ko.reactjs.org/docs/error-boundaries.html)
        // ErrorBoundary.jsx
      import React from 'react';
      
      class ErrorBoundary extends React.Component {
        state = { hasError: false, error: null };
        static getDerivedStateFromError(error) {
          return {
            hasError: true,
            error,
          };
        }
        render() {
          if (this.state.hasError) {
            return this.props.fallback;
          }
          return this.props.children;
        }
      }
      export default ErrorBoundary;



      'Individual.tsx' 의 렌더링 결과는 아래 사진과 같다.
      Individual 렌더링 결과
      4개의 Block Component에 대해 각각 'loading Suspense'가 작동하고, 응답값이 오는 즉시 Good(200 success) 또는 Error(500 error시)를 표시한다.


    3. 두번째 상황으로 Block 4개를 하나로 묶어 Suspense와 ErrorBoundary를 걸어보자.
      'Individual'과 비슷하게 'Integration' 컴포넌트를 만든다.
        // Integration.tsx
      import React, { Suspense } from 'react';
      import ErrorBoundary from '../ErrorBoundary';
      import Block from './Block';
      
      function Integration() {
        return (
          <ErrorBoundary
            fallback={
              <div className="boundary" style={{ color: 'red' }}>
                Error
              </div>
            }
          >
            <Suspense fallback={<div className="boundary">loading...</div>}>
              <section className="blocks">
                {['ONE', 'TWO', 'THREE', 'FOUR'].map((label) => (
                  <div className="blockWrapper" key={label}>
                    <Block label={label} key={label} />
                  </div>
                ))}
              </section>
            </Suspense>
          </ErrorBoundary>
        );
      }
      export default Integration;

      Individual과 마찬가지로 4개의 Block을 렌더링하지만, 이번에는 Suspense와 ErrorBoundary를 4개의 Block에 한번에 묶어서 걸어주었다.
      'Integration'의 결과는 아래와 같다.
      에러 발생 시(윗줄 Individual, 아랫줄 Integration)
      로딩/에러 발생 시(윗줄 Individual, 아랫줄 Integration)

      4개의 Block 중 하나라도 loading(Suspense) 또는 Error(ErrorBoundary) 상태일 경우, Integration 전체가 하나의 단위로 묶인다.
      4개의 Block이 모두 성공하면 각각 정상적으로 Good이 표시된다.
  • 의의
    1. Suspense와 ErrorBoundary를 사용해보니, 함수형 프로그래밍의 장점을 리액트 컴포넌트에 접목시켰다는 느낌이 들었다.

      기존에는 custom hook/HOC을 이용하여 로딩/에러 상태를 분리하거나, isLoading/isError와 같은 상태값을 추가하여 관리하였다. 그리고 이러한 방법들은 로딩/에러 상태와 각각의 컴포넌트 사이의 결합성이 존재했다.

      반면 Suspense와 ErrorBoundary를 이용할 경우, 컴포넌트는 '성공' 상태일 때의 결과에만 집중하여 개발할 수 있다. 로딩 상태와 에러 상태인 경우에는 그 사실을 위로 throw 하기만 하면 되기 때문이다. 이 때, throw한 상태를 어디에서 catch할 지도 개발자가 마음대로 정할 수 있고 그 위치를 수정하는 것도 쉽다는 것을 알게되었다.