Suspense 동작 원리 — Promise를 throw하는 마법의 정체
컴포넌트 렌더링 도중 Promise를 throw한다? 에러도 아닌데 throw를 한다니, 이것이 어떻게 동작하는 걸까요?
Suspense는 React의 가장 독특한 메커니즘 중 하나입니다. "아직 준비되지 않은 것"이 있을 때 fallback UI를 보여주는 선언적 방법인데, 그 내부에서는 Promise를 throw하는 다소 파격적인 방식으로 동작합니다.
Suspense의 기본 사용
import { Suspense, lazy } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<div>로딩 중...</div>}>
<HeavyComponent />
</Suspense>
);
}
HeavyComponent가 아직 로드되지 않았을 때 "로딩 중..."이 표시되고, 로드가 완료되면 컴포넌트가 렌더링됩니다. 간단해 보이지만, 내부 동작은 상당히 흥미롭습니다.
throw Promise — 핵심 메커니즘
일반적인 컴포넌트 렌더링
React: "Component를 렌더링해줘"
Component: <div>안녕하세요</div> 반환
React: DOM에 반영
Suspense가 있을 때
React: "Component를 렌더링해줘"
Component: Promise를 throw! (아직 데이터가 없어요)
Suspense: Promise를 catch → fallback 표시
...
Promise가 resolve됨
React: "Component를 다시 렌더링해줘"
Component: <div>데이터 로드 완료</div> 반환
React: DOM에 반영
의사 코드로 이해하기
// React.lazy의 내부 동작 (간략화)
function lazy(importFn) {
let status = 'pending';
let result;
const modulePromise = importFn().then(
(module) => {
status = 'fulfilled';
result = module.default;
},
(error) => {
status = 'rejected';
result = error;
}
);
return function LazyComponent(props) {
if (status === 'pending') {
throw modulePromise; // Promise를 throw!
}
if (status === 'rejected') {
throw result; // 에러를 throw → ErrorBoundary가 잡음
}
// 로드 완료 → 실제 컴포넌트 렌더링
return React.createElement(result, props);
};
}
Suspense의 catch 동작 (간략화)
// Suspense의 내부 동작 (간략화)
class Suspense {
render() {
try {
return this.props.children;
} catch (thrown) {
if (thrown instanceof Promise) {
// Promise를 잡았다 → fallback 표시
this.showFallback();
thrown.then(() => {
// Promise가 resolve되면 다시 렌더링 시도
this.retryRender();
});
} else {
// 일반 에러 → 상위로 전파 (ErrorBoundary가 처리)
throw thrown;
}
}
}
}
코드 스플리팅에서의 Suspense
React.lazy는 Suspense의 가장 일반적인 사용 사례입니다.
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
동작 흐름:
/dashboard에 접근합니다Dashboard컴포넌트가 렌더링을 시도합니다- 모듈이 아직 로드되지 않았으므로 Promise를 throw합니다
Suspense가 Promise를 catch하고<PageSkeleton />을 표시합니다import('./pages/Dashboard')가 완료됩니다- React가
Dashboard를 다시 렌더링합니다 — 이번에는 성공합니다
데이터 페칭에서의 Suspense
일반 useEffect 방식 (Suspense 미사용)
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <Skeleton />;
return <div>{user.name}</div>;
}
각 컴포넌트가 자체적으로 로딩 상태를 관리합니다.
Suspense 방식
// Suspense 지원 라이브러리 사용 (React Query 예시)
function UserProfile({ userId }) {
// suspense: true 옵션으로 Suspense 연동
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// loading 체크 불필요 — Suspense가 처리
return <div>{user.name}</div>;
}
// 사용하는 쪽에서 Suspense로 감쌈
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={1} />
</Suspense>
로딩 상태 관리가 컴포넌트에서 빠져나와 Suspense 바운더리로 이동합니다.
주의: 직접 구현은 비권장
// 이런 패턴은 프레임워크/라이브러리 없이 직접 만들지 마세요
function createResource(promise) {
let status = 'pending';
let result;
const suspender = promise.then(
(data) => { status = 'success'; result = data; },
(err) => { status = 'error'; result = err; }
);
return {
read() {
if (status === 'pending') throw suspender;
if (status === 'error') throw result;
return result;
}
};
}
React 팀은 이런 low-level 패턴을 직접 사용하지 말고, React Query, SWR, Relay 등 Suspense를 지원하는 라이브러리를 사용할 것을 권장합니다.
중첩 Suspense
Suspense를 중첩하여 각 영역의 로딩을 독립적으로 처리할 수 있습니다.
function DashboardPage() {
return (
<Suspense fallback={<FullPageSkeleton />}>
{/* 전체 페이지 로딩 */}
<Header />
<div className="content">
<Suspense fallback={<ChartSkeleton />}>
{/* 차트만 로딩 */}
<ChartSection />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
{/* 테이블만 로딩 */}
<DataTable />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
{/* 사이드바만 로딩 */}
<Sidebar />
</Suspense>
</div>
</Suspense>
);
}
중첩 Suspense의 동작
- 처음에는 모든 데이터가 없으므로 가장 바깥
<FullPageSkeleton />이 표시될 수 있습니다 - Header가 로드되면 레이아웃이 표시됩니다
- 차트, 테이블, 사이드바는 각각 독립적으로 로딩됩니다
- 차트가 먼저 로드되면 차트만 표시되고, 나머지는 여전히 스켈레톤입니다
이렇게 하면 점진적 로딩(Progressive Loading) 경험을 제공합니다.
Suspense와 ErrorBoundary 조합
function DataSection() {
return (
<ErrorBoundary FallbackComponent={ErrorUI}>
<Suspense fallback={<Skeleton />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
);
}
- 데이터가 ** 로딩 중 **이면 → Suspense의 fallback (Skeleton)
- 데이터 로딩이 ** 실패 **하면 → ErrorBoundary의 fallback (ErrorUI)
- 데이터가 ** 성공 **하면 → DataComponent 렌더링
Suspense의 한계
useEffect 안의 fetch는 동작하지 않음
// 이 방식은 Suspense와 동작하지 않습니다
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/data').then(res => res.json()).then(setData);
}, []);
// useEffect는 렌더링 후에 실행되므로 Promise를 throw할 수 없음
}
Suspense는 ** 렌더링 도중** throw된 Promise만 감지합니다. useEffect는 렌더링 ** 후에** 실행되므로 Suspense 메커니즘과 맞지 않습니다.
지원되는 방식
React.lazy(코드 스플리팅)- Suspense를 지원하는 데이터 페칭 라이브러리 (React Query, SWR, Relay)
- React Server Components
use()Hook (React 19+)
React 19의 use() Hook
// React 19+
import { use, Suspense } from 'react';
function UserProfile({ userPromise }) {
// use()는 Promise가 resolve될 때까지 Suspense를 트리거
const user = use(userPromise);
return <div>{user.name}</div>;
}
function App() {
const userPromise = fetchUser(1); // Promise 생성
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
use()는 Promise, Context 등을 읽는 새로운 Hook으로, Suspense와 자연스럽게 통합됩니다.
정리
Suspense의 핵심은 "Promise를 throw하여 로딩 상태를 선언적으로 처리하는 것"입니다.
- throw Promise: 컴포넌트가 아직 준비되지 않은 리소스가 있으면 Promise를 throw합니다
- Suspense catch: 가장 가까운 Suspense 바운더리가 Promise를 catch하고 fallback을 표시합니다
- ** 코드 스플리팅 **:
React.lazy는 Suspense의 가장 기본적인 사용 사례입니다 - ** 데이터 페칭 **: React Query 등 Suspense 지원 라이브러리와 함께 사용합니다
- ** 중첩 Suspense**: 영역별 독립적인 로딩 처리로 점진적 로딩을 구현합니다
- **ErrorBoundary와 조합 **: 로딩과 에러를 분리하여 처리합니다
주의할 점
Suspense를 지원하지 않는 라이브러리에서 Promise를 직접 throw
React의 내부 프로토콜을 직접 구현하면 버전 업데이트 시 깨질 수 있습니다. TanStack Query, SWR 등 공식적으로 Suspense를 지원하는 라이브러리를 사용하는 것이 안전합니다.
fallback이 너무 잦게 나타나는 UX 문제
빠른 네트워크에서 fallback이 깜빡이듯 나타나면 오히려 사용자 경험이 나빠집니다. startTransition과 함께 사용하면 이전 UI를 유지하면서 새 데이터를 로드할 수 있습니다.
Suspense는 "로딩 상태 관리"를 컴포넌트 내부에서 바운더리 레벨로 끌어올려, 더 선언적이고 일관된 UX를 만들 수 있게 합니다.