react

React Suspense 완벽 가이드: 동작 원리부터 React 19까지

React Suspense의 등장 배경, Promise throw 메커니즘, 중첩 Boundary 동작, use Hook, 서버 컴포넌트 통합까지. React 19의 최신 변경사항을 포함한 Suspense 심화 가이드.

react suspense concurrent use-hook server-components streaming-ssr

들어가며

React를 사용하다 보면 데이터 로딩 중에 스피너를 보여주거나, 코드 분할로 컴포넌트를 지연 로딩하는 상황을 자주 마주한다. 이때 등장하는 것이 <Suspense>다.

하지만 “Suspense가 정확히 어떻게 동작하는 걸까?”, “중첩된 Suspense는 어떤 순서로 처리되는 걸까?”, “React 19에서 뭐가 달라졌는 걸까?” 같은 질문에 답하기는 쉽지 않다.

이 글에서는 Suspense의 등장 배경부터 내부 동작 원리, 그리고 React 19의 최신 변경사항까지 처음부터 끝까지 정리한다.

Suspense 이전의 세계

Suspense가 왜 필요한지 이해하려면, 그 전에 어떤 문제가 있었는지 알아야 한다.

fetch-on-render 패턴의 문제

가장 흔한 데이터 페칭 패턴을 보자.

const ProfilePage = () => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUser().then((data) => {
      setUser(data);
      setLoading(false);
    });
  }, []);

  if (loading) return <Spinner />;

  return (
    <div>
      <h1>{user.name}</h1>
      <Posts userId={user.id} />
    </div>
  );
};

이 패턴에는 몇 가지 문제가 있다.

Waterfall 문제

위 코드에서 Posts 컴포넌트도 내부에서 데이터를 페칭한다고 가정하면 어떻게 될까?

1. ProfilePage 마운트 → fetchUser() 시작
2. 2초 후 user 데이터 도착 → Posts 컴포넌트 마운트
3. Posts 마운트 → fetchPosts() 시작
4. 1초 후 posts 데이터 도착 → 최종 렌더링

총 소요 시간: 3초 (직렬 실행)

두 요청을 병렬로 보냈다면 2초면 끝날 것을, 컴포넌트 구조 때문에 3초가 걸린다. 이것이 Waterfall 문제다.

반복되는 보일러플레이트

모든 비동기 컴포넌트마다 같은 패턴이 반복된다.

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  fetchData()
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false));
}, []);

if (loading) return <Spinner />;
if (error) return <Error />;
return <Content data={data} />;

컴포넌트가 10개면 이 코드가 10번 반복된다. 로딩 상태 관리가 컴포넌트 내부에 흩어져 있어 일관성을 유지하기도 어렵다.

React 팀의 목표

React 팀은 이 문제들을 해결하기 위해 세 가지 목표를 세웠다.

  1. Render-as-you-fetch: 데이터 페칭과 렌더링을 동시에 시작
  2. 선언적 로딩 처리: 명령형 isLoading 상태 제거
  3. 일관된 UI: 부분적 로딩으로 인한 깜빡임 방지

Suspense란 무엇인가

Suspense는 컴포넌트가 렌더링되기 전에 **“아직 준비되지 않았다”**는 것을 React에 알려주는 메커니즘이다.

<Suspense fallback={<Spinner />}>
  <ProfileDetails />
</Suspense>

Props

Prop설명
children실제로 렌더링할 UI. 렌더링 중 suspend되면 fallback을 표시
fallbackchildren이 준비되지 않았을 때 표시할 대체 UI

영화관 비유

Suspense를 영화관에 비유하면 이해하기 쉽다.

관객(React)이 상영관에 들어갔는데 영화(데이터)가 아직 준비되지 않았다면, 일단 광고(fallback)를 보여준다. 영화가 준비되면 광고를 끊고 본 영화를 상영한다.

Suspense의 동작 원리

Suspense가 내부적으로 어떻게 동작하는지 알아보자.

Promise throw 메커니즘

Suspense의 핵심은 Promise를 throw하는 것이다. 일반적으로 throw는 에러를 던질 때 사용하지만, Suspense에서는 Promise를 던져서 “아직 준비 중”임을 알린다.

const cache = new Map();

