목차
- Suspense란
- 동작원리
- Suspense 동작 이해하기
- 주의사항
Suspense란
일부 구성 요소의 렌더링 준비가 되지 않았을때 준비가 될때까지 사용자에게 대체 UI를 노출시켜주는 기능
Suspense라는 기능은 if(isLoading) return <Loading/>과 같은 코드를 일일이 작성하지 않고 미리 정의한 Suspense의 UI를 노출시켜줍니다.
아래는 isLoading이 ture일때 Suspense에서 노출하는 UI와 isLoading이 false일때 렌더링된 컴포넌트를 노출하는 예시 화면입니다.


구조
<Suspense fallback={<Loading />}>
<Albums /> //children
</Suspense>
- children
- suspense가 렌더링하려는 실제 UI입니다. children의 렌더링이 지연되면 suspense는 fallback을 대신 렌더링 합니다.
- fallback
- 실제 UI가 로딩될때까지 대신 렌더링 되는 대체 UI
- 보통 로딩 스피너, 스켈레톤 같은 것들을 사용
사용방법
오류 방지를 위해 ErrorBoundary도 함께 구현했지만 Suspense를 중점적으로 봐주세요.
ErrorBoundary는 추후에 알아보겠습니다.
부모 컴포넌트
import {ErrorBoundary} from "react-error-boundary";
import React, {Suspense} from "react";
import {ProductDetail} from "./ProductDetail.tsx";
export function ProductPage() {
const errorComponent = <div>
상품 정보를 불러오지 못했습니다. 잠시 후 시도해주세요.
</div>
//Suspense의 fallback
const loadingComponent = <div>
상품 상세 정보를 불러오는 중...
</div>
return (
<>
<ErrorBoundary fallback={errorComponent}>
{/*로딩 중일 때 처리*/}
<Suspense fallback={loadingComponent}>
{/*실제 컨텐츠*/}
<ProductDetail productId={123}/>
</Suspense>
</ErrorBoundary>
</>
)
}
비동기 작업이 발생하는 자식 컴포넌트를 Suspense 모듈로 감싸주어야합니다.
자식 컴포넌트에서 렌더링 준비가 되지 않은 상황 발생시 Suspense가 판단하여 fallback을 노출시켜주기 위해서 입니다.
자식 컴포넌트
import {useSuspenseQuery} from "@tanstack/react-query";
import React from "react";
export function ProductDetail({productId}: {productId: number}) {
const {data: product} = useSuspenseQuery({
queryKey: ['product', productId],
queryFn: async () => {
//중점적으로 봐야할 곳!!!!
return new Promise((resolve) => {
setTimeout(() => {resolve({name: '꾸꾸꾸 남자 코트', price: 320000})}, 3000)
})
}
})
return (
<div>
<h1>{product.name}</h1>
<p>{product.price}원</p>
</div>
)
}
Suspense의 간단한 동작을 보여주기 위해 tanstack query에서 Suspense 동작을 도와주는 useSuspenseQuery 훅을 예시로 들었습니다.
아래에서 Suspense의 동작원리를 이해하고 보시면 편리한 훅이 있구나하고 사용하시면 됩니다.
추후에 포스팅하겠습니다.
중점적으로 봐야할 것은 Promise를 반환하는 부분입니다.
렌더링 준비가 되지 않았을때를 보여주기 위해 시간을 조금 지연 시킨 예시입니다.


