<Suspense>

우아하게 비동기 처리하기

2023-11-16
suspensecomponent

Suspense란?

React Suspense는 비동기 작업의 선언적 처리를 도와주는 컴포넌트다. 자식 컴포넌트의 렌더링이 준비되기 전까지 fallback을 렌더링하며 기다릴 수 있도록 도와준다.

더 고차원적으로 Suspense는 개발자 혹은 데이터 프레임워크가 비동기 데이터를 처리하는 동안 UI 라이브러리인 리액트와 소통하는 하나의 메커니즘이다.

어떤 문제를 해결하는가?

명령적인 비동기 처리

비동기 데이터를 불러올 때는 1) 로딩중 2) 에러 3) 성공 세 가지 경우에 대응해야 한다. Suspense를 사용하기 이전에는 각 상태를 컴포넌트 내에서 명령적으로 처리해주어야 했다. 아래 예제에서 Albums 컴포넌트는 getAlbums() 함수를 통해 데이터를 비동기적으로 불러온다. 그리고 컴포넌트 내에 로딩, 에러, 성공 각 상태에 대한 처리가 명시되어 있다.

Albums.js
export default function Albums({ artistId }) {
  const [albums, setAlbums] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    getAlbums(artistId)
      .then((l) => {
        setAlbums(l);
        setIsLoading(false);
      })
      .catch((e) => {
        setError(e);
        setIsLoading(false);
      });
  }, []);

  if (isLoading) return <Loading />;
  if (error) return <ErrorFallback />;

  return (
    <ul>
      {albums.map((album) => (
        <li key={album.id}>
          {album.title} ({album.year})
        </li>
      ))}
    </ul>
  );
}
ArtistPage.js
export default function ArtistPage({ artist }) {
  return (
    <>
      <h1>{artist.name}</h1>
      <Albums artistId={artist.id} />
    </>
  );
}

Suspense를 이용해 선언적인 방법으로 다시 작성해보자.

Albums 컴포넌트를 Suspense와 ErrorBoundary로 감싸면 Albums 컴포넌트는 View에 집중하고, 로딩과 에러에 대한 처리를 상위 컴포넌트인 Suspense와 ErrorBoundary에 위임할 수 있다(ErrorBoundary는 추후 포스팅에서 다루어보자). 컴포넌트는 어떤 데이터를 사용할지 '선언'하고, 이에 따른 View를 담당한다는 것이 더 명확해졌다.

Albums.js
export default function Albums({ artistId }) {
  //albums를 사용하겠다고 선언한다. 로딩과 에러에 대한 처리는 상위 컴포넌트로 위임한다.
  const albums = use(fetchData(`/${artistId}/albums`));

  return (
    <ul>
      {albums.map(album => (
        <li key={album.id}>
          {album.title} ({album.year})
        </li>
      ))}
    </ul>
  );
}
ArtistPage.js
export default function ArtistPage({ artist }) {
  return (
    <>
      <h1>{artist.name}</h1>
      <ErrorBoundary>
        <Suspense fallback={<Loading />}>
          <Albums artistId={artist.id} />
        </Suspense>
      </ErrorBoundary>
    </>
  );
}

Waterfall 방식의 렌더링

웹 개발자가 해결해야 하는 가장 중요한 문제 중 하나는, 앱이 여러 비동기 작업을 처리하면서도 최고의 사용자 경험을 제공하는 것이다.

React 팀의 리서치 결과 사용자는 앱의 첫 실행시간 뿐만 아니라 앱 사용중 로딩 시간과 경험에도 매우 민감하다고 한다. 사용자는 무의식적으로 A -> B의 작업이 시간이 소요된다는 것을 인지한다. 그리고 대부분의 앱에서 로딩 인디케이터로 A -> B 작업이 시작했음을 알려준다. 하지만 A -> B의 작업이 시작했다는 반응이 없으면 사용자는 앱이 어딘가 고장났다는 느낌을 받는다.

Suspense를 사용하기 전에는 런타임에 이루어지는 비동기 작업이 컴포넌트의 렌더링(A -> B 작업)을 막는 경우가 있었다. 컴포넌트의 effect에서 비동기 작업을 처리하기 때문에 비동기 작업이 완료되기 전에는 다른 컴포넌트의 렌더링이 시작되지도 않기 때문이다. Fetch-on-render 방식이 대표적이다.

Fetch-on-render

아래 예제에서, ProfileTimeLine 컴포넌트는 ProfilePage에 의존하는 데이터가 없음에도, ProfilePage의 fetching이 완료될 때까지 기다려야 한다. 이러한 방식을 fetch-on-render(waterfall) 방식이라고 한다.

