Suspense란?

React의 비동기 작업 처리와 Suspense의 등장

React에서는 3가지 방법을 통해 렌더링 시 비동기 작업을 처리할 수 있다.

  • fetch on render: 컴포넌트 렌더링을 먼저 시작하고 useEffectcomponentDidMount로 비동기 처리한다.
  • fetch then render: useEffectcomponentDidMount로 화면을 그리는데 필요한 데이터를 모두 조회한 후 렌더링을 시작한다.
  • render as you fetch: 비동기 작업과 렌더링을 동시에 시작한다. 즉시 초기 상태를 렌더링(fallback rendering)하고, 비동기 작업이 완료되면 다시 렌더링한다.

fetch on renderfetch then render 방식의 단점

fetch on renderfetch then render 방식에는 아래와 같은 어려움이 있다.

  • Race Condition
    • 비동기 작업들이 가지는 자신만의 생명주기로 인해 Race Condition이 발생한다.
    • fetching과 렌더링 사이에 동기화를 통해 해결할 수 있다.
      if (isPending) {
        return <div>Loading...</div>;
      }
      
  • Waterfall Problem
    • 동기화 처리로 인한 동시성이 불가능해지고 효율성이 떨어진다.
  • High Coupling
    • 비동기 작업들을 한 곳에서 동시적으로 처리하고, 그 결과를 컴포넌트로 전달하면 동시성을 보장할 수 있지만 컴포넌트들 간의 역할분담이 불명확해지고 결합도가 높아진다.
      useEffect(() => {
        Promise.all([fetching1, fetching2]).then((res) => {
          setState(res);
        });
      }, []);
      

위와 같은 어려움으로 인해 render as you fetch방식을 사용하는 Suspense가 등장하게 됐다.

Suspense의 동작 방식

Suspense는 useEffect나 이벤트 핸들러 내부에서 페칭하는 경우를 감지하지 않는다.
Suspense를 동작시키기 위해서는 children에 속한 컴포넌트가 비동기 작업을 진행할 때 예외 처리로 Promisethrow해줘야 한다.

const resource = fetchProfileData();

function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>
  );
}

function ProfileDetails() {
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

function ProfileTimeline() {
  const posts = resource.posts.read();
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
export function fetchProfileData() {
  let userPromise = fetchUser();
  let postsPromise = fetchPosts();

  return {
    user: wrapPromise(userPromise),
    posts: wrapPromise(postsPromise)
  };
}

function wrapPromise(promise) {
  let status = 'pending';
  let result;
  let suspender = promise.then(
    (r) => {
      status = 'success';
      result = r;
    },
    (e) => {
      status = 'error';
      result = e;
    }
  );

  return {
    read() {
      if (status === 'pending') {
        throw suspender;
      } else if (status === 'error') {
        throw result;
      } else if (status === 'success') {
        return result;
      }
    }
  };
}

또한 아래와 같은 방법을 통해 Suspense 컴포넌트를 활성화할 수 있다.

  • Suspense를 도입한 프레임워크를 사용한 데이터 페칭

  • lazy를 사용한 지연 로딩 컴포넌트 코드

    import { lazy } from 'react';
    
    // 최상위 레벨에서 선언
    const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
    
    function Editor() {
    	(...)
    	<Suspense fallback={<Loading />}>
    	  <h2>Preview</h2>
    	  <MarkdownPreview />
    	</Suspense>
    	(...)
    }
    

Suspense의 사용법

Suspense를 사용하면 children이 로딩을 완료할 때까지 fallback을 표시할 수 있다.
fallback은 로딩이 완료되지 않은 경우에 보여주는 대체 UI로 주로 로딩 스피너나 스켈레톤을 사용한다.
Suspense는 children이 일시 중단되면 자동으로 fallback으로 전환되고, 데이터가 준비되면 다시 children으로 전환된다.

<Suspense fallback={<Loading />}>
  <Albums />
</Suspense>

기본적으로 Suspense 내부의 전체 트리는 단일 단위로 취급된다. 예를 들어, 다음 컴포넌트 중 하나만 데이터 대기를 위해 일시 중단하더라도 모든 컴포넌트가 함께 로딩 표시기로 대체된다.

<Suspense fallback={<Loading />}>
  <Biography />
  <Panel>
    <Albums />
  </Panel>
</Suspense>

컴포넌트가 일시 중단되면 가장 가까운 상위 Suspense 컴포넌트가 폴백을 표시한다. 이를 통해 여러 Suspense 컴포넌트를 중첩하여 로딩 시퀀스를 만들 수 있다.

<Suspense fallback={<BigSpinner />}>
  {' '}
  // 1
  <Biography /> // 2
  <Suspense fallback={<AlbumsGlimmer />}>
    {' '}
    // 3
    <Panel>
      <Albums /> // 4
    </Panel>
  </Suspense>
</Suspense>

갑작스러운 결론

Suspense 사용법을 익히고 캐싱을 직접 구현하기 어려우니 react-query를 쓰자!
useDeferredValue 훅, useTransition 훅은 어렵다.


참고

https://react-ko.dev/reference/react/Suspense
https://www.daleseo.com/react-suspense/ https://velog.io/@jay/Suspense
https://fe-developers.kakaoent.com/2021/211127-211209-suspense/