const fetchData = (key: string) => {
  if (cache.has(key)) {
    const entry = cache.get(key);
    if (entry.status === 'resolved') return entry.data;
    if (entry.status === 'rejected') throw entry.error;
    throw entry.promise;
  }

  const promise = fetch(`/api/${key}`)
    .then((res) => res.json())
    .then((data) => {
      cache.set(key, { status: 'resolved', data });
    })
    .catch((error) => {
      cache.set(key, { status: 'rejected', error });
    });

  cache.set(key, { status: 'pending', promise });
  throw promise;
};

React의 처리 흐름

Promise가 throw되면 React는 다음과 같이 처리한다.

1. 컴포넌트 렌더링 중 Promise가 throw됨
2. React가 가장 가까운 Suspense boundary를 찾음
3. fallback UI를 렌더링
4. Promise가 resolve되면 해당 컴포넌트를 다시 렌더링 시도
5. 이번에는 캐시된 데이터가 있으므로 정상 렌더링

Fiber 아키텍처와의 관계

React 16에서 도입된 Fiber 아키텍처가 Suspense를 가능하게 한다.

Fiber Tree 구조:

    App (fiber)

   Suspense (fiber) ← Boundary 역할

   Profile (fiber) ← Promise throw 발생 시
     │                 이 fiber의 작업이 중단되고
   Posts (fiber)       Suspense fiber로 제어권 이동

Suspense가 동작하는 조건

모든 비동기 작업에 Suspense가 동작하는 것은 아니다.

동작하는 경우:

동작하지 않는 경우:

// ❌ 동작하지 않음 - useEffect는 렌더링 후 실행
useEffect(() => {
  fetchData().then(setData);
}, []);

// ✅ 동작함 - use Hook 사용
const data = use(dataPromise);

// ✅ 동작함 - React.lazy 사용
const LazyComponent = lazy(() => import('./Component'));

중첩 Suspense와 경계(Boundary)

Suspense는 중첩해서 사용할 수 있다. 이때 어떤 boundary가 fallback을 표시할지 이해하는 것이 중요하다.

가장 가까운 Boundary 규칙

suspend된 컴포넌트는 가장 가까운 Suspense boundary의 fallback을 표시한다.

<Suspense fallback={<PageSkeleton />}>      {/* 외부 boundary */}
  <Header />
  <Suspense fallback={<SidebarSkeleton />}> {/* 내부 boundary */}
    <Sidebar />                              {/* suspend → SidebarSkeleton */}
  </Suspense>
  <MainContent />                            {/* suspend → PageSkeleton */}
</Suspense>

All or Nothing

같은 Suspense boundary 안에 여러 컴포넌트가 있으면 어떻게 될까?

<Suspense fallback={<Loading />}>
  <ProfileDetails />   {/* 2초 소요 */}
  <ProfilePosts />     {/* 1초 소요 */}
  <ProfileFriends />   {/* 3초 소요 */}
</Suspense>

동작:

이것이 “All or Nothing” 방식이다. 부분적으로 콘텐츠를 보여주고 싶다면 Suspense를 분리해야 한다.

Progressive Loading 패턴

중첩 Suspense를 활용하면 점진적 로딩을 구현할 수 있다.

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

로딩 순서:

  1. 처음에는 BigSpinner 표시
  2. Biography 로드 완료 → Biography 표시 + AlbumsSkeleton 표시
  3. Albums 로드 완료 → Albums 표시

외부 콘텐츠가 먼저 나타나고, 내부 콘텐츠가 나중에 채워지는 자연스러운 UX를 제공한다.

Concurrent Features와 Suspense

React 18에서 도입된 Concurrent Features는 Suspense와 함께 사용할 때 진가를 발휘한다.

useTransition: 이전 UI 유지하기

기본적으로 컴포넌트가 suspend되면 즉시 fallback이 표시된다. 하지만 탭 전환처럼 이전 콘텐츠를 유지하고 싶을 때가 있다.

const TabContainer = () => {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();

  const selectTab = (nextTab: string) => {
    startTransition(() => {
      setTab(nextTab);
    });
  };

  return (
    <>
      <TabButtons onSelect={selectTab} isPending={isPending} />
      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        <Suspense fallback={<Spinner />}>
          {tab === 'home' && <HomeTab />}
          {tab === 'posts' && <PostsTab />}
        </Suspense>
      </div>
    </>
  );
};

useTransition의 효과:

useDeferredValue: 값의 지연 업데이트

검색 입력처럼 빠른 업데이트가 필요한 경우, useDeferredValue로 Suspense와 조합할 수 있다.

const SearchPage = () => {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="검색어 입력"
      />
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <Suspense fallback={<SearchSkeleton />}>
          <SearchResults query={deferredQuery} />
        </Suspense>
      </div>
    </>
  );
};

