전체 글 보기

React Suspense의 등장 배경과 사용법 알아 보기

profile icon

SSR의 구조적 한계에서 출발해 Suspense가 해결하는 문제, 실제 사용법, 그리고 Suspense가 활성화되는 조건과 활성화되지 않는 경우까지 자세히 알아본다

#React
마지막 수정일:

React 18에서 도입된 Suspense는 단순한 로딩 UI 도구가 아니다. SSR의 구조적 한계를 해결하기 위해 등장했으며, 데이터 페칭과 하이드레이션 방식 자체를 바꾼다. 이 글에서는 Suspense가 왜 필요한지, 어떻게 동작하는지, 그리고 실제로 Suspense가 활성화되는 조건과 활성화되지 않는 경우를 함께 살펴본다.

서버 사이드 렌더링(Server Side Rendering, SSR)은 초기 로딩 성능과 SEO 측면에서 큰 장점을 제공하지만, 모든 과정이 '직렬적'으로 이루어져야 한다는 구조적인 한계를 가지고 있다.

SSR의 가장 큰 문제점 중 하나는 서버에서 모든 데이터를 한번에 가져와야 한다는 것이다. 컴포넌트 트리를 HTML로 렌더링하기 전에, 해당 페이지에 필요한 모든 데이터가 준비되어 있어야 한다.

예를 들어, 다음과 같이 구성되어 있는 블로그 포스트 페이지를 생각해보자.

만약 댓글 데이터를 가져오는데 시간이 오래 걸린다면, 프론트엔드 개발자는 다음 중 하나를 선택해야 한다.

  1. 댓글을 서버 렌더링에서 제외하기
    • 장점: 다른 컴포넌트들을 빠르게 보여줄 수 있음
    • 단점: 사용자는 JavaScript가 로드될 때까지 댓글을 볼 수 없음
  2. 댓글을 포함해서 서버 렌더링하기
    • 장점: 댓글이 초기 HTML에 포함됨
    • 단점: 전체 페이지의 렌더링이 댓글 데이터를 기다리느라 지연됨

SSR의 두 번째 큰 문제는 하이드레이션(hydration) 과정과 관련이 있다. React는 서버에서 생성된 HTML과 클라이언트의 컴포넌트 트리를 매칭하기 위해, 모든 컴포넌트의 JavaScript 코드가 로드될 때까지 기다려야 한다.

또한, React의 하이드레이션 과정이 가진 문제가 있다. 현재 React는 컴포넌트 트리 전체를 한번에 하이드레이션하고, 이는 다음과 같은 문제를 야기한다.

이러한 문제들의 근본 원인은 SSR이 다음과 같은 순차적인 "폭포수" 프로세스를 따르기 때문이다.

  1. 데이터 페칭 (서버)
  2. HTML 렌더링 (서버)
  3. JavaScript 코드 로딩 (클라이언트)
  4. 하이드레이션 (클라이언트)

각 단계는 이전 단계가 완전히 완료되어야만 시작될 수 있어, 전체적인 성능과 사용자 경험이 저하된다.

이러한 문제들을 해결하기 위해 React 18에서는 Suspense를 도입했다. Suspense를 사용하면 앱의 다른 부분들이 개별적으로 로드되고 하이드레이션될 수 있어, 위에서 언급한 문제들을 효과적으로 해결할 수 있다.

사용법은 간단하다. 데이터를 전부 불러오기를 기다리고 싶지 않은 컴포넌트를 <Suspense> 로 감싸고, fallback props에 로딩시 보여줄 컴포넌트를 넣어주면 된다.

tsx
function BlogPost() {
  return (
    <div>
      <Nav />
      <Sidebar />
      <Article />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </div>
  );
}

초기 HTML은 다음과 같이 그려진다.

html
<main>
  <!-- 생략... -->
  <section id="comments-spinner">
    <!-- Spinner -->
    <img width=400 src="spinner.gif" alt="Loading..." />
  </section>
</main>
ssr-initial-render-with-fallback.png

이후, 서버에서 댓글 컴포넌트를 위한 데이터가 준비되면 아래와 같은 과정을 거친다.

html
<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  document.getElementById('sections-spinner').replaceChildren( 
    document.getElementById('comments') 
  ); 