결과는 위와 같이 초반에는 비동기 작업으로 인해 렌더링 준비가 되지 않은 상황에서 Suspense의 fallback에 정의한 UI가 노출됩니다. 3초 뒤에 Promise를 반환하고 Suspense의 자식 컴포넌트가 UI에 노출되는것을 확인할 수 있습니다.
동작원리
Suspense는 자식 컴포넌트의 비동기 작업의 진행 여부를 감지 및 판단을 할 수 있다는 건데, 어떻게 이게 가능한지 알아보겠습니다.
Suspense의 핵심은 로딩중(pending)인 경우 Promise를 throw 해준다는 것입니다.
Suspense는 throw된 Promise를 캐치한 후, 대체 UI를 노출시킵니다.
자식 컴포넌트가 Promise를 throw한 경우 컴포넌트 트리의 렌더링이 일시 중지되고 리액트는 해당 컴포넌트의 렌더링 결과(DOM 생성)을 반영하지 못하고 폐기합니다. 그리고 가까운 Suspense를 찾아 fallback으로 UI를 노출시킵니다.
아래는 리액트 엔진 내부에서 Promise를 잡는 예시 코드입니다.
참고한 글의 예시 코드를 참고했습니다.
// React pseudo-code
try {
const result = renderComponent();
// 정상적으로 렌더링됨
return result;
} catch (thrownValue) {
if (thrownValue instanceof Promise) {
// Suspense 처리
return findAndRenderSuspenseFallback();
}
// 실제 에러는 Error Boundary로 전파
throw thrownValue;
}
Promise가 throw 되었을때 Suspense의 fallback을 반환하는 모습입니다.
throw promise에 대한 주의사항
Suspense 동작원리를 찾으면서 많이 헷갈렸던 부분입니다. 주의사항으로, throw된 promise는 사용자 컴포넌트 내에서는 catch될 수 없다는 것입니다.
promise를 throw할때 promise를 catch하는 역할은 리액트 엔진의 재조정자(reconciler)가 합니다.
추후에 throw관련해서 정리해볼 예정입니다.
(번외) 왜 throw Promise를 하면 렌더링이 멈출까?
원리는 비동기 작업을 수행하는 브라우저 영역과 렌더링시 컴포넌트 트리를 재구성하는 fiber와의 협력속에 이루어집니다.
fetch와 setTimeout과 같은 비동기 작업은 브라우저의 웹 API에서 돌아갑니다.
렌더링시 컴포넌트 트리 재구축은 fiber라는 작업 단위를 중심으로 이루어집니다.
재구축과 throw promise를 중점으로 바라보았을때 suspense 동작의 흐름은 아래와 같습니다.
1. fiber는 재구축을 하는 동안 비동기 작업(promise)이 브라우저 웹 API에서 동작합니다.
2. promise가 throw되면 fiber는 해당 컴포넌트 렌더링을 중단합니다.
3. 렌더링이 중단되면 suspense가 throw를 잡고 fallback UI를 노출합니다.
4. 이 후 promise가 완료(resolved)가 되면 브라우저는 Promise 완료 신호를 보냅니다.
5. fiber는 Promise 완료 신호를 받고 해당 컴포넌트 부터 다시 렌더링을 시작합니다.
6. Suspense의 자식 컴포넌트가 성공적으로 렌더링되어 노출됩니다.
위의 과정에서 fiber는 throw된 promise가 발생하면 렌더링을 멈추는데..
이유는 일관성이 깨진 불완전한 UI를 사용자에게 보여주지 않기 위해서입니다.
비동기 작업의 결과물(데이터)가 로딩중이여서 존재하지 않는다면, 해당 데이터를 참조했을 경우 undefined에러가 화면에 띄워지게 될것입니다.
이런 경험을 사용자에게 제공하지 않기 위해 fiber는 앱이 죽게 내버려두느니 promise 응답을 받을때 까지 suspense(임시 중단)하고 기다리겠다는 전략때문입니다.
Suspense 동작 이해하기
Suspense는 비동기 작업 중 promise가 throw되었을때 렌더링을 suspense하는 방식으로 사용자에게 일관성 있는 UI를 보여주기 위한 기능입니다.
비동기 작업 중 promise를 throw하고 Suspense가 대체 UI를 노출하는 것을 코드레벨로 확인해 보겠습니다.
아래는 비동기 작업의 상태를 관리하는 예시 코드입니다.
//1. 비동기 작업의 Promise를 인자로 넘겨 받음
export default function WrapPromise<T>(promise : Promise<T>) {
let status: "pending" | "success" | "error" = "pending";
let response : T | any;
//3. Promise가 완료되었을때 상태를 변경해줌
const suspender = promise.then(
(res: T) => {
status = "success";
response = res;
},
)
.catch((err) => {
status = "error";
response = err;
})
//2. 초기 상태는 pending이기 때문에 suspender를 던져줌. 이를 Suspense가 캐치함
//4. 성공시 response를 반환하고, 실패시 오류 반환으로 Errorboundary에서 캐치함
const read = (): T => {
switch (status) {
case "pending":
throw suspender;
case "error":
throw response;
default:
return response;
}
}
return {read};
}
- 비동기 작업을 관리할 WrapPromise입니다.
- 컴포넌트에서 작업할 promise를 매개변수로 받습니다.
- 렌더링 시점에 promise의 결과를 조회하는 read함수 입니다.
- promise가 로딩 중인 시점에서는 status가 pending이기 때문에 promise를 throw 합니다.
- 이때 렌더링이 멈추고 Suspense로 promise가 전달됩니다.
- promise 결과에 따라 status를 업데이트 해줍니다.
- promise가 끝나면 다시 렌더링이 시작되고 read함수를 통해 데이터를 읽으려고 합니다.
- 성공시 결과를 반환합니다.
- 실패시 결과를 throw합니다. 해당 경우는 리액트에서 오류라 판단하고 ErrorBoundary에서 캐치합니다.
아래는 비동기 작업을 호출하고 WrapPromise를 이용해 데이터를 가져오고 렌더링하려는 자식 컴포넌트입니다.
import WrapPromise from "./WrapPromise.ts";
import React, {Suspense, useEffect, useMemo, useState} from "react";
import {ErrorBoundary} from "react-error-boundary";
interface Product {
id: number;
name: string;
like: number;
}
const data: Product[] = [{id:1, name: '아이폰', like: 0}, {id:2, name: '맥북', like: 1}, {id:2, name: '에어팟', like: 2},{id:3, name: '아이맥', like: 0}]
//순수 비동기 fetch 함수
//2. Promise 반환
const fetch = async (id: number) : Promise<Product> => {
return new Promise((resolve, reject): Product | void => {
setTimeout(() => {
const result = data.filter((p)=> p.id == id).pop();
return result ? resolve(result) : reject(new Error("상품을 찾을 수 없습니다."));
},3000)
})
}
const ProductInfo = ({resource}: {resource: {read: () => Product}}) => {
//3. WrapPromise 내부의 상태에 따라 결과 반환
const product = resource.read();
return (
<div>
<h1>상품명: {product.name}</h1>
<h2>좋아요: {product.like}</h2>
</div>
)
}
export default function SuspenseTest() {
const [idValue, setIdValue] = useState(0);
/**
* 1. 비동기 작업을 하는 fetch함수를 WrapPromise로 감쌉니다.
*/
const resource = useMemo(() => {
return WrapPromise(fetch(idValue))
}, [idValue])
return (
<div>
<input type="number" value={idValue} onChange={(e) => {
setIdValue(Number(e.target.value))}
} placeholder="상품 ID"/>
<Suspense fallback={<div>상품을 찾는 중입니다.</div>}>
<ErrorBoundary fallback={<div>상품을 찾을 수 없습니다</div>} resetKeys={[idValue]}>
<ProductInfo resource={resource}/>
</ErrorBoundary>
</Suspense>
</div>
)
}
- 비동기 작업을 하는 fetch함수를 WrapPromise로 감쌉니다.
- fetch 함수는 Promise를 반환하는 함수입니다.
- suspense의 확인을 위해 setTimeout으로 지연시켰습니다.
- 필터링 성공시
- 렌더링 될 때마다 WrapPromise로 감싸진 promise의 결과를 읽어오기 위해 read함수 호출
- 로딩 중 : Suspense의 fallback렌더링
- 성공 : product 상품 정보 렌더링
- 실패 : ErrorBoundary UI 호출


