React 혹은 Next를 할 때, 비동기 데이터 처리가 꼭 필요하다.
밑에는 실제 프로젝트에서 사용한 코드의 예시이다.
//게스트 정보 불러오기
const { data, isLoading, isError } = useInviteGuest(exerciseId);
if (isLoading)
return (
<div>
<LoadingSpinner />
</div>
);
if (isError) {
return <p className="body-rg-500">오류 발생</p>;
}
const noneData = data?.list.length === 0;위 코드를 보면 useQuery(hook내부)를 사용하면서 같은 페이지에서 isError와 isLoading을 관리한다.
이 방식은 직관적이지만, 프로젝트 규모가 커질수록 다음과 같은 문제가 생긴다.
명령형 데이터 패칭
1. isLoading, isError 분기가 모든 컴포넌트에 반복
2. 컴포넌트가 직접 한 페이지 안에서 loading,Error의 상태를 판단하고 어떤 UI를 보여줄지 결정한다.
👉즉 컴포넌트가 UI를 그리는 역할이 아닌, 상태 분기 로직을 처리하게 된다.
“지금 상태가 이러니까, 이런 UI를 보여줘”
하지만 React는 선언적 UI를 추구한다.
선언적 데이터 패칭
컴포넌트는 ‘데이터가 존재한다고 가정한 UI’만 선언하고, 로딩과 에러 처리는 컴포넌트 바깥에서 담당하는 방식
<button disabled={isDisabled}>제출</button>
“언제 disable할지”를 직접 제어하지 않고, “상태가 이러면 이렇게 보여라”라고 선언한다.
React는 이 철학을 데이터 패칭에도 적용하고자 했고, 그 결과 등장한 것이 Suspense다.
Suspense✏️
Suspense는 비동기 작업이 끝나기 전까지 (Suspense의 chidren컴포) 렌더링을 미루고, 그동안 대신 보여줄 UI를 선언하는 메커니즘이다.
즉 비동기 함수로 인해, 데이터가 로드되기 전까지 fallback ui를 보여준다.
<Suspense fallback={<SekeletonUi />}>
<MyComponent />
</Suspense>MyComponent가 대기 상태라면, React는 fallback을 렌더링한다. 이후 준비가 끝나면 실제 UI로 교체한다.
컴포넌트는 "데이터는 이미 준비되어 있다고 가정"하며 로딩과 에러는 컴포넌트 바깥에서 처리한다.
그렇다면 어떤 데이터가 사용될까?🤔
1️⃣ 코드 로딩 (React.lazy)
import { Suspense, lazy } from "react";
const Profile = lazy(() => import("./Profile"));
export default function App() {
return (
<Suspense fallback={<p>로딩 중...</p>}>
<Profile />
</Suspense>
);
}
동작 흐름
- Profile JS 파일 아직 안 내려옴
- React가 Suspense에 "아직 준비 안 됨" 알림
- <p>로딩 중...</p> 표시
- 다운로드 완료 → Profile 렌더링
2️⃣ 비동기 데이터
const { data, isLoading, isError } = useInviteGuest(exerciseId);
-----------------------------------------
<section>
<div className="flex items-center justify-center flex-col">
<InviteGuestList data={data} exerciseId={exerciseId} />
</div>
</section>
------------------------------------------
// 데이터를 비동기로 불러오는 컴포넌트
type InviteGuestProps = {
data?: { list: ResponseInviteGuest[] };
exerciseId: number;
};
export default function InviteGuestList({
data,
exerciseId,
}: InviteGuestProps) {
const handleDelete = useDeleteInviteForm(exerciseId);
return data?.list.map((item: ResponseInviteGuest) => {
const apilevel = toKor(item.level);
console.log(data);
const responseLevelValue =
apilevel === "disabled"
? "급수 없음"
: ["A조", "B조", "C조", "D조"].includes(apilevel)
? `전국 ${apilevel}`
: apilevel;
const numberStatus = item.isWaiting ? "waiting" : "Participating";
const apiNumber = item.isWaiting
? `대기.${item.participantNumber}`
: `No.${item.participantNumber}`;
return (
<Member
key={item.guestId}
status={numberStatus}
{...item}
guestName={item.inviterName}
gender={item.gender.toUpperCase() as "MALE" | "FEMALE"}
number={apiNumber}
level={responseLevelValue}
showDeleteButton={true}
useDeleteModal={false}
isGuest={true}
guestNumber={true}
onDelete={() => handleDelete.mutate(item.guestId)}
/>
);
});
}React Query, Server Component 등 Suspense 연동 환경
Suspense의 특징😶🌫️
1️⃣ 하나의 렌더링 단위
Suspense는 자신이 감싸고 있는 모든 자식 컴포넌트를 하나의 렌더링 단위로 묶는다
요소 중 어떤 데이터에 의해 지연이 되더라도, “모든 구성 요소”가 함께 Fallback UI 로 대체된다. (가장 느린 비동기 기준으로 렌더링 됨)
이해하기 위해 밑에 예시를 보자.
<Suspense fallback={<Loading />}>
<Component1 /> // 3초
<Component2 /> // 1초
<Component3 /> // 비동기 호출 X
</Suspense>
- Component2는 1초 만에 준비되지만
- Component1이 3초 걸리면
- 3초 후에 component 1, 2 ,3 모두 동시에 렌더링된다.
즉 component2는 1초가 지나면 이미 렌더링 되었지만 Suspense특징으로 인해 같은 단위 안에 묶여있어서 혼자 먼저 빠져나올 수 없다.
이렇게 하는 이유는 " UI 덩어리는 함께 나타나는 것이 UX상 자연스럽다”고 판단하기 때문이다.
//Example
카드 + 버튼 + 설명
프로필 이미지 + 이름 + 액션 버튼
Promise.all과 Suspense의 관계🤔
Promise.all
JavaScript에서 Promise.all은 여러 비동기 작업을 병렬로 실행하면서도,
모두 완료될 때까지 결과를 하나도 반환하지 않는 특징이 있다.
await Promise.all([fetch1, fetch2, fetch3]);
fetch2는 1초 만에 끝나도 fetch1이 3초 걸리면 전체 대기 시간은 3초이다.
즉 ,가장 느린 요청이 전체 렌더링을 결정하며 중간에 완료된 fetch2·fetch3은 결과를 미리 쓸 수 없다.
- 동작: fetch1(3초), fetch2(1초)가 있을 때, 3초가 지날 때까지 화면에는 아무것도 나타나지 않는다.
- 결과: 사용자에게는 3초 동안 빈 화면이나 전체 로딩 스피너만 보인다.
Suspense
병렬로 데이터를 받아오면서도 , 먼저 준비된 UI부터 보여줄 수 있다.
- 동작:
- <Suspense>로 감싸진 두 컴포넌트가 각각 데이터를 호출한다.
- fetch2가 1초 만에 완료되면, React는 즉시 해당 컴포넌트만 먼저 렌더링한다.
- fetch1은 여전히 로딩 중이므로 그 자리에는 로딩 바가 유지된다.
- 3초 뒤 fetch1이 완료되면 나머지 부분도 업데이트된다.
일반적으로 throw는 에러를 던지지만, Suspense는 Promise 객체 자체를 throw한다.
데이터 로딩이 진행 중인 컴포넌트가 Promise를 throw하면, Suspense는 이 Promise가 resolve(해결)될 때까지 자식 컴포의 렌더링을 멈춘다.
2️⃣ 중첩된 Suspense
Suspense는 중첩할 수 있고, 각각 독립적인 단위이다.
<Suspense fallback={<PageLoading />}>
<Header />
<Suspense fallback={<FeedLoading />}>
<Feed /> // 느림
</Suspense>
<Suspense fallback={<SidebarLoading />}>
<Sidebar /> // 매우 느림
</Suspense>
</Suspense>- Page Suspense 진입
- Header는 비동기 없음 → 즉시 렌더
- Feed는 아직 준비 안 됨 → FeedLoading
- Sidebar도 준비 안 됨 → SidebarLoading
- Feed 준비 완료 → Feed만 교체
- Sidebar 준비 완료 → Sidebar만 교체
👉 부분적으로 UI가 완성됨
throw된 Promise가 가장 가까운 상위의 <Suspense> 컴포넌트의 fallback 프로퍼티에 의해 감지되고.
Promise가 해결되기 전까지 fallback으로 지정된 컴포넌트가 화면에 표시됨
Q. suspense를 왜 쪼개야하나요!!?? 🤔
<Suspense fallback={<PageLoading />}>
<Header />
<Feed />
<Sidebar />
<Footer />
</Suspense>이런식으로 하나의 Suspense안에 많은 요소들을 두면, 페이지 전체가 로딩화면이 된다.
그렇게 된다면 사용자는 사이트가 멈췄다고 판단 할 것이다.
때문에 Suspense를 사용할 때 , UI를 어떤 단위로 보여줄지 결정하는 설계 하는 것이 중요하다.
Error Boudary✏️
Error Boundary는 하위 컴포넌트에서 발생한 에러를 잡아서, 대체 UI를 보여주는 React의 에러 처리 경계이다.
Suspense는 로딩만 처리하고, 에러는 처리하지 않는다.
Error Boundary사용 예시
<ErrorBoundary fallback={<ErrorUI />}>
<Component />
</ErrorBoundary>
Error Boundary + Suspense 동시 사용 예시
<ErrorBoundary fallback={<ErrorUI />}>
<Suspense fallback={<SkeletonUI />}>
<InviteGuestList />
</Suspense>
</ErrorBoundary>
- 로딩 → Suspense
- 실패 → Error Boundary
- 성공 → InviteGuestList는 성공 UI만 책임진다.
useSuspensQuery✏️
TanStack Query v5에서 새로 도입된 Suspense 전용 쿼리 훅이다.
TanStack Query가 v5버전을 정식 출시하면서 주요 변경 중에 suspense를 지원하는 useSuspenseQuery, useSuspenseInfiniteQuery, useSuspenseQueries가 나오게 되었다.
const { data } = useSuspenseQuery({ queryKey: ['data'], queryFn: fetchData });
// 여기서 data는 무조건 '성공'한 상태임이 보장.
return <div>{data.name}</div>;
- 로딩: throw Promise
- 에러: throw Error
- 성공: data만 반환
기존 useQuery는 컴포넌트 내부에서 항상 로딩과 에러 처리를 직접 해주었다면,
useSuspenseQuery는 데이터가 성공적으로 받아와졌을 때만 컴포넌트를 실행한다. 로딩은 부모의 <Suspense>가, 에러는 부모의 <ErrorBoundary>가 대신 책임진다.
특히 useSuspensQuery에서는 enabled옵션이 사라졌다.
enabled 옵션의 주 용도는 "특정 조건(데이터가 준비됨)이 충족될 때까지 쿼리를 실행하지 마라"는 것이다. 하지만 Suspense는 "무조건 데이터가 있다고 가정하고 실행"한다. 즉 컴포넌트가 렌더링되려면 이 데이터가 반드시 필요해! 라고 선언한다.
때문에 enabled는 suspense와 상충되는 개념이기에 option에서 사라졌다.
때문에, Suspense에서 조건부 렌더링을 하고싶다면
{userId && (
<Suspense fallback={<Loading />}>
<UserInfo userId={userId} />
</Suspense>
)}enabled 대신 컴포넌트 자체를 조건부로 렌더링해야 한다.
한줄정리 <말을 트자>
Suspense와 Error Boundary는 선언적 데이터 패칭을 가능하게 하여, 로딩과 에러를 컴포넌트 밖으로 분리하는 React의 설계 도구이다. 특히 Suspense는 suspense가 감싸고 있는 component를 하나의 단위로 묶으며, 가장 느린 비동기 작업으로 렌더링 된다. 또한 중첩된 suspense가 가능하여 부분적 ui 표현이 가능하며 효율적인 ux를 제공한다.
'React' 카테고리의 다른 글
| [React] lazy loading을 사용한 초기 렌더링 최적화 (0) | 2026.03.17 |
|---|---|
| react hook -useRef (0) | 2026.01.06 |
| react hook -useState와 useEffect (객체 deps를 곁들인..) (0) | 2026.01.06 |