React Suspense 완벽 가이드: 동작 원리부터 React 19까지
React Suspense의 등장 배경, Promise throw 메커니즘, 중첩 Boundary 동작, use Hook, 서버 컴포넌트 통합까지. React 19의 최신 변경사항을 포함한 Suspense 심화 가이드.
들어가며
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 팀은 이 문제들을 해결하기 위해 세 가지 목표를 세웠다.
- Render-as-you-fetch: 데이터 페칭과 렌더링을 동시에 시작
- 선언적 로딩 처리: 명령형
isLoading상태 제거 - 일관된 UI: 부분적 로딩으로 인한 깜빡임 방지
Suspense란 무엇인가
Suspense는 컴포넌트가 렌더링되기 전에 **“아직 준비되지 않았다”**는 것을 React에 알려주는 메커니즘이다.
<Suspense fallback={<Spinner />}>
<ProfileDetails />
</Suspense>
Props
| Prop | 설명 |
|---|---|
children | 실제로 렌더링할 UI. 렌더링 중 suspend되면 fallback을 표시 |
fallback | children이 준비되지 않았을 때 표시할 대체 UI |
영화관 비유
Suspense를 영화관에 비유하면 이해하기 쉽다.
- children: 본 영화
- fallback: 영화 시작 전 광고/예고편
- Suspense boundary: 상영관 입구
관객(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를 가능하게 한다.
- 작업 중단 가능: Promise가 throw되면 현재 fiber의 작업을 중단
- 우선순위 관리: 사용자 입력이 Suspense 대기 작업보다 우선
- 재시도 메커니즘: Promise resolve 후 중단된 지점부터 재개
Fiber Tree 구조:
App (fiber)
│
Suspense (fiber) ← Boundary 역할
│
Profile (fiber) ← Promise throw 발생 시
│ 이 fiber의 작업이 중단되고
Posts (fiber) Suspense fiber로 제어권 이동
Suspense가 동작하는 조건
모든 비동기 작업에 Suspense가 동작하는 것은 아니다.
동작하는 경우:
React.lazy()로 지연 로딩되는 컴포넌트useHook으로 Promise 읽기 (React 19)- Suspense-enabled 프레임워크 (Relay, Next.js App Router)
동작하지 않는 경우:
useEffect내부의 데이터 페칭- 이벤트 핸들러 내부의 데이터 페칭
- 일반적인
useState+useEffect패턴
// ❌ 동작하지 않음 - 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>
Sidebar가 suspend되면 →<SidebarSkeleton />표시MainContent가 suspend되면 →<PageSkeleton />표시
All or Nothing
같은 Suspense boundary 안에 여러 컴포넌트가 있으면 어떻게 될까?
<Suspense fallback={<Loading />}>
<ProfileDetails /> {/* 2초 소요 */}
<ProfilePosts /> {/* 1초 소요 */}
<ProfileFriends /> {/* 3초 소요 */}
</Suspense>
동작:
- 가장 오래 걸리는 컴포넌트(3초)가 완료될 때까지 fallback 표시
- 3초 후 세 컴포넌트가 동시에 나타남
- 하나라도 준비되지 않으면 전체가 fallback
이것이 “All or Nothing” 방식이다. 부분적으로 콘텐츠를 보여주고 싶다면 Suspense를 분리해야 한다.
Progressive Loading 패턴
중첩 Suspense를 활용하면 점진적 로딩을 구현할 수 있다.
<Suspense fallback={<BigSpinner />}>
<Biography />
<Suspense fallback={<AlbumsSkeleton />}>
<Panel>
<Albums />
</Panel>
</Suspense>
</Suspense>
로딩 순서:
- 처음에는
BigSpinner표시 Biography로드 완료 → Biography 표시 +AlbumsSkeleton표시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의 효과:
- 새 탭이 로딩 중이어도 이전 탭 콘텐츠 유지
isPending으로 전환 중임을 시각적으로 표시- fallback으로 갑자기 바뀌는 깜빡임 방지
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>
</>
);
};
동작:
- 입력은 즉시 반영 (
query) - 검색 결과는 지연 반영 (
deferredQuery) - 이전 결과를 흐릿하게 보여주면서 새 결과 로딩
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} />;
};
장점:
- Server Component 렌더링을 blocking하지 않음
- Promise가 Server에서 생성되어 Client로 전달되므로 re-render마다 재생성되지 않음
- Streaming SSR과 자연스럽게 통합
주의사항
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.lazy()와 함께 코드 분할에만 사용 가능- 데이터 페칭에는 공식 지원 없음
- 클라이언트 사이드만 지원
React 18 (2022년)
Concurrent Features와 통합
- 서버 사이드 Suspense 지원
- Streaming SSR (
renderToPipeableStream) - Selective Hydration
useTransition,useDeferredValue추가
React 19 (2024년)
안정화 및 확장
useHook 공식 도입- Actions (
useActionState,useFormStatus,useOptimistic) - Server Components 안정화
- Asset Loading (스타일시트, 폰트, 스크립트)과 Suspense 통합
- Pre-warming: Suspense fallback 즉시 commit 후 sibling의 lazy request를 백그라운드에서 워밍업
- ref를 prop으로 직접 전달 가능 (
forwardRef불필요) - Context를 Provider 없이 직접 사용 가능
// 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 콘텐츠를 배칭하여 함께 노출한다.
이전: 콘텐츠가 준비되는 즉시 개별 노출
현재: 짧은 시간 동안 배칭하여 함께 노출
- Core Web Vitals (LCP 2.5초)를 기준으로 자동 최적화
- 더 자연스러운 콘텐츠 표시 애니메이션 가능
실전 패턴과 주의점
모범 사례
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의 비동기 렌더링을 선언적으로 처리하는 핵심 메커니즘이다.
- 동작 원리: Promise를 throw하면 가장 가까운 Suspense boundary가 fallback을 표시
- 중첩 규칙: 여러 Suspense를 중첩하여 Progressive Loading 구현 가능
- Concurrent Features:
useTransition,useDeferredValue와 조합하여 더 나은 UX 제공 - use Hook: React 19에서 추가된 Promise/Context 읽기 API, 조건문 안에서도 사용 가능
- 서버 컴포넌트: Streaming SSR, Selective Hydration과 긴밀하게 통합
React 19.2에서는 Suspense Boundary Batching이 추가되어 더 자연스러운 콘텐츠 표시가 가능해졌다. Suspense는 단순한 로딩 처리를 넘어 React의 비동기 렌더링 전략의 핵심으로 자리잡았다.
참고 자료
공식 문서
- React Suspense — Suspense 컴포넌트의 Props, 사용법, 주의사항을 설명하는 공식 레퍼런스.
- React use Hook — use Hook의 Promise/Context 사용법과 Suspense 통합을 설명하는 공식 문서.
- React 19 Release Notes — React 19의 새로운 기능과 변경사항 정리.
- React 19.2 Release Notes — Suspense Boundary Batching 등 최신 업데이트.
블로그 및 아티클
- React 19 Suspense Deep Dive — React 19에서의 Suspense 활용법을 심층적으로 다룬 글.