React Suspense란?

Jun Song
11 min readJul 9, 2023

--

Concurrency in React 시리즈

React 18부터 주목받고 있는 동시성 렌더링이라는 큰 주제 안에서 필요한 조각들을 수집합니다.

tl;dr;
<Suspense>는 React에서 무언가를 기다릴 때 사용합니다. children이 로딩되기 전에 fallback을 보여줄 수 있습니다.

💯 Example

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

fallback에는 실제 UI가 로딩이 끝날 때까지 대신 보여줄 컴포넌트를 넣어줍니다. 보통 스피너나 스켈레톤을 넣습니다.

만약 Suspense가 컨텐츠를 보여준 다음 다시 suspend 상태에 들어가게 되면, fallback을 보여주게 됩니다. useDeferredValuestartTransition 등을 사용하면 suspend 상태가 되어도 fallback을 보여주지 않고 결과값이 나올 때까지 기다린 다음에 보여줍니다.

✏️ Suspense의 원리

위 글을 참고했습니다. 원리를 몰라도 아래 내용(Usage)을 이해하기엔 어렵지 않으니 넘어가셔도 좋을 것 같습니다.

<Layout>
<NavBar />
<Sidebar />
<RightPanel>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane;>
</Layout>

위와 같은 구조로 트리가 구성되어 있다고 가정해 봅시다. Comments 컴포넌트가 Suspense에 감싸져 있습니다. React는 이를 통해 Comments를 기다리지 않고 다른 HTML을 먼저 스트리밍해도 되겠다고 판단합니다.

Comments 컴포넌트를 제외한 것들을 먼저 받아옵니다.
<main>
<nav>
<!--NavBar -->
<a href="/">Home</a>
</nav>
<aside>
<!-- Sidebar -->
<a href="/profile">Profile</a>
</aside>
<article>
<!-- Post -->
<p>Hello world</p>
</article>
<section id="comments-spinner">
<!-- Spinner -->
<img width=400 src="spinner.gif" alt="Loading..." />
</section>
</main>

따라서, 처음에 클라이언트는 Comments에 대한 HTML이 아닌 스피너로 대체된 HTML을 받게 됩니다.

<div hidden id="comments">
<!-- Comments -->
<p>First comment</p>
<p>Second comment</p>
</div>
<script>
// This implementation is slightly simplified
document.getElementById('sections-spinner').replaceChildren(
document.getElementById('comments')
);
</script>

만약에 데이터가 준비되었다고 하면, React는 같은 스트림으로 추가적인 HTML을 보내게 됩니다. 이때 인라인으로 자바스크립트 코드가 들어가는데, 해당 코드에는 Suspend된 컴포넌트가 들어갈 자리에 새로운 컴포넌트를 갈아끼우는 코드가 들어갑니다.

모든 HTML이 렌더링이 된 모습

이렇게 되면 모든 HTML이 먼저 렌더링이 됩니다. 다만 아직 Hydration을 거치지 않았기에, 이벤트 핸들러와 같은 유저 인터랙션은 수행할 수 없는 상태입니다. 기존 SSR과 달리, Suspense를 사용하면 Hydration 역시 컴포넌트 단위로 작업할 수 있습니다. (Selective Hydration)

만약에 여러 개의 컴포넌트가 Suspense로 감싸져 있어, Hydration을 각기 다른 타이밍에 수행해야 된다면 React는 어떻게 행동할까요? 아래와 같은 구조를 상상해 봅시다.

<Layout>
<NavBar />
<Suspense fallback={<Spinner />}>
<Sidebar />
</Suspense>
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>

Suspense로 감싸진 컴포넌트가 두 개가 있기 때문에, 이 두 컴포넌트를 제외한 나머지 컴포넌트는 HTML 렌더링과 Hydration이 먼저 일어나게 됩니다. 아래와 같이 말이죠.

초록색으로 칠해진 부분이 Hydration이 마무리되어 인터랙션이 가능한 상태입니다.

React는 두 컴포넌트 중 트리에서 더 빨리 찾아지는 Suspense 바운더리를 먼저 Hydrating을 시도합니다. 가령, 사이드바가 먼저 찾아졌다고 가정합시다. 근데 이때, 유저가 Comments 컴포넌트와 인터랙션을 시도하면 어떻게 될까요? 놀랍게도 React는 클릭 이벤트가 발생한 컴포넌트를 먼저 동기적으로 Hydrating합니다.

Suspense는 Hydration도 컴포넌트 단위로 가능하게 합니다.

😮 Usages

컨텐츠가 로딩 중일 때 fallback을 보여주기

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

가장 대표적인 예시입니다. <Albums />가 컴포넌트 내부에서 비동기적으로 데이터를 가져오고 있을 때, 이를 가장 가까운 Suspense 바운더리에서 캐치한 후 로딩 컴포넌트를 보여줄 수 있습니다.