동작:

startTransition: Navigation 시 레이아웃 보호

페이지 전환 시 Suspense fallback이 전체 레이아웃을 가리는 것을 방지할 수 있다.

const Router = () => {
  const [page, setPage] = useState('/');

  const navigate = (url: string) => {
    startTransition(() => {
      setPage(url);
    });
  };

  return (
    <Layout>
      <Suspense fallback={<PageSkeleton />}>
        <Page url={page} />
      </Suspense>
    </Layout>
  );
};

startTransition 없이 setPage를 호출하면 새 페이지가 suspend될 때 Layout까지 사라진다. startTransition을 사용하면 이전 페이지를 유지하면서 새 페이지를 준비한다.

use Hook (React 19)

React 19에서 가장 중요한 추가 기능 중 하나가 use Hook이다.

기본 사용법

use는 Promise나 Context의 값을 읽을 수 있는 새로운 API다.

import { use } from 'react';

const Comments = ({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) => {
  const comments = use(commentsPromise);

  return (
    <ul>
      {comments.map((comment) => (
        <li key={comment.id}>{comment.text}</li>
      ))}
    </ul>
  );
};

다른 Hook과의 차이점

use는 다른 Hook과 달리 조건문이나 반복문 안에서 호출할 수 있다.

const HorizontalRule = ({ show }: { show: boolean }) => {
  if (show) {
    const theme = use(ThemeContext);
    return <hr className={theme} />;
  }
  return null;
};

useContext였다면 조건문 안에서 호출할 수 없었겠지만, use는 가능하다.

Server → Client 데이터 스트리밍

use의 강력한 활용법은 Server Component에서 생성한 Promise를 Client Component에서 읽는 것이다.

// app/page.tsx (Server Component)
import { fetchComments } from './data';
import { Comments } from './Comments';

const Page = () => {
  const commentsPromise = fetchComments();

  return (
    <Suspense fallback={<CommentsSkeleton />}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
};
// Comments.tsx (Client Component)
'use client';

import { use } from 'react';

export const Comments = ({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) => {
  const comments = use(commentsPromise);
  return <CommentList comments={comments} />;
};

장점:

주의사항

try-catch 사용 불가:

// ❌ 동작하지 않음
try {
  const data = use(dataPromise);
} catch (error) {
  // Suspense 관련 에러를 잡을 수 없음
}

// ✅ Error Boundary 사용
<ErrorBoundary fallback={<Error />}>
  <Suspense fallback={<Loading />}>
    <DataComponent />
  </Suspense>
</ErrorBoundary>

Client Component에서 Promise 생성 주의:

// ❌ 매 render마다 새 Promise 생성
const Component = () => {
  const dataPromise = fetchData();
  const data = use(dataPromise);
};

// ✅ Server Component에서 생성하여 전달
// 또는 캐싱 라이브러리 사용

서버 컴포넌트(RSC)와 Suspense

React Server Components와 Suspense는 긴밀하게 통합되어 있다.

async/await 직접 사용

Server Component에서는 async/await를 직접 사용할 수 있다.

// Server Component
const ProfilePage = async ({ userId }: { userId: string }) => {
  const user = await fetchUser(userId);

  return (
    <div>
      <h1>{user.name}</h1>
      <Suspense fallback={<PostsSkeleton />}>
        <Posts userId={userId} />
      </Suspense>
    </div>
  );
};

const Posts = async ({ userId }: { userId: string }) => {
  const posts = await fetchPosts(userId);
  return <PostList posts={posts} />;
};

Streaming SSR 동작 흐름

서버에서 Suspense가 있는 페이지를 렌더링하면 Streaming SSR이 동작한다.

1. 서버에서 렌더링 시작
2. ProfilePage의 user 데이터 await
3. user ready → HTML 청크 전송 (PostsSkeleton 포함)
4. Posts 데이터 await (백그라운드에서 계속 진행)
5. Posts ready → HTML 청크 + 교체 스크립트 전송
6. 클라이언트에서 skeleton을 실제 콘텐츠로 교체

사용자는 전체 페이지가 완성되기 전에 이미 일부 콘텐츠를 볼 수 있다.

Selective Hydration

Suspense boundary는 hydration 우선순위에도 영향을 준다.

<Suspense fallback={<NavSkeleton />}>
  <Nav />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
  <Sidebar />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
  <Content />
</Suspense>

사용자가 Content 영역을 클릭하면, React는 해당 영역의 hydration 우선순위를 높인다. 사용자 상호작용에 따라 동적으로 hydration 순서가 조정되는 것이다.

Suspense의 발전 과정

Suspense는 여러 버전을 거치며 발전해왔다.

React 16.6 (2018년)

첫 도입 — 코드 분할 전용

const OtherComponent = React.lazy(() => import('./OtherComponent'));

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

React 18 (2022년)

Concurrent Features와 통합

React 19 (2024년)

안정화 및 확장

// React 19: forwardRef 불필요
const MyInput = ({ placeholder, ref }: { placeholder: string; ref: React.Ref<HTMLInputElement> }) => {
  return <input placeholder={placeholder} ref={ref} />;
};

// React 19: Context 직접 사용
<ThemeContext value="dark">
  {children}
</ThemeContext>

React 19.2 (2025년)

Suspense Boundary Batching

서버 스트리밍 시 Suspense 콘텐츠를 배칭하여 함께 노출한다.

이전: 콘텐츠가 준비되는 즉시 개별 노출
현재: 짧은 시간 동안 배칭하여 함께 노출

실전 패턴과 주의점

모범 사례

1) 의미 있는 단위로 Boundary 설정

// ✅ 논리적 단위로 그룹화
<Suspense fallback={<ProfileSkeleton />}>
  <ProfileHeader />
  <ProfileStats />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
  <Feed />
</Suspense>

// ❌ 너무 세분화 (깜빡임 유발)
<Suspense fallback={<Skeleton />}><ProfileName /></Suspense>
<Suspense fallback={<Skeleton />}><ProfileImage /></Suspense>
<Suspense fallback={<Skeleton />}><ProfileBio /></Suspense>

2) Error Boundary와 함께 사용

<ErrorBoundary fallback={<ErrorUI />}>
  <Suspense fallback={<Loading />}>
    <DataComponent />
  </Suspense>
</ErrorBoundary>

Promise가 reject되면 가장 가까운 Error Boundary가 처리한다.

3) 레이아웃과 유사한 Skeleton UI

