React

Suspense 알아보기

한우코딩 2025. 3. 24. 14:56

React Suspense 분석하기

React로 개발할 때 가장 까다로운 작업 중 하나는 비동기 데이터를 로딩하는 동안 사용자에게 적절한 로딩 상태를 표시하는 것입니다. 이를 위해 React 팀은 Suspense라는 강력한 기능을 도입했습니다. 이 글에서는 Suspense가 무엇인지, 실제 애플리케이션에서 어떻게 활용할 수 있는지, 그리고 내부적으로 어떻게 동작하는지 살펴보겠습니다.

목차

  1. Suspense 소개
  2. React Query와 함께 사용하기
  3. SWR과 함께 사용하기
  4. useTransition과 함께 활용하기
  5. Suspense의 내부 동작 원리
  6. Suspense의 고급 패턴
  7. 마무리

Suspense 소개

Suspense는 컴포넌트가 렌더링되기 전에 무언가를 "기다릴" 수 있게 해주는 React의 기능입니다. 주로 두 가지 싱황에서 사용됩니다

  1. 데이터 페칭: API에서 데이터를 가져올 때
  2. 코드 스플리팅: React.lazy()로 컴포넌트를 동적으로 로드할 때

기본적인 Suspense 사용법은 다음과 같습니다:

import { Suspense } from 'react';

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

여기서 fallback prop은 자식 컴포넌트가 로딩 중일 때 보여줄 UI입니다. 하지만 실제 애플리케이션에서는 어떻게 Suspense를 활용할 수 있을까요? 인기 있는 데이터 페칭 라이브러리들과 함께 사용하는 방법을 알아보겠습니다.

React Query와 함께 사용하기

React Query는 데이터 페칭, 캐싱, 동기화를 위한 강력한 라이브러리로, Suspense와 통합하여 사용하기 매우 좋습니다.

import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { Suspense } from 'react';

// React Query 클라이언트 설정
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true, // Suspense 모드 활성화
    },
  },
});

// 앱 컴포넌트
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="app">
        <h1>사용자 대시보드</h1>
        <Suspense fallback={<div className="loading-spinner">데이터 로딩 중...</div>}>
          <UserProfile userId="1" />
        </Suspense>
      </div>
    </QueryClientProvider>
  );
}

// 사용자 프로필 컴포넌트
function UserProfile({ userId }) {
  // useQuery에서 에러 핸들링은 제거해도 됩니다(에러 바운더리로 처리 가능)
  const { data } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
  });

  return (
    <div className="profile-card">
      <img src={data.avatarUrl} alt={data.name} className="avatar" />
      <h2>{data.name}</h2>
      <p>{data.email}</p>
      <div className="stats">
        <div>팔로워: {data.followers}</div>
        <div>팔로잉: {data.following}</div>
      </div>
    </div>
  );
}

이 예제에서는 useQuerysuspense: true 옵션이 활성화되어 있어(QueryClient 기본 설정에서), 데이터가 로딩되는 동안 Suspense의 fallback이 표시됩니다. 데이터가 준비되면 React는 자동으로 UserProfile 컴포넌트를 렌더링합니다.

에러 처리 추가하기

Suspense는 로딩 상태만 처리하기 때문에, 에러 처리는 ErrorBoundary와 함께 사용해야 합니다

import { ErrorBoundary } from 'react-error-boundary';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="app">
        <h1>사용자 대시보드</h1>
        <ErrorBoundary
          fallback={<div className="error-message">사용자 정보를 불러오는 중 오류가 발생했습니다.</div>}
        >
          <Suspense fallback={<div className="loading-spinner">데이터 로딩 중...</div>}>
            <UserProfile userId="1" />
          </Suspense>
        </ErrorBoundary>
      </div>
    </QueryClientProvider>
  );
}

SWR과 함께 사용하기

SWR은 Vercel에서 만든 데이터 페칭 라이브러리로, "stale-while-revalidate" 전략을 사용합니다. SWR도 Suspense와 쉽게 통합됩니다

import useSWR from 'swr';
import { Suspense } from 'react';

// 데이터 페칭 함수
const fetcher = (...args) => fetch(...args).then(res => res.json());

function App() {
  return (
    <div className="blog-app">
      <header>
        <h1>내 블로그</h1>
      </header>
      <main>
        <Suspense fallback={<div className="skeleton-list">게시물 로딩 중...</div>}>
          <PostList />
        </Suspense>
      </main>
    </div>
  );
}