function ProfilePage() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser().then((u) => setUser(u));
  }, []);

  if (user === null) {
    return <p>Loading profile...</p>;
  }
  return (
    <>
      <h1>{user.name}</h1>
      <ProfileTimeline />
    </>
  );
}

function ProfileTimeline() {
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    fetchPosts().then((p) => setPosts(p));
  }, []);

  if (posts === null) {
    return <h2>Loading posts...</h2>;
  }
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}
fetch-on-render

Fetch-then-render

Suspense 없이도 waterfall 방식을 해결하는 방법 중 하나는 모든 fetch를 한 번에 수행하고, 필요할 때마다 데이터를 가져와서 렌더링하는 방법인 fetch-then-render 방식이 있다. 하나의 주체가 모든 데이터를 fetch하고, 쿼리 형태로 불러오는 Relay 같은 라이브러리가 차용하는 방식이다.

function fetchProfileData() {
  return Promise.all([fetchUser(), fetchPosts()]).then(([user, posts]) => {
    return { user, posts };
  });
}
// Kick off fetching as early as possible
const promise = fetchProfileData();

function ProfilePage() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    promise.then((data) => {
      setUser(data.user);
      setPosts(data.posts);
    });
  }, []);

  if (user === null) {
    return <p>Loading profile...</p>;
  }
  return (
    <>
      <h1>{user.name}</h1>
      <ProfileTimeline posts={posts} />
    </>
  );
}

// The child doesn't trigger fetching anymore
function ProfileTimeline({ posts }) {
  if (posts === null) {
    return <h2>Loading posts...</h2>;
  }
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

하지만 이 마저도 모든 데이터를 불러온 뒤에야 첫 렌더링이 시작된다는 단점이 있다.

fetch-then-render

Render-as-you-fetch

Suspense를 활용한 render-as-you-fetch 방식은 각 fetch가 완료될 때 컴포넌트를 렌더링한다. 네트워크 요청은 병렬로 이루어지기 때문에 완료된 순서대로 컴포넌트가 렌더링 된다. 이 방법으로 waterfall 방식의 렌더링을 해결할 수 있다.

With Suspense, we don’t wait for the response to come back before we start rendering. In fact, we start rendering pretty much immediately after kicking off the network request:

// This is not a Promise. It's a special object from our Suspense integration.
const resource = fetchProfileData();

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

function ProfileDetails() {
  // Try to read user info, although it might not have loaded yet
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

function ProfileTimeline() {
  // Try to read posts, although they might not have loaded yet
  const posts = resource.posts.read();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}
render-as-you-fetch

Code splitting

Suspense는 data fetching 외에도 React.lazy()를 활용한 code splitting에 사용될 수 있다. 사실 data fetching보다 먼저 사용된 곳이 code splitting이었다.

import { Suspense, lazy } from "react";
import Loading from "./Loading.js";

const MarkdownPreview = lazy(() => import("./MarkdownPreview.js"));

// ...
<Suspense fallback={<Loading />}>
  <h2>Preview</h2>
  <MarkdownPreview />
</Suspense>;
// ...

Suspense 적용하기

Suspense가 어떤 문제를 해결하는지 어느정도 이해되었다면 이를 프로젝트에 적용하는 방법을 알아보자.

Code splitting

Suspense를 활용한 code splitting은 조건부로 컴포넌트를 렌더링하는 경우 사용하기 적합하다. 특정 이벤트에 따라 렌더링되는 모달 등의 컴포넌트가 이에 해당한다. React 공식 문서에 있는 예제도 조건부로 렌더링하는 경우를 다루고 있다. Next.js로 개발하는 경우 next/dynamic을 사용할 수 있다.

import { Suspense, lazy, useState } from "react";
import Loading from "./Loading.js";

const MarkdownPreview = lazy(() =>
  delayForDemo(import("./MarkdownPreview.js")),
);

export default function MarkdownEditor() {
  const [showPreview, setShowPreview] = useState(false);
  const [markdown, setMarkdown] = useState("Hello, **world**!");
  return (
    <>
      <textarea
        value={markdown}
        onChange={(e) => setMarkdown(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={showPreview}
          onChange={(e) => setShowPreview(e.target.checked)}
        />
        Show preview
      </label>
      <hr />
      {showPreview && (
        <Suspense fallback={<Loading />}>
          <h2>Preview</h2>
          <MarkdownPreview markdown={markdown} />
        </Suspense>
      )}
    </>
  );
}

With data libraries

Relay, react-query, SWR 등의 데이터 라이브러리는 Suspense를 지원한다. 옵션에서 suspense: true를 설정해주고, 비동기 데이터를 불러오는 컴포넌트를 Suspense로 감싸면 된다. react-query의 경우를 살펴보자.

아래와 같이 전체 적용할 수도 있고,

// Configure for all queries
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true,
    },
  },
});