이때 주의할 점은, Suspensechildrensuspense-enabled data여야 한다는 점입니다. children 컴포넌트가 useEffect를 사용하거나 이벤트 핸들러를 통해 데이터를 가져온다면 Suspense는 이를 감지하지 못합니다.

Next.js와 같은 프레임워크를 사용할 수도 있고, React만 사용한다면 컴포넌트에 lazy loading을 적용하는 방법도 있습니다. 사실 퓨어하게 React만 사용해서 이를 구현하는 게 큰 의미가 없고 프레임워크에서 기본으로 해주기 때문에, renderToString이나 hydrateRoot와 같은 React API는 많이 안 쓰일 듯 합니다.

Suspense를 적절히 활용해 몇 가지 변형들을 줄 수 있습니다.

1. 여러 개의 컨텐츠를 동시에 보여주고 싶을 때

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

Suspense 안에 있는 모든 트리는 단일 단위로 취급됩니다. Biography, Albums 컴포넌트의 데이터 fetching이 마무리되어야 해당 컨텐츠가 보이게 됩니다.

2. 각 컨텐츠의 타이밍이 다를 때

<Suspense fallback={<BigSpinner />}>
<Biography />
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums />
</Panel>
</Suspense>
</Suspense>

Suspense 컴포넌트는 겹쳐서 사용할 수 있습니다. 위 컴포넌트에서 Biography 컴포넌트는 Albums 컴포넌트를 기다리지 않아도 됩니다.

컴포넌트 단위로 로딩 UI를 적용할 수 있다는 것이 큰 장점이고, 디자이너의 요구 사항에 맞춰 전체 페이지를 한 번에 보여줄지, 아니면 특정 컨테이너 단위로 보여줄지 결정하면 됩니다.

fresh한 컨텐츠가 로딩 중일 때, stale한 컨텐츠를 보여주고 싶을 때

<Suspense fallback={<h2>Loading...</h2>}>
<SearchResults query={query} />
</Suspense>

검색 관련 UI를 구현할 때 유용할 것 같은 케이스입니다. SearchResults가 데이터를 가져오는 동안, 로딩 중임을 보여줍니다.

만약 이전 결과값을 그대로 유지하고 싶다면, useDeferredValue를 사용합니다.

const deferredQuery = useDeferredValue(query);
...
<Suspense fallback={<h2>Loading...</h2>}>
<SearchResults query={deferredQuery} />
</Suspense>

❓그래서, useEffect보다 나은 점은?

useEffect의 고질적인 단점을 모두 해결합니다.

  1. useEffect는 UI 폭포(Waterfall)이 발생합니다. 상위 컴포넌트의 데이터 로딩이 끝나면, 하위 컴포넌트 데이터 로딩이 시작되기 때문에 UI가 순차적으로 나타납니다.
  2. useEffect는 초기 렌더링 이후에 발생하는 사이드 이펙트로, 데이터 로딩이 끝나면 리렌더링을 수행하기 때문에, Race Condition에 취약합니다
  3. Declarative vs. Imperative 코드 스타일. React는 Declarative한 코드를 지향하고, 한 컴포넌트 내에서 데이터 로딩과 렌더링을 같이 한다는 점이 React스럽지 못합니다.

🆕 새로운 SSR 아키텍처, 서버 컴포넌트

일반적으로 서버 사이드 렌더링과 서버 컴포넌트는 아예 다른 개념으로 다뤄집니다. 서버 컴포넌트에 대해선 추후에 다루도록 하겠습니다.

일반적인 SSR은 다음과 같은 과정을 거칩니다. (대표적인 예시가 Next.js ~12의 getServerSideProps가 있겠네요.)

  1. 서버에서 전체 앱의 데이터를 받아옵니다.
  2. 서버에서 전체 앱의 HTML을 렌더링하고 이를 클라이언트에서 받습니다.
  3. 클라이언트에서 자바스크립트 코드를 받아옵니다.
  4. 클라이언트에서, 자바스크립트와 서버에서 만들어진 HTML 사이의 로직을 연결하는 작업인 Hydration을 수행합니다.

만약 앱의 아주 작은 컴포넌트에서 병목이 발생하면, 앱 전체에도 그 영향이 미치게 됩니다. 병목은 여러 방향으로 나타날 수 있습니다. 데이터 로드가 오래 걸리거나, Hydration이 오래 걸릴 수도 있습니다.

Suspense는 전체 앱을 더 작은 독립적인 유닛으로 쪼개어 위 과정들을 수행할 수 있게 해줍니다. 이러한 논의는 React Server Component(RSC)로도 이어지게 됩니다.

--

--

Jun Song
Jun Song

Written by Jun Song

Frontend Developer @Superblock

No responses yet