// ✅ 실제 레이아웃을 반영한 skeleton
<Suspense
  fallback={
    <div className="profile-card">
      <div className="skeleton-avatar" />
      <div className="skeleton-text" />
      <div className="skeleton-text short" />
    </div>
  }
>
  <ProfileCard />
</Suspense>

// ❌ 단순한 스피너만 사용
<Suspense fallback={<Spinner />}>
  <ProfileCard />
</Suspense>

흔한 실수들

1) 수동 Promise throw 구현

// ❌ 직접 구현은 edge case가 많음
let cache = {};
const fetchWithSuspense = (url) => {
  if (cache[url]) return cache[url];
  throw fetch(url).then((data) => (cache[url] = data));
};

// ✅ 검증된 라이브러리 사용
// - TanStack Query (React Query)
// - SWR
// - Relay

2) useEffect에서 Suspense 트리거 시도

// ❌ useEffect는 렌더링 후 실행되므로 Suspense와 맞지 않음
const Component = () => {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(setData);
  }, []);

  if (!data) throw new Promise(() => {}); // 안티패턴
};

// ✅ use Hook 또는 Suspense-enabled 라이브러리 사용

3) Suspense 없이 lazy 사용

// ❌ Error 발생
const LazyComponent = lazy(() => import('./Component'));
<LazyComponent />

// ✅ 반드시 Suspense로 감싸기
<Suspense fallback={<Loading />}>
  <LazyComponent />
</Suspense>

4) 불필요한 Waterfall

// ❌ user 로딩 후에야 Posts 렌더링 시작
const Page = () => {
  const user = useUser();

  return (
    <>
      <Profile user={user} />
      {user && <Posts userId={user.id} />}
    </>
  );
};

// ✅ 병렬 데이터 페칭
const Page = () => {
  return (
    <>
      <Suspense fallback={<ProfileSkeleton />}>
        <Profile />
      </Suspense>
      <Suspense fallback={<PostsSkeleton />}>
        <Posts />
      </Suspense>
    </>
  );
};

정리

Suspense는 React의 비동기 렌더링을 선언적으로 처리하는 핵심 메커니즘이다.

React 19.2에서는 Suspense Boundary Batching이 추가되어 더 자연스러운 콘텐츠 표시가 가능해졌다. Suspense는 단순한 로딩 처리를 넘어 React의 비동기 렌더링 전략의 핵심으로 자리잡았다.

참고 자료

공식 문서

블로그 및 아티클