Suspense 동작 원리
지난 포스팅에서 Suspense에 대해 간단하게 알아보았다.
Suspense는 Lazy Loading 을 처리하거나, 비동기 처리에 대한 책임을 위임 받아 로딩 상태를 처리하도록 도와주는 React 컴포넌트이다.
import {use} from 'react';
import { fetchData } from './data.js';
export default function ArtistPage({ artist }) {
return (
<Suspense fallback={<Loading />}>
<Albums artistId={artist.id} />
</Suspense>
);
}
// 데이터를 비동기로 불러오는 컴포넌트
function Albums({ artistId }) {
const albums = use(fetchData(`/${artistId}/albums`));
return (
<ul>
{albums.map(album => (
<li key={album.id}>
{album.title} ({album.year})
</li>
))}
</ul>
);
}
이를 통해 React에서는 기존에 로딩 상태를 따로 처리해주어야 했던 코드를 외부에 선언적으로 처리함으로서 컴포넌트 내부에서는 본 로직에만 집중 할 수 있도록 하였다.
이번 포스팅에서는 Suspense가 어떻게 동작하는지 더 자세히 알아보도록 하자.
📌 어떻게 동작할까?
Suspense와 비동기 컴포넌트가 소통하는 방법 :: Promise
Suspense 는 비동기 상태에 따라 fallback UI, children 컴포넌트를 보여주게 된다. 이는 Suspense가 하위 children 컴포넌트에서 일어나는 비동기 상태를 “감지”할 수 있다는 것인데, 어떻게 알 수 있는 것일까??
“Suspense는 하위 컴포넌트에서 Promise를 throw 해주는 것을 감지한다.”
아래 코드는 React 코어 팀에서 Suspense로 비동기를 감지하는 과정 설명을 위해 작성한 컨셉 코드이다.
실제 구현과는 다르지만, Susepense가 어떻게 children 컴포넌트를 감지하는지 이해를 도울 수 있다.
function createResource(promise) {
let status = "pending";
let response;
const suspender = promise.then(
(res) => {
status = "success";
response = res;
},
(err) => {
status = "error";
response = err;
}
);
return {
read() {
if (status === "pending") throw suspender; // 🚩 Suspense 트리거
if (status === "error") throw result; // 🚩 ErrorBoundary 트리거
return result; // 성공 시 데이터 반환
},
};
}
export default wrapPromise;
createResource 함수는 Promise 객체를 매개변수로 받아서 promise의 상태에 따라 “Pending”, “error” 상태면 상위로 throw를 하고, 데이터가 준비된 “success” 시점에는 response를 반환한다.
이렇게 Promise의 상태에 따라 상위로 throw함으로 상위에 존재하는 Suspense 컴포넌트와 소통을 할 수 있는 것이다.
그렇다면 실제 비동기 코드에 적용시켜보자.
import React, { Suspense } from "react";
function userResource(){
return fetch('/api/user')
.then(res => res.json())
.then(res => res.data)
}
function UserProfile() {
const user = userResource.read(); // 🚩 데이터 없으면 Promise throw
return (
<div>
<h1>{user.name}</h1>
<p>나이: {user.age}</p>
</div>
);
}
export default function App() {
return (
<Suspense fallback={<h2>로딩 중...</h2>}>
<UserProfile />
</Suspense>
);
}
App 컴포넌트는 렌더링 될 때마다 read() 함수를 통해 결과 값을 읽으려고 시도한다.
그럼 wrapPromise() 함수에서 “pending” 혹은 “error” 상태인 경우 상위의 Suspense 컴포넌트로 해당 Promise를 throw하고, 정상 종료 상태인 경우에는 response 반환하기 때문에 정상적인 UI를 보여주게 된다.
Suspense가 throw를 어떻게 감지할까 ❓
React 렌더링 과정에서 일어나는 작업이기 때문에 내부적으로는 Fiber 트리와 reconciliation 과정과 아주 밀접하게 연관되어있다.
내부의 모든 원리를 지금 다루기에는 방대하고 복잡하기 때문에 단순화하여 알아보자.
핵심 아이디어
React Fiber 트리에서 렌더링 중 Promise가 throw 되면
→ React는 해당 컴포넌트를 일단 중단하고, fallback UI 로 대체
→ 이때 가장 가까운
Suspense가 Promise를 구독(subscribe)Promise가 resolve가 되면 React 는 해당 Suspense 경계를 다시 렌더하도록 schedule → 렌더링
import React, { ReactNode } from "react";
function isPromise<T>(i: any): i is Promise<T> {
return i && typeof i.then === "function";
}
// throw 된 Promise를 구독하고, 성공과 실패에 상관없이 실행할 callback 함수 전달
declare function subscribeToPromise(promise: Promise<any>, callback: () => void): void;
// fallback과 children은 ReactNode 타입
interface SuspenseProps {
children: ReactNode;
fallback: ReactNode;
}
export function Suspense({ children, fallback }: SuspenseProps): ReactNode {
try {
return children;
} catch (thrownValue) {
if (isPromise(thrownValue)) {
// 아직 로딩 중이니 fallback UI를 그린다
subscribeToPromise(thrownValue, retryRender);
return fallback;
}
throw thrownValue; // Promise가 아닌 에러는 ErrorBoundary로 전달
}
}
// Promise resolve 시 호출됨
function retryRender(): void {
scheduleUpdateOnFiber(); // Fiber 트리를 다시 렌더링
}
하위 children 컴포넌트에서 렌더링 중에 발생하면, Suspense에서 Try-Catch 로 throw를 잡는다.
이때 Promise 라면 해당 Promise를 Set 자료구조를 통해 구독(저장) 한다. 이때, subscribeToPromise 함수를 통해서 종료가 되면 Fiber 트리가 다시 렌더링 되도록 callback 함수를 넘겨주어 한번 더 감싸준다.
해당 Promise 가 비동기적으로 동작하면서 resolve 혹은 reject 결과를 내보내게 되는데, resolve 상태라면, Fiber 트리를 다시 렌더링 하도록 스케줄링 함수를 실행한다.
이렇게 Fiber 트리의 reconciliation 과정을 통해 Suspense와 아래 컴포넌트가 다시 렌더링이 발생하고, 이를 통해 fallback UI가 아니라, 실제 UI를 다시 보여주게 된다.
동작 흐름 정리
- 렌더링 시작
- 컴포넌트가 렌더링을 시도
- 동기적으로 처리 가능하면 그대로 렌더링 진행
- 비동기 데이터 요청 감지
- 렌더링 도중 특정 컴포넌트가
use(),React.lazy,비동기 요청등 데이터를 가지고 있지 않다면, Promise를 던짐 - React는 이 Promise를 캐치하고 “Suspense boundary”를 찾음
- 렌더링 도중 특정 컴포넌트가
- Fallback UI 표시
- “Suspense boundary”를 찾으면 React는 현재 트리를 멈추고 fallback UI 를 그림
- 동시에 비동기 요청(Promise)은 백그라운드에서 계속 진행
- Suspense에서 Promise 처리
- children 컴포넌트에서 발생한 Promise를 따로 Set 자료구조에 저장
- Promise가
resolve될 시 Fiber 트리를 다시 렌더링 하도록 callback 함수 전달
- Promise가
- children 컴포넌트에서 발생한 Promise를 따로 Set 자료구조에 저장
- 비동기 작업 완료
- Promise가
resolve되면 React는 Suspense boundary 내부 컴포넌트를 다시 렌더링 시도 - 데이터가 준비되었으면 정상적으로 UI가 그려짐
- Promise가
- UI 교체
- Fallback UI가 사라지고, 준비된 실제 UI가 화면에 나타남
📌 마무리
이번 포스팅에서는 Suspense의 동작원리를 아주 단순하게만 알아보았다. 실제로는 Fiber 와 reconciliation 과정이 복잡하게 얽혀있기 때문에 더욱 자세히 알아보려면 내부 코드를 직접 확인해보며 좋을 것 같다.
이 외에도 Server Component의 등장으로 Suspense는 아직 알아볼게 더 많으니 다음 포스팅에서 계속 알아보자.
📌 Reference
How to handle data fetching with React Suspense - LogRocket Blog
React Query와 함께 Concurrent UI Pattern을 도입하는 방법 | 카카오페이 기술 블로그
함께 보면 좋은 콘텐츠
- Engineering Guide
React Complier에 대하여
2025년 10월 1일 React 19.2가 정식으로 출시가 되었다. 더불어 10월 7-8일에 “React Conf 2025” 가 진행되면서 최신 React에 대한 새로운 주제가 많이 발표되었다.
- Engineering Guide
Streaming SSR
최근 계속 프론트엔드 개발자로 최적화된 사용자 경험을 위한 기술과 렌더링 방식에 대해 공부하며, 관련 개념을 정리하고 있다.
- Engineering Guide
Suspense 기본
React 에서 화면을 개발하면서 필연적으로 네트워크 요청 등의 비동기 데이터를 처리 하게 된다.