</script>
ssr-streaming-comments-loaded.png

이 방식 덕분에 모든 데이터를 가져올 때까지 기다리지 않아도 화면에 콘텐츠를 표시할 수 있다.

초기 HTML은 생성했지만, 여전히 JavaScript를 전부 불러와야 하이드레이션을 시작할 수 있다. lazy를 통한 코드 분할(code splitting)로 하이드레이션 과정도 분리할 수 있다.

tsx
const Comments = lazy(() => import('./Comments'));

function BlogPost() {
  return (
    <div>
      <Nav />
      <Article />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </div>
  );
}
lazy-hydration-partial.png

댓글 섹션을 제외한 나머지 JavaScript 로드가 끝난 부분부터 하이드레이션을 진행한다. 이후 댓글 컴포넌트의 JavaScript까지 전부 로드되면 댓글도 하이드레이션된다.

lazy-hydration-complete.png

지금 까지의 과정을 한번에 보면 다음과 같다.

suspense-process-overview.gif

모든 데이터 로딩이 Suspense를 유발하지는 않는다. React가 컴포넌트의 "지연 상태"를 인식할 수 있는 특정 패턴이 필요하다.

  1. use Hook을 사용한 데이터 페칭

    tsx
    import { use } from 'react';
    
    // 데이터를 가져오는 함수
    function fetchUserData(userId) {
      return fetch(`/api/users/${userId}`)
        .then(res => res.json());
    }
    
    function UserProfile({ userId }) {
      const user = use(fetchUserData(userId)); 
      return (
        <div>
          <h1>{user.name}</h1>
          <p>{user.bio}</p>
        </div>
      );
    }
    
    // Suspense로 래핑
    function App() {
      return (
        <Suspense fallback={<Loading />}>
          <UserProfile userId={1} />
        </Suspense>
      );
    }
  2. lazy 컴포넌트

    tsx
    import { lazy, Suspense } from 'react';
    
    // 동적으로 로드되는 컴포넌트
    const HeavyComponent = lazy(() => import('./HeavyComponent')); 
    
    function App() {
      return (
        <div>
          <h1>My App</h1>
          <Suspense fallback={<Loading />}>
            <HeavyComponent />
          </Suspense>
        </div>
      );
    }
  3. 서버 컴포넌트의 async / await

    tsx
    // 서버 컴포넌트
    async function BlogPost({ id }) { 
      const post = await fetchBlogPost(id); 
      return (
        <article>
          <h1>{post.title}</h1>
          <p>{post.content}</p>
        </article>
      );
    }
    
    function BlogPage({ id }) {
      return (
        <Suspense fallback={<LoadingPost />}>
          <BlogPost id={id} />
        </Suspense>
      );
    }
  4. useDeferredValue 사용

    tsx
    function SearchResults({ query }) {
      // 검색어 변경을 지연시켜 처리
      const deferredQuery = useDeferredValue(query); 
    
      return (
        <Suspense fallback={<Loading />}>
          <SearchResultsList query={deferredQuery} />
        </Suspense>
      );
    }

다음과 같은 일반적인 데이터 페칭 패턴들은 Suspense를 발동시키지 않는다.

  1. useEffect 내부에서의 데이터 패칭

    tsx
    // ❌ Suspense를 발동시키지 않음
    function UserProfile({ userId }) {
      const [user, setUser] = useState(null);
    
      useEffect(() => {
        fetchUserData(userId).then(setUser);
      }, [userId]);
    
      if (!user) return <Loading />;
      return <h1>{user.name}</h1>;
    }
  2. 일반적인 Promise 처리

    tsx
    // ❌ Suspense를 발동시키지 않음
    function UserProfile({ userId }) {
      const [user, setUser] = useState(null);
      const [error, setError] = useState(null);
    
      Promise.resolve(fetchUserData(userId))
        .then(setUser)
        .catch(setError);
    
      if (error) return <Error error={error} />;
      if (!user) return <Loading />;
      return <h1>{user.name}</h1>;
    }

기존에 사용하던 useQuery 대신, 반드시 Suspense 전용 훅인 useSuspenseQuery를 사용해야 의도한 대로 동작한다.


참고
전체 글 보기