function Root() {
  return (
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  );
}

개별 쿼리에도 설정할 수 있다.

import { useQuery } from "@tanstack/react-query";

// Enable for an individual query
useQuery({ queryKey, queryFn, suspense: true });

useSuspenseQuery()를 사용할 수도 있는데, react-query 공식 문서의 예제에서는 컴포넌트 내에서 useSuspenseQuery()로 데이터를 불러온다.

Project.jsx
import React from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'

import Button from './Button'
import Spinner from './Spinner'

import { fetchProject } from '../queries'

export default function Project({ activeProject, setActiveProject }) {
  const { data, isFetching } = useSuspenseQuery({
    queryKey: ['project', activeProject],
    queryFn: () => fetchProject(activeProject),
  })

  return (
    <div>
      <Button onClick={() => setActiveProject(null)}>Back</Button>
      <h1>
        {activeProject} {isFetching ? <Spinner /> : null}
      </h1>
      {data ? (
        <div>
          <p>forks: {data.forks_count}</p>
          <p>stars: {data.stargazers_count}</p>
          <p>watchers: {data.watchers}</p>
        </div>
      ) : null}
      <br />
      <br />
    </div>
  )
}

그리고 컴포넌트를 Suspense로 감싸준다.

index.jsx
// ...
<React.Suspense fallback={<h1>Loading projects...</h1>}>
  {showProjects ? (
    activeProject ? (
      <Project
        activeProject={activeProject}
        setActiveProject={setActiveProject}
      />
    ) : (
      <Projects setActiveProject={setActiveProject} />
    )
  ) : null}
</React.Suspense>
// ...

다만 데이터 라이브러리와 Suspense를 사용할 때 각 라이브러리의 동작 방식을 잘 확인해보고 사용하자. 잘 이해하고 사용하지 않으면 Suspense로 해결하려 했던 waterfall 문제를 해결하지 못한다. 이 블로그 글을 참고해보자.

With SSR (no streaming)

Suspense는 streaming이 enabled 되지 않은 SSR 환경에서 다루기 까다로웠다. 정확히 말하면 서버 환경에서는 Suspense를 사용할 수 없었다. Code splitting한 컴포넌트를 조건부로 Client에서 렌더링 할 수 있었지만, 데이터 fetching을 Suspense로 감싸도 서버에서 HTML을 렌더링 할 때 모든 fetch가 이루어지기 때문에 Suspense를 사용하지 못했다.

아래 예제를 실행해보면 서버에서 data를 모두 fetching한 뒤, 렌더링한다는 것을 알 수 있다. Suspense가 의도대로 동작하지 않는다. 하지만 이는 React 팀 블로그에서도 확인할 수 있는 내용이다.

However, the only supported use case was code splitting with React.lazy, and it wasn’t supported at all when rendering on the server. - React v18.0

Person.jsx
import { useSuspenseQuery } from "@tanstack/react-query";

export default function Person() {
  const { data } = useSuspenseQuery({
    queryKey: ["person"],
    queryFn: fetchDummy,
  });

  console.log("render");
  return (
    <>
      <h1>Hello this {data?.name}</h1>
    </>
  );
}

async function fetchDummy() {
  await new Promise((resolve) => {
    setTimeout(resolve, 3000);
  });
  const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
  return await res.json();
}

index.jsx
import { Suspense, lazy } from "react";

const Person = lazy(() => import("../../components/person"));

export default function ProfilePage() {
  return (
    <Suspense fallback={<div>Loading person...</div>}>
      <Person />
    </Suspense>
  );
}

이를 해결하기 위해서는 브라우저 환경인지 서버 환경인지에 따라 별도로 처리해주는 커스텀 컴포넌트를 만들어야 한다. 이 컴포넌트로 Suspense 내부 컴포넌트를 브라우저에서 렌더링 되도록 처리하는 것이다. jbee님의 글에서 Suspense와 ErrorBoundary를 활용한 컴포넌트를 참고할 수 있다.

With SSR (streaming)