function PostList() {
  // suspense 옵션을 true로 설정
  const { data } = useSWR('/api/posts', fetcher, { suspense: true });

  return (
    <div className="post-grid">
      {data.map(post => (
        <article key={post.id} className="post-card">
          <img src={post.coverImage} alt={post.title} />
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
          <div className="post-meta">
            <span>작성자: {post.author}</span>
            <span>작성일: {new Date(post.date).toLocaleDateString()}</span>
          </div>
        </article>
      ))}
    </div>
  );
}

SWR을 사용하면 캐시된 데이터가 있을 경우 즉시 표시하고, 백그라운드에서 최신 데이터를 가져오는 방식으로 동작합니다. 이는 사용자 경험을 크게 향상시킵니다.

useTransition과 함께 활용하기

React 18에서는 useTransition 훅이 도입되었는데, 이를 Suspense와 결합하면 더 나은 사용자 경험을 제공할 수 있습니다

import { useState, useTransition, Suspense } from 'react';
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';

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

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

function ProductSearch() {
  const [searchTerm, setSearchTerm] = useState('');
  const [isPending, startTransition] = useTransition();
  const [currentSearchTerm, setCurrentSearchTerm] = useState('');

  const handleSearch = (e) => {
    // 입력값은 즉시 업데이트 (사용자 입력에 즉시 반응)
    setSearchTerm(e.target.value);

    // 검색 결과 업데이트는 트랜지션으로 처리 (우선순위 낮춤)
    startTransition(() => {
      setCurrentSearchTerm(e.target.value);
    });
  };

  return (
    <div className="product-search">
      <div className="search-bar">
        <input
          type="text"
          value={searchTerm}
          onChange={handleSearch}
          placeholder="제품 검색..."
          className="search-input"
        />
        {isPending && <span className="loading-indicator">검색 중...</span>}
      </div>

      <div className="results-container">
        {currentSearchTerm ? (
          <Suspense fallback={<ProductGridSkeleton />}>
            <SearchResults searchTerm={currentSearchTerm} />
          </Suspense>
        ) : (
          <div className="empty-message">검색어를 입력하세요</div>
        )}
      </div>
    </div>
  );
}

function SearchResults({ searchTerm }) {
  const { data } = useQuery({
    queryKey: ['products', searchTerm],
    queryFn: () => fetch(`/api/products?q=${searchTerm}`).then(res => res.json())
  });

  if (data.length === 0) {
    return <div className="no-results">검색 결과가 없습니다</div>;
  }

  return (
    <div className="product-grid">
      {data.map(product => (
        <div key={product.id} className="product-card">
          <img src={product.image} alt={product.name} />
          <h3>{product.name}</h3>
          <p className="price">{product.price.toLocaleString()}원</p>
          <button className="cart-button">장바구니에 추가</button>
        </div>
      ))}
    </div>
  );
}

function ProductGridSkeleton() {
  // 제품 그리드 로딩 UI
  return (
    <div className="product-grid skeleton">
      {Array.from({ length: 8 }).map((_, i) => (
        <div key={i} className="product-card skeleton">
          <div className="image-skeleton"></div>
          <div className="title-skeleton"></div>
          <div className="price-skeleton"></div>
          <div className="button-skeleton"></div>
        </div>
      ))}
    </div>
  );
}

이 예제에서 useTransition은 두 가지 중요한 이점을 제공합니다:

  1. 사용자 입력은 즉시 반영되어 UI가 반응적으로 느껴짐
  2. 데이터 로딩은 낮은 우선순위로 처리되며, 로딩 중임을 표시하는 인디케이터 제공

이렇게 하면 사용자는 검색창에 입력할 때 화면이 멈추거나 버벅이는 느낌 없이 부드러운 경험을 할 수 있습니다.

Suspense의 내부 동작 원리

Suspense가 실용적인 측면에서 어떻게 사용되는지 살펴보았으니, 이제 내부적으로 어떻게 동작하는지 알아보겠습니다. Suspense의 작동 방식을 이해하면 더 효과적으로 활용할 수 있습니다.

1. 기본 메커니즘

