Frontend/React

리액트 18의 Suspense 정리하기

hyewon.dev 2023. 1. 16. 17:56

개요

Suspense는 아직 준비되지 않은 UI 일부에 대해 로딩 상태를 선언적으로 표시할 수 있게 해주는 기능입니다.
처음에는 React 16.6에서 실험적인 기능으로 소개되었는데 React 18에서 정식으로 릴리즈되면서 기능이 확장되었습니다.
기존에는 주로 React.lazy와 함께 코드 스플리팅을 위해 사용됐으나 React 18부터는 코드뿐만 아니라 데이터 로딩이나 SSR 측면에서도 활용할 수 있게 되었습니다.
추후에는 거의 모든 비동기 작업에 Suspense를 적용할 수 있도록 React 팀이 지속적으로 기능을 확장해 나갈 계획이라고 합니다.

 

import { Suspense, lazy } from 'react';

const LazyComponent = lazy(() => import('./LazyComponent'));

function App() {
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

 

Suspense 컴포넌트의 동작을 활성화하는 방법

React에서 Suspense는 기본적으로 렌더링하는 동안 발생하는 비동기 작업을 처리하도록 설계되었습니다.
그렇기 때문에 Effect나 이벤트 핸들러 내부에서 데이터를 가져오는 작업은 렌더링 이후(컴포넌트가 화면에 렌더링된 후)에 수행되므로 Suspense가 이를 감지할 수 없어요.

 

따라서 아래 방식을 통해서 Suspense로 비동기 로직을 처리하고 fallback을 활성화시킬 수 있습니다.

  • Relay와 Next.js 같이 Suspense가 가능한 프레임워크를 사용한 데이터 가져오기
  • lazy를 활용한 지연 로딩 컴포넌트
  • use를 사용해서 Promise 값 읽기 (실험적)

 

Suspense의 기본적인 동작 원리

Javascript에서 throw를 하면 가장 가까운 catch 블록이 에러를 잡는데, React의 Suspense도 작동 방식은 다르지만 유사한 개념을 가지고 있어요.
컴포넌트가 suspend(렌더링 준비가 되지 않음) 상태가 되면 Suspense 컴포넌트가 감싸고 처리하게 됩니다.
예를 들어 React.lazy는 내부적으로 자동으로 suspend됩니다. (import된 코드가 로드되지 않았으면 suspend, 로드되면 React가 다시 렌더링 됨)

 

렌더링 방식의 변경

18버전에서는 렌더링 방식과 관련해 여러 가지 개선점이 반영되었습니다.

다음은 비동기 데이터를 표시하는 Comments와 이를 감싸는 Panel 컴포넌트를 Suspense를 이용해 렌더링하는 예제 코드입니다.

// 예제 코드
<div>
  {showComments && (
    <Suspense fallback={<Spinner />}>
      <Panel>
        <Comments />
      </Panel>
    </Suspense>
  )}
</div>

 

⬇️ 18 이전 버전의 처리 방식

  1. Panel의 내용을 DOM에 먼저 렌더링하고 Comments 자리는 비워둠
  2. 불완전한 Panel 내용이 보이지 않도록 display: none을 추가
  3. Spinner를 DOM에 추가해 화면에 표시합니다
  4. Panel이 기술적으로는 mount된 상태이기 때문에 effect가 실행됨
  5. Comments가 준비될 때까지 대기
  6. 다시 렌더링 시도
  7. DOM에서 Spinner를 제거
  8. 기존 Panel 아래에 Comments 내용을 삽입
  9. Panel의 display: none을 제거하여 화면에 표시

 

⬇️ 변경된 방식

  1. 준비되지 않은 Panel을 DOM에 넣지 않고 버림
  2. Spinner를 DOM에 렌더링해 화면에 표시
  3. Comments가 준비될 때까지 대기
  4. 다시 렌더링 시도
  5. DOM에서 Spinner를 제거
  6. Comments까지 포함된 완전한 Panel 내용을 DOM에 삽입
  7. Panel의 이펙트가 실행됨

 

새로운 방식은 준비되지 않은 트리를 DOM에 커밋하지 않기 때문에 훨씬 더 직관적이고 예측 가능해졌어요.

React 16.6에서는 클래스 기반 컴포넌트가 주로 사용되었는데 많은 컴포넌트에서 componentWillMount 메서드를 활용하고 있었습니다. 이 메서드는 DOM이 렌더링되기 전에 실행되기 때문에 React가 컴포넌트가 실제로 준비되었는지 판단하기 어렵게 만들었고 하위 호환성 문제로 인해 새로운 방식을 도입할 수 없었습니다.

현재는 해당 메서드가 UNSAFE_componentWillMount로 변경되어 deprecated 되었고 대부분의 코드가 Hooks 기반으로 전환되면서 보다 안정적인 방식으로 UI를 렌더링할 수 있게 되었어요.

 

SSR 환경에서 사용하기

React 18에서 SSR 환경에서도 Suspense 기능을 활용할 수 있게 되었습니다. (처음 등장했을 때는 클라이언트 렌더링용)

이로 인해 기존의 서버 사이드 렌더링에서 겪던 여러 가지 문제들이 개선되었어요.

 

기존 SSR의 문제

  1. 모든 데이터를 서버에서 받아와야 HTML을 전송할 수 있다.
  2. 모든 Javascript가 로드되고 실행된 이후에 하이드레이션이 시작된다.
  3. 전체 컴포넌트가 하이드레이션을 완료한 이후에만 상호작용이 가능했다.

 

React 18에서 개선된 점

  1. 서버에서 모든 데이터를 가져오지 않아도, Suspense를 활용한 스트리밍으로 먼저 HTML 전송을 시작할 수 있다.
  2. 준비된 부분부터 순차적으로 하이드레이션할 수 있다. (선택적 하이드레이션)
  3. 사용자와의 상호작용이 필요한 컴포넌트는 우선적으로 하이드레이션되어 빠르게 반응할 수 있다.

 

Next.js에서 사용해보기

Next.js는 기본적으로 서버 사이드 렌더링을 지원하므로 React의 Suspense와 스트리밍 기능을 쉽게 구현할 수 있습니다.

import { Suspense } from "react";
import { skeleton, shimmerBar } from "./styles.css";

const fetchData = async (ms: number): Promise<string> => {
  return new Promise((resolve) => {
    setTimeout(() => resolve("Hello, this is streamed data!"), ms);
  });
};

const MyComponent1 = async () => {
  const data = await fetchData(5000); // 비동기 데이터 호출
  return <div>{data}</div>;
};

const MyComponent2 = async () => {
  const data = await fetchData(8000); // 비동기 데이터 호출
  return <div>{data}</div>;
};

export const Skeleton = () => (
  <div className={skeleton}>
    <div className={shimmerBar} />
  </div>
);

export default function Page() {
  return (
    <>
      <Suspense fallback={<Skeleton />}>
        <MyComponent1 />
      </Suspense>

      <Suspense fallback={<Skeleton />}>
        <MyComponent2 />
      </Suspense>
    </>
  );
}

 

 

참고

RFC for Suspense in React 18