React Suspense 기본 개념
Suspense는 하위 컴포넌트가 아직 화면에 보여줄 준비가 되지 않았을 때, 가장 가까운 <Suspense> 경계의 fallback을 대신 보여주는 React의 경계 컴포넌트다.
처음에는 Suspense를 “비동기 작업을 기다려주는 도구” 정도로 이해했는데, 그렇게만 설명하면 조금 위험하다. Suspense는 아무 Promise나 자동으로 기다려주는 마법 상자가 아니다. React가 인식할 수 있는 방식으로 하위 컴포넌트가 “아직 준비되지 않았다”고 알려야 하고, 그때 Suspense가 fallback UI를 보여준다.
그래서 Suspense의 핵심은 로딩 상태를 컴포넌트 안에서 매번 조건문으로 처리하는 대신, 준비되지 않은 UI를 특정 경계 밖으로 밀어내는 것에 가깝다고 생각한다.
단순히 fallback을 넣으면 로딩 UI가 보인다는 설명만으로는 Suspense가 잘 와닿지 않았다. 내가 궁금했던 것은 “React는 무엇을 보고 이 컴포넌트가 아직 준비되지 않았다고 판단할까?”였다.
가장 기본적인 사용: React.lazy
Suspense를 가장 쉽게 이해할 수 있는 예시는 React.lazy다. React.lazy를 사용하면 컴포넌트 코드를 동적으로 불러올 수 있고, 아직 해당 코드가 로드되지 않았을 때 Suspense의 fallback이 표시된다.
import React, { Suspense } from 'react';
const MyComponent = React.lazy(() => import('./MyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
);
}
export default App;위 코드에서 MyComponent의 코드가 아직 다운로드되지 않았다면, React는 MyComponent를 바로 보여줄 수 없다. 이때 가장 가까운 Suspense 경계가 fallback으로 지정한 Loading...을 보여준다.
즉 Suspense는 “로딩 UI를 보여줄 위치”를 컴포넌트 트리 안에 선언해두는 방식이다.
데이터 fetching과 Suspense
Suspense는 코드 스플리팅뿐 아니라 데이터 fetching과도 함께 사용할 수 있다. 다만 여기서 주의할 점이 있다. 일반적인 fetch() Promise를 컴포넌트 안에서 만들었다고 해서 Suspense가 자동으로 그것을 기다려주지는 않는다.
데이터 fetching에서 Suspense를 쓰려면 Suspense와 통합된 프레임워크나 라이브러리를 사용하는 편이 안전하다. 예를 들어 TanStack Query에서는 Suspense용 API인 useSuspenseQuery를 제공한다.
import { Suspense } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
function Posts() {
const { data } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
function App() {
return (
<Suspense fallback={<div>게시글을 불러오는 중...</div>}>
<Posts />
</Suspense>
);
}이런 구조에서는 Posts가 데이터를 준비하지 못한 동안 Suspense 경계의 fallback이 보여진다. 컴포넌트 안에서 isLoading을 직접 검사해서 분기하지 않아도, 로딩 상태를 바깥 경계에서 다룰 수 있다.
응용해보기: 어떻게 동작하는 걸까?
Suspense의 동작을 이해하려면 “준비되지 않은 컴포넌트가 Promise를 던진다”는 모델을 떠올릴 수 있다. 하위 컴포넌트가 렌더링 중에 Promise를 던지면, React는 해당 컴포넌트를 지금은 보여줄 수 없다고 판단하고 가장 가까운 Suspense 경계의 fallback을 보여준다.
여기서부터의 코드는 실무용 데이터 fetching 도구를 만들기 위한 코드라기보다, Suspense가 fallback으로 전환되는 조건을 직접 확인하기 위한 실험에 가깝다.
하지만 이 방식을 애플리케이션 코드에서 직접 구현해서 사용하는 것은 권장하기 어렵다. React 공식 문서에서도 Suspense를 지원하는 데이터 소스는 프레임워크나 라이브러리 수준에서 통합되는 흐름을 전제로 설명한다. 아래 코드는 “이런 식으로 동작을 이해할 수 있다”는 참고용에 가깝다.
// 이해를 위한 예시일 뿐, 일반 애플리케이션 코드에서 권장하는 패턴은 아니다.
function LazyComponent() {
const data = fetchSomething();
if (data instanceof Promise) {
throw data;
}
return <div>{data}</div>;
}이 코드는 문제가 많다. React 컴포넌트는 여러 번 렌더링될 수 있으므로, 렌더링할 때마다 새로운 Promise를 만들면 같은 요청이 반복될 수 있다. Suspense와 함께 쓰려면 같은 작업에 대해서는 같은 Promise나 결과를 재사용할 수 있어야 한다.
왜 캐시가 필요한가
Suspense에서 데이터를 다룰 때 캐시가 중요한 이유는 같은 데이터 요청에 대해 매번 새로운 Promise를 만들면 안 되기 때문이다. 렌더링 중 Promise를 던졌는데, 다음 렌더링에서도 또 새로운 Promise를 만들면 React 입장에서는 계속 “아직 준비되지 않은 상태”처럼 보일 수 있다.
아주 단순하게 표현하면 아래와 같은 형태를 생각할 수 있다.
const cache = new Map<string, unknown>();
function readData<T>(key: string, fn: () => Promise<T>): T {
if (!cache.has(key)) {
const promise = fn().then((data) => {
cache.set(key, data);
});
cache.set(key, promise);
}
const cached = cache.get(key);
if (cached instanceof Promise) {
throw cached;
}
return cached as T;
}이 예시는 Suspense의 내부 모델을 이해하기 위한 단순화된 코드다. 실제 서비스에서 이 정도 캐시만으로 데이터 fetching을 직접 구현하기는 어렵다. 에러 처리, 재시도, 무효화, 중복 요청 제거, 서버 렌더링과의 연결 같은 문제가 계속 따라오기 때문이다.
그래서 실무에서는 직접 Suspense용 데이터 캐시를 만들기보다, 프레임워크나 TanStack Query 같은 라이브러리의 Suspense 지원을 사용하는 편이 낫다.
프로미스 상태를 감싸는 방식
Suspense를 설명할 때 자주 등장하는 예시로 read() 함수를 가진 resource 객체가 있다. Promise 상태에 따라 pending이면 Promise를 던지고, 실패하면 에러를 던지고, 성공하면 값을 반환하는 방식이다.
function createSuspenseResource<T>(promise: Promise<T>) {
let status: 'pending' | 'success' | 'error' = 'pending';
let result: T;
let error: unknown;
const suspender = promise.then(
(value) => {
status = 'success';
result = value;
},
(reason) => {
status = 'error';
error = reason;
}
);
return {
read() {
if (status === 'pending') {
throw suspender;
}
if (status === 'error') {
throw error;
}
return result;
},
};
}이 코드는 Suspense가 “값이 준비되지 않았을 때 fallback으로 전환되는 방식”을 이해하는 데는 도움이 된다. 하지만 이것을 그대로 커스텀 훅으로 감싸서 데이터 fetching 도구처럼 쓰는 것은 조심해야 한다.
특히 useEffect 안에서 Promise를 만들고 resource를 설정하는 방식은 Suspense의 핵심 흐름과 잘 맞지 않는다. Suspense는 렌더링 중에 하위 컴포넌트가 준비되지 않았음을 알릴 때 동작하는데, useEffect는 렌더링이 끝난 뒤에 실행되기 때문이다.
정리
Suspense는 단순히 “비동기 작업을 기다리는 도구”라기보다, 아직 준비되지 않은 UI를 어디에서 대신 보여줄지 정하는 경계에 가깝다.
코드 스플리팅에서는 React.lazy와 함께 사용해 아직 로드되지 않은 컴포넌트 대신 fallback을 보여줄 수 있다. 데이터 fetching에서는 아무 Promise나 직접 던지는 방식으로 접근하기보다, Suspense를 지원하는 프레임워크나 라이브러리와 함께 쓰는 편이 안전하다.
Suspense를 이해하고 나니 로딩 상태는 컴포넌트 내부의 if (loading) 문제라기보다, 사용자에게 어느 범위까지 기다리게 할 것인가의 문제처럼 보이기 시작했다.
결국 Suspense를 이해할 때 중요한 질문은 이것이다.
이 컴포넌트가 준비되지 않았을 때, 화면의 어느 경계까지 fallback으로 바꿀 것인가?