동작 흐름
Suspense의 동작 원리를 알기 위한 예시 코드의 전체적인 동작 흐름입니다.
- 초기 렌더링시 resource 정의
- fetch함수 : promise 로딩 중
- WrapPromise : status는 'pending'
- ProductInfo에서 read함수 호출
- WrapPromise의 상태가 pending이기 때문에 throw Promise
- Suspense의 fallback UI 노출
- idValue의 변경으로 WrapPromise 결과(promise) 업데이트
- id가 0에서 3으로 변경
- fetch함수에서 필터링 성공으로 product 반환
- ProductInfo에서 read함수 호출시 product 반환
- 자식 컴포넌트에 product 정보 노출
주의사항
Suspense를 학습하면서 배웠던 주의 사항입니다.
1. useEffect와 suspense는 동작 원리가 상충하기 때문에 관련된 데이터는 사이드 이펙트로 관리하지 않는다.
const [productDetail, setProductDetail] = useState<Product>(null);
useEffect(() => {
const fetchData = async () => {
return WrapPromise<Product>(fetch(idValue));
}
const fetchResult = await fetchData();
setProductDetail(fetchResult);
}, [idValue]);
처음에 예시 코드를 작성할때 WrapPromise를 해당 컴포넌트의 상태로 관리하려고 했었습니다.
하지만 suspense는 렌더링을 임시 중단한 상태에서 promise를 대기하는데, 저는 렌더링 후의 effect 단계에서 가져오려다 보니 suspense의 작동 원리와 useEffect의 실행시점이 충돌이 나서 오류가 발생하였습니다.
2. promise를 throw했을때 사용자 컴포넌트에서는 throw를 캐치할 수 없습니다.
앞서 Suspense 주의사항에 작성했던 것처럼 promise를 throw하는 것은 리액트 엔진의 재조정자에 의해 catch되는 것이기 때문에 사용자 컴포넌트 단계에서 catch를 한다고 해서 잡을 수 없습니다.
참고
https://velog.io/@seeh_h/suspense%EC%9D%98-%EB%8F%99%EC%9E%91%EC%9B%90%EB%A6%AC
Suspense의 동작 원리
React v18에서 공식 릴리즈된 Suspense에 대해 파헤쳐보자.
velog.io
https://www.jungmin.me/post/react-suspense-%EB%82%B4%EB%B6%80-%EB%8F%99%EC%9E%91%EB%B0%A9%EC%8B%9D
React Suspense의 내부 동작방식
Suspense의 내부 동작 방식 이해하기🔗 코드복사function App() { return ( // 왜 loading 이나 placeholder가 아니라 fallback일까? 로딩중...}> )}function App() { return ( // 왜 loading 이나 placeholder가 아니라 fallback일까?
www.jungmin.me
'cs > react' 카테고리의 다른 글
| [React] shadcn & tailwind (0) | 2026.01.30 |
|---|---|
| [React] ErrorBoundary (0) | 2026.01.23 |
| [React] TanStack Query (V5) (1) | 2026.01.20 |
| [React] useActionState (0) | 2026.01.17 |
| [React] useOptimistic (0) | 2026.01.16 |