Suspense는 React의 렌더링 과정과 밀접하게 통합되어 있습니다. 기본적인 동작은 다음과 같습니다:

  1. React가 컴포넌트 트리를 렌더링
  2. 특정 컴포넌트가 아직 준비되지 않은 데이터에 접근하려고 함
  3. 데이터 페칭 라이브러리(React Query, SWR 등)가 내부적으로 Promise를 throw
  4. React는 이 Promise를 포착하고 가장 가까운 Suspense 경계를 찾음
  5. Suspense 경계의 fallback이 렌더링됨
  6. Promise가 해결되면 React는 원래 컴포넌트를 다시 렌더링 시도

2. Suspense 컴포넌트의 렌더링 과정

Suspense 컴포넌트는 React의 reconciliation(재조정) 과정에서 특별하게 처리됩니다. 이 처리는 updateSuspenseComponent 함수에서 이루어집니다.
Suspense 컴포넌트 렌더링의 주요 단계는 다음과 같습니다

  1. 초기 마운트 시
  • 자식 컴포넌트(primary children)와 대체 UI(fallback)를 처리합니다.
  • 대체 UI를 보여줘야 하는지 여부를 결정합니다(showFallback 변수).
  • showFallback이 true라면 mountSuspenseFallbackChildren 함수를 호출하여 대체 UI를 마운트합니다.
  • 그렇지 않다면 mountSuspensePrimaryChildren 함수를 호출하여 일반 자식들을 마운트합니다.
  1. 업데이트 시

현재 상태와 다음 상태를 비교하여 네 가지 경우를 처리합니다

이전: fallback → 이후: fallback
이전: fallback → 이후: 콘텐츠
이전: 콘텐츠 → 이후: fallback
이전: 콘텐츠 → 이후: 콘텐츠

3. Offscreen 컴포넌트와 상태 유지

흥미로운 점은 Suspense가 실제 내용을 완전히 언마운트하지 않고 "숨긴다"는 것입니다. React는 내부적으로 Offscreen이라는 특별한 컴포넌트를 사용합니다

// 추상화된 내부 구현
<Suspense fallback={<Loading />}>
  <Content />
</Suspense>

// 실제 Fiber 트리 구성
<Suspense>
  <Offscreen mode="hidden">
    <Content />
  </Offscreen>
  <Fragment>
    <Loading />
  </Fragment>
</Suspense>

이 방식의 장점은

  • 컴포넌트 상태가 보존됨
  • 효과(effects)가 불필요하게 정리되고 다시 실행되는 것을 방지
  • 데이터가 준비되었을 때 더 빠르게 전환 가능

4. Promise 처리 메커니즘

Promise가 throw되면 React는 다음과 같은 단계로 처리합니다:

  1. handleError 함수가 호출되어 에러(이 경우 Promise)를 처리
  2. throwException 함수가 호출되어 컴포넌트 트리를 거슬러 올라가며 가장 가까운 Suspense 경계를 찾음
  3. 찾은 Suspense에 ShouldCapture 플래그 설정
  4. completeUnitOfWorkunwindWork 함수를 통해 작업 완료 및 정리
  5. ShouldCapture 플래그가 DidCapture로 변환
  6. Suspense 컴포넌트가 다시 렌더링되어 fallback 표시

이 모든 과정은 React 내부에서 자동으로 처리되므로, 개발자는 복잡한 내부 메커니즘을 걱정할 필요 없이 Suspense API를 사용할 수 있습니다.

Suspense의 동작 원리 이해하기

지금까지 살펴본 내용을 바탕으로 Suspense의 동작 원리를 정리해보겠습니다

  1. 선언적 로딩 상태: Suspense는 로딩 상태를 선언적으로 정의할 수 있게 해줍니다. 개발자는 "이 컴포넌트가 로딩 중일 때 이것을 보여줘"라고 간단히 지정할 수 있습니다.
  2. 렌더링 일시 중단: Suspense는 렌더링을 일시 중단하고 나중에 다시 시도하는 메커니즘을 제공합니다. 이는 "컴포넌트가 준비될 때까지 기다린다"는 개념을 구현합니다.
  3. Promise 기반: Suspense는 Promise를 통해 작동합니다. 준비되지 않은 데이터에 접근하면 Promise를 throw하고, 이 Promise가 해결되면 렌더링을 재개합니다.
  4. 컴포넌트 상태 보존: Suspense는 Offscreen 컴포넌트를 사용하여 일시 중단된 컴포넌트의 상태를 보존합니다. 이는 로딩이 완료된 후 사용자 경험을 개선합니다.
  5. 리액트의 이벤트 루프와 통합: Suspense는 React의 reconciliation 알고리즘과 긴밀하게 통합되어 있어, 효율적인 업데이트를 가능하게 합니다.