개인적으로 Suspense의 활용도와 이점을 끌어올려준 것이 streaming이라고 생각한다. New Suspense SSR Architecture in React 18에서 Suspense와 renderToPipeableStream()을 활용한 진일보한 설계 패턴을 설명한다. React conf 2021에도 이 주제에 대한 좋은 keynote가 있다.

요약하자면, 기존 SSR에서의 문제는

  1. 렌더링 하기 위해 서버에서 모든 데이터를 불러와야 한다.
  2. 서버에서 보낸 HTML을 클라이언트에서 hydrate 하기 위해 모든 리소스를 불러와야 한다.
  3. 사용자 인터랙션을 허용하기 위해 모든 hydrate이 완료되어야 한다.
  4. 각 과정은 순차적으로 실행되며, 다음 과정을 blocking 한다.

이 결과로 TTFB, TTI, FCP가 전반적으로 느려져 사용자 경험을 나쁘게 만든다.

이 문제를 Suspense와 함께 아래 방법으로 해결한다.

  1. Streaming Server rendering
    • 서버에서 HTML을 점진적으로 streaming한다.
    • Suspense fallback을 먼저 응답으로 보내고, 데이터를 모두 fetching하면 컴포넌트를 inline script와 함께 마저 전달한다.
    • 이로 인해 서버에서 모든 데이터를 불러오기 전에도 HTML을 클라이언트로 전송할 수 있다. (TTFB, FCP가 빨라진다)
  2. Selective Hydration
    • 사용자 인터랙션이 중요한 컴포넌트부터 먼저 hydrate 한다.
    • 만약 아직 로드되지 않은 컴포넌트가 있어도 로드되는 것을 기다리지 않는다.
    • 이로 인해 hydrate을 더 빨리 시작할 수 있다. (TTI가 빨라진다)

아래 예제에서 PostFeed 혹은 Weather 컴포넌트가 원격 데이터를 의존하고 있어도 Posts 컴포넌트와 Suspense fallback이 먼저 클라이언트로 전달된다. 원격 데이터를 의존하는 두 컴포넌트를 제외한 컴포넌트는 hydration을 시작하고, 모든 데이터가 준비되었을 때 두 컴포넌트 또한 렌더/hydrate된다.

import { Suspense } from "react";
import { PostFeed, Weather } from "./Components";

export default function Posts() {
  return (
    <section>
      <Suspense fallback={<p>Loading feed...</p>}>
        <PostFeed />
      </Suspense>
      <Suspense fallback={<p>Loading weather...</p>}>
        <Weather />
      </Suspense>
    </section>
  );
}

Next.js App router 혹은 streaming을 지원하는 프레임워크를 사용하면 위와 같이 Suspense를 이용해 streaming 가능한 SSR을 구현할 수 있다. 이 설계 패턴에 대해 더 자세히 알고 싶다면 New Suspense SSR Architecture in React 18을, Next.js app router와 streaming에 대해 더 알고 싶다면 Next.js 공식 문서 What is Streaming?을 확인해보자.

맺으며

React core team이 생각하는 Suspense의 최종 비전은 모든 비동기 작업의 선언적 처리를 도와주는 것이다.

As in previous versions of React, you can also use Suspense for code splitting on the client with React.lazy. But our vision for Suspense has always been about much more than loading code — the goal is to extend support for Suspense so that eventually, the same declarative Suspense fallback can handle any asynchronous operation (loading code, data, images, etc).

React 팀과 긴밀하게 협업한 Next.js 팀은 App router에서 streaming과 Suspense를 적극 활용하며 더 나은 사용자 경험에 대한 옵션을 제공하고 있으며, 앞으로 React 생태계 내 많은 라이브러리와 프레임워크가 이에 동참할 것으로 보인다.

하지만 실제 프로젝트에 적용할 지는 개발자의 선택이다. 비슷한 비동기 작업을 처리하기 위한 코드를 반복적으로 작성하고 있다면, React 컴포넌트 내 데이터 의존성이 렌더링의 병목이 되고 있어 이를 해결하기 위한 방법을 찾고 있다면 Suspense가 하나의 선택지가 될 것이다.

참고자료

<Suspense>
Next.js conf
React v18.0
Suspense rfc
Behavior change of suspense
React.lazy rfc
Conceptual Model of React Suspense
NextJs에 React18 Suspense 적용하기 (with react-query 적용)
React에서 선언적으로 비동기 다루기
React 18 Suspense — 실전 (절망편)
Streaming Server Rendering with Suspense
Loading UI and Streaming


Related Posts

react

서버에서 실행되는 React 컴포넌트

Server Components
2023/12/03

Server Components