Suspense의 고급 패턴

Suspense를 효과적으로 활용하기 위한 고급 패턴을 살펴보겠습니다.

1. 중첩된 Suspense 사용하기

여러 비동기 데이터를 처리할 때 중첩된 Suspense를 사용하면 더 세밀한 로딩 상태를 제공할 수 있습니다:

function ProfilePage({ userId }) {
  return (
    <Suspense fallback={<FullPageSpinner />}>
      <ProfileHeader userId={userId} />

      <div className="profile-content">
        <div className="main-content">
          <Suspense fallback={<PostsSkeleton />}>
            <UserPosts userId={userId} />
          </Suspense>
        </div>

        <aside className="sidebar">
          <Suspense fallback={<FriendsSkeleton />}>
            <UserFriends userId={userId} />
          </Suspense>

          <Suspense fallback={<GroupsSkeleton />}>
            <UserGroups userId={userId} />
          </Suspense>
        </aside>
      </div>
    </Suspense>
  );
}

이 패턴은 "Waterfall" 문제를 방지하면서도 페이지의 각 부분에 맞춤형 로딩 상태를 제공합니다.

2. SuspenseList (실험적 기능)

여러 Suspense 컴포넌트의 표시 순서를 조정하고 싶다면 SuspenseList를 사용할 수 있습니다 (아직 실험적 기능)

import { SuspenseList } from 'react';

function NewsFeed() {
  return (
    <SuspenseList revealOrder="forwards" tail="collapsed">
      <Suspense fallback={<NewsSkeleton />}>
        <LatestNews />
      </Suspense>

      <Suspense fallback={<WeatherSkeleton />}>
        <WeatherWidget />
      </Suspense>

      <Suspense fallback={<StocksSkeleton />}>
        <StockTicker />
      </Suspense>
    </SuspenseList>
  );
}

revealOrder="forwards"는 컴포넌트를 위에서 아래 순서로 표시하며, tail="collapsed"는 아직 로딩 중인 항목에 대해 하나의 fallback만 표시합니다.

3. 서버 컴포넌트와 함께 사용하기

React 18과 함께 도입된 서버 컴포넌트는 Suspense와 특히 잘 작동합니다:

// ServerComponent.js - 서버에서 실행
export async function UserData({ userId }) {
  // 서버에서 직접 데이터 가져오기 (클라이언트 번들에 포함되지 않음)
  const userData = await fetchUserData(userId);

  return (
    <div className="user-data">
      <h2>{userData.name}</h2>
      <p>{userData.bio}</p>
    </div>
  );
}

// ClientComponent.jsx - 클라이언트에서 실행
'use client';
import { Suspense } from 'react';
import { UserData } from './ServerComponent';

export default function Profile({ userId }) {
  return (
    <div className="profile">
      <Suspense fallback={<div>사용자 데이터 로딩 중...</div>}>
        <UserData userId={userId} />
      </Suspense>
    </div>
  );
}

서버 컴포넌트는 데이터 페칭을 서버에서 직접 수행하므로, 클라이언트로 전송되는 JavaScript 번들 크기를 줄이고 초기 로딩 성능을 향상시킵니다.

마무리

Suspense는 React 애플리케이션에서 데이터 로딩을 우아하게 처리할 수 있는 강력한 도구입니다. 이 글에서 다룬 내용을 요약하면:

  1. Suspense 기본 사용법: 컴포넌트가 준비될 때까지 fallback UI를 표시
  2. 데이터 페칭 라이브러리와 통합: React Query, SWR 등과 함께 사용
  3. useTransition 활용: 중요한 UI 업데이트와 덜 중요한 데이터 로딩 분리
  4. 내부 동작 원리: Suspense가 어떻게 Promise를 처리하고 컴포넌트를 관리하는지
  5. 고급 패턴: 중첩된 Suspense, SuspenseList, 서버 컴포넌트 통합

실제 프로젝트에서 Suspense를 활용하여 사용자 경험을 향상시켜 보세요. 로딩 상태를 더 세련되게 처리하고, 애플리케이션의 응답성을 개선할 수 있습니다. Suspense는 단순한 API 뒤에 복잡한 비동기 로직을 숨겨, 개발자가 더 선언적이고 직관적인 코드를 작성할 수 있게 해줍니다.