Suspense 알아보기
React Suspense 분석하기
React로 개발할 때 가장 까다로운 작업 중 하나는 비동기 데이터를 로딩하는 동안 사용자에게 적절한 로딩 상태를 표시하는 것입니다. 이를 위해 React 팀은 Suspense라는 강력한 기능을 도입했습니다. 이 글에서는 Suspense가 무엇인지, 실제 애플리케이션에서 어떻게 활용할 수 있는지, 그리고 내부적으로 어떻게 동작하는지 살펴보겠습니다.
목차
- Suspense 소개
- React Query와 함께 사용하기
- SWR과 함께 사용하기
- useTransition과 함께 활용하기
- Suspense의 내부 동작 원리
- Suspense의 고급 패턴
- 마무리
Suspense 소개
Suspense는 컴포넌트가 렌더링되기 전에 무언가를 "기다릴" 수 있게 해주는 React의 기능입니다. 주로 두 가지 싱황에서 사용됩니다
- 데이터 페칭: API에서 데이터를 가져올 때
- 코드 스플리팅:
React.lazy()
로 컴포넌트를 동적으로 로드할 때
기본적인 Suspense 사용법은 다음과 같습니다:
import { Suspense } from 'react';
function App() {
return (
<div>
<Suspense fallback={<div>로딩 중...</div>}>
<SomeComponentThatMightSuspend />
</Suspense>
</div>
);
}
여기서 fallback
prop은 자식 컴포넌트가 로딩 중일 때 보여줄 UI입니다. 하지만 실제 애플리케이션에서는 어떻게 Suspense를 활용할 수 있을까요? 인기 있는 데이터 페칭 라이브러리들과 함께 사용하는 방법을 알아보겠습니다.
React Query와 함께 사용하기
React Query는 데이터 페칭, 캐싱, 동기화를 위한 강력한 라이브러리로, Suspense와 통합하여 사용하기 매우 좋습니다.
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
// React Query 클라이언트 설정
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true, // Suspense 모드 활성화
},
},
});
// 앱 컴포넌트
function App() {
return (
<QueryClientProvider client={queryClient}>
<div className="app">
<h1>사용자 대시보드</h1>
<Suspense fallback={<div className="loading-spinner">데이터 로딩 중...</div>}>
<UserProfile userId="1" />
</Suspense>
</div>
</QueryClientProvider>
);
}
// 사용자 프로필 컴포넌트
function UserProfile({ userId }) {
// useQuery에서 에러 핸들링은 제거해도 됩니다(에러 바운더리로 처리 가능)
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
});
return (
<div className="profile-card">
<img src={data.avatarUrl} alt={data.name} className="avatar" />
<h2>{data.name}</h2>
<p>{data.email}</p>
<div className="stats">
<div>팔로워: {data.followers}</div>
<div>팔로잉: {data.following}</div>
</div>
</div>
);
}
이 예제에서는 useQuery
에 suspense: true
옵션이 활성화되어 있어(QueryClient 기본 설정에서), 데이터가 로딩되는 동안 Suspense의 fallback이 표시됩니다. 데이터가 준비되면 React는 자동으로 UserProfile 컴포넌트를 렌더링합니다.
에러 처리 추가하기
Suspense는 로딩 상태만 처리하기 때문에, 에러 처리는 ErrorBoundary
와 함께 사용해야 합니다
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<QueryClientProvider client={queryClient}>
<div className="app">
<h1>사용자 대시보드</h1>
<ErrorBoundary
fallback={<div className="error-message">사용자 정보를 불러오는 중 오류가 발생했습니다.</div>}
>
<Suspense fallback={<div className="loading-spinner">데이터 로딩 중...</div>}>
<UserProfile userId="1" />
</Suspense>
</ErrorBoundary>
</div>
</QueryClientProvider>
);
}
SWR과 함께 사용하기
SWR은 Vercel에서 만든 데이터 페칭 라이브러리로, "stale-while-revalidate" 전략을 사용합니다. SWR도 Suspense와 쉽게 통합됩니다
import useSWR from 'swr';
import { Suspense } from 'react';
// 데이터 페칭 함수
const fetcher = (...args) => fetch(...args).then(res => res.json());
function App() {
return (
<div className="blog-app">
<header>
<h1>내 블로그</h1>
</header>
<main>
<Suspense fallback={<div className="skeleton-list">게시물 로딩 중...</div>}>
<PostList />
</Suspense>
</main>
</div>
);
}
function PostList() {
// suspense 옵션을 true로 설정
const { data } = useSWR('/api/posts', fetcher, { suspense: true });
return (
<div className="post-grid">
{data.map(post => (
<article key={post.id} className="post-card">
<img src={post.coverImage} alt={post.title} />
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<div className="post-meta">
<span>작성자: {post.author}</span>
<span>작성일: {new Date(post.date).toLocaleDateString()}</span>
</div>
</article>
))}
</div>
);
}
SWR을 사용하면 캐시된 데이터가 있을 경우 즉시 표시하고, 백그라운드에서 최신 데이터를 가져오는 방식으로 동작합니다. 이는 사용자 경험을 크게 향상시킵니다.
useTransition과 함께 활용하기
React 18에서는 useTransition
훅이 도입되었는데, 이를 Suspense와 결합하면 더 나은 사용자 경험을 제공할 수 있습니다
import { useState, useTransition, Suspense } from 'react';
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<ProductSearch />
</QueryClientProvider>
);
}
function ProductSearch() {
const [searchTerm, setSearchTerm] = useState('');
const [isPending, startTransition] = useTransition();
const [currentSearchTerm, setCurrentSearchTerm] = useState('');
const handleSearch = (e) => {
// 입력값은 즉시 업데이트 (사용자 입력에 즉시 반응)
setSearchTerm(e.target.value);
// 검색 결과 업데이트는 트랜지션으로 처리 (우선순위 낮춤)
startTransition(() => {
setCurrentSearchTerm(e.target.value);
});
};
return (
<div className="product-search">
<div className="search-bar">
<input
type="text"
value={searchTerm}
onChange={handleSearch}
placeholder="제품 검색..."
className="search-input"
/>
{isPending && <span className="loading-indicator">검색 중...</span>}
</div>
<div className="results-container">
{currentSearchTerm ? (
<Suspense fallback={<ProductGridSkeleton />}>
<SearchResults searchTerm={currentSearchTerm} />
</Suspense>
) : (
<div className="empty-message">검색어를 입력하세요</div>
)}
</div>
</div>
);
}
function SearchResults({ searchTerm }) {
const { data } = useQuery({
queryKey: ['products', searchTerm],
queryFn: () => fetch(`/api/products?q=${searchTerm}`).then(res => res.json())
});
if (data.length === 0) {
return <div className="no-results">검색 결과가 없습니다</div>;
}
return (
<div className="product-grid">
{data.map(product => (
<div key={product.id} className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p className="price">{product.price.toLocaleString()}원</p>
<button className="cart-button">장바구니에 추가</button>
</div>
))}
</div>
);
}
function ProductGridSkeleton() {
// 제품 그리드 로딩 UI
return (
<div className="product-grid skeleton">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="product-card skeleton">
<div className="image-skeleton"></div>
<div className="title-skeleton"></div>
<div className="price-skeleton"></div>
<div className="button-skeleton"></div>
</div>
))}
</div>
);
}
이 예제에서 useTransition
은 두 가지 중요한 이점을 제공합니다:
- 사용자 입력은 즉시 반영되어 UI가 반응적으로 느껴짐
- 데이터 로딩은 낮은 우선순위로 처리되며, 로딩 중임을 표시하는 인디케이터 제공
이렇게 하면 사용자는 검색창에 입력할 때 화면이 멈추거나 버벅이는 느낌 없이 부드러운 경험을 할 수 있습니다.
Suspense의 내부 동작 원리
Suspense가 실용적인 측면에서 어떻게 사용되는지 살펴보았으니, 이제 내부적으로 어떻게 동작하는지 알아보겠습니다. Suspense의 작동 방식을 이해하면 더 효과적으로 활용할 수 있습니다.
1. 기본 메커니즘
Suspense는 React의 렌더링 과정과 밀접하게 통합되어 있습니다. 기본적인 동작은 다음과 같습니다:
- React가 컴포넌트 트리를 렌더링
- 특정 컴포넌트가 아직 준비되지 않은 데이터에 접근하려고 함
- 데이터 페칭 라이브러리(React Query, SWR 등)가 내부적으로 Promise를 throw
- React는 이 Promise를 포착하고 가장 가까운 Suspense 경계를 찾음
- Suspense 경계의 fallback이 렌더링됨
- Promise가 해결되면 React는 원래 컴포넌트를 다시 렌더링 시도
2. Suspense 컴포넌트의 렌더링 과정
Suspense 컴포넌트는 React의 reconciliation(재조정) 과정에서 특별하게 처리됩니다. 이 처리는 updateSuspenseComponent 함수에서 이루어집니다.
Suspense 컴포넌트 렌더링의 주요 단계는 다음과 같습니다
- 초기 마운트 시
- 자식 컴포넌트(primary children)와 대체 UI(fallback)를 처리합니다.
- 대체 UI를 보여줘야 하는지 여부를 결정합니다(showFallback 변수).
- showFallback이 true라면 mountSuspenseFallbackChildren 함수를 호출하여 대체 UI를 마운트합니다.
- 그렇지 않다면 mountSuspensePrimaryChildren 함수를 호출하여 일반 자식들을 마운트합니다.
- 업데이트 시
현재 상태와 다음 상태를 비교하여 네 가지 경우를 처리합니다
이전: fallback → 이후: fallback
이전: fallback → 이후: 콘텐츠
이전: 콘텐츠 → 이후: fallback
이전: 콘텐츠 → 이후: 콘텐츠
3. Offscreen 컴포넌트와 상태 유지
흥미로운 점은 Suspense가 실제 내용을 완전히 언마운트하지 않고 "숨긴다"는 것입니다. React는 내부적으로 Offscreen
이라는 특별한 컴포넌트를 사용합니다
// 추상화된 내부 구현
<Suspense fallback={<Loading />}>
<Content />
</Suspense>
// 실제 Fiber 트리 구성
<Suspense>
<Offscreen mode="hidden">
<Content />
</Offscreen>
<Fragment>
<Loading />
</Fragment>
</Suspense>
이 방식의 장점은
- 컴포넌트 상태가 보존됨
- 효과(effects)가 불필요하게 정리되고 다시 실행되는 것을 방지
- 데이터가 준비되었을 때 더 빠르게 전환 가능
4. Promise 처리 메커니즘
Promise가 throw되면 React는 다음과 같은 단계로 처리합니다:
handleError
함수가 호출되어 에러(이 경우 Promise)를 처리throwException
함수가 호출되어 컴포넌트 트리를 거슬러 올라가며 가장 가까운 Suspense 경계를 찾음- 찾은 Suspense에
ShouldCapture
플래그 설정 completeUnitOfWork
와unwindWork
함수를 통해 작업 완료 및 정리ShouldCapture
플래그가DidCapture
로 변환- Suspense 컴포넌트가 다시 렌더링되어 fallback 표시
이 모든 과정은 React 내부에서 자동으로 처리되므로, 개발자는 복잡한 내부 메커니즘을 걱정할 필요 없이 Suspense API를 사용할 수 있습니다.
Suspense의 동작 원리 이해하기
지금까지 살펴본 내용을 바탕으로 Suspense의 동작 원리를 정리해보겠습니다
- 선언적 로딩 상태: Suspense는 로딩 상태를 선언적으로 정의할 수 있게 해줍니다. 개발자는 "이 컴포넌트가 로딩 중일 때 이것을 보여줘"라고 간단히 지정할 수 있습니다.
- 렌더링 일시 중단: Suspense는 렌더링을 일시 중단하고 나중에 다시 시도하는 메커니즘을 제공합니다. 이는 "컴포넌트가 준비될 때까지 기다린다"는 개념을 구현합니다.
- Promise 기반: Suspense는 Promise를 통해 작동합니다. 준비되지 않은 데이터에 접근하면 Promise를 throw하고, 이 Promise가 해결되면 렌더링을 재개합니다.
- 컴포넌트 상태 보존: Suspense는 Offscreen 컴포넌트를 사용하여 일시 중단된 컴포넌트의 상태를 보존합니다. 이는 로딩이 완료된 후 사용자 경험을 개선합니다.
- 리액트의 이벤트 루프와 통합: Suspense는 React의 reconciliation 알고리즘과 긴밀하게 통합되어 있어, 효율적인 업데이트를 가능하게 합니다.
Suspense의 고급 패턴
Suspense를 효과적으로 활용하기 위한 고급 패턴을 살펴보겠습니다.
1. 중첩된 Suspense 사용하기
여러 비동기 데이터를 처리할 때 중첩된 Suspense를 사용하면 더 세밀한 로딩 상태를 제공할 수 있습니다:
function ProfilePage({ userId }) {
return (
<Suspense fallback={<FullPageSpinner />}>
<ProfileHeader userId={userId} />
<div className="profile-content">
<div className="main-content">
<Suspense fallback={<PostsSkeleton />}>
<UserPosts userId={userId} />
</Suspense>
</div>
<aside className="sidebar">
<Suspense fallback={<FriendsSkeleton />}>
<UserFriends userId={userId} />
</Suspense>
<Suspense fallback={<GroupsSkeleton />}>
<UserGroups userId={userId} />
</Suspense>
</aside>
</div>
</Suspense>
);
}
이 패턴은 "Waterfall" 문제를 방지하면서도 페이지의 각 부분에 맞춤형 로딩 상태를 제공합니다.
2. SuspenseList (실험적 기능)
여러 Suspense 컴포넌트의 표시 순서를 조정하고 싶다면 SuspenseList
를 사용할 수 있습니다 (아직 실험적 기능)
import { SuspenseList } from 'react';
function NewsFeed() {
return (
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<NewsSkeleton />}>
<LatestNews />
</Suspense>
<Suspense fallback={<WeatherSkeleton />}>
<WeatherWidget />
</Suspense>
<Suspense fallback={<StocksSkeleton />}>
<StockTicker />
</Suspense>
</SuspenseList>
);
}
revealOrder="forwards"
는 컴포넌트를 위에서 아래 순서로 표시하며, tail="collapsed"
는 아직 로딩 중인 항목에 대해 하나의 fallback만 표시합니다.
3. 서버 컴포넌트와 함께 사용하기
React 18과 함께 도입된 서버 컴포넌트는 Suspense와 특히 잘 작동합니다:
// ServerComponent.js - 서버에서 실행
export async function UserData({ userId }) {
// 서버에서 직접 데이터 가져오기 (클라이언트 번들에 포함되지 않음)
const userData = await fetchUserData(userId);
return (
<div className="user-data">
<h2>{userData.name}</h2>
<p>{userData.bio}</p>
</div>
);
}
// ClientComponent.jsx - 클라이언트에서 실행
'use client';
import { Suspense } from 'react';
import { UserData } from './ServerComponent';
export default function Profile({ userId }) {
return (
<div className="profile">
<Suspense fallback={<div>사용자 데이터 로딩 중...</div>}>
<UserData userId={userId} />
</Suspense>
</div>
);
}
서버 컴포넌트는 데이터 페칭을 서버에서 직접 수행하므로, 클라이언트로 전송되는 JavaScript 번들 크기를 줄이고 초기 로딩 성능을 향상시킵니다.
마무리
Suspense는 React 애플리케이션에서 데이터 로딩을 우아하게 처리할 수 있는 강력한 도구입니다. 이 글에서 다룬 내용을 요약하면:
- Suspense 기본 사용법: 컴포넌트가 준비될 때까지 fallback UI를 표시
- 데이터 페칭 라이브러리와 통합: React Query, SWR 등과 함께 사용
- useTransition 활용: 중요한 UI 업데이트와 덜 중요한 데이터 로딩 분리
- 내부 동작 원리: Suspense가 어떻게 Promise를 처리하고 컴포넌트를 관리하는지
- 고급 패턴: 중첩된 Suspense, SuspenseList, 서버 컴포넌트 통합
실제 프로젝트에서 Suspense를 활용하여 사용자 경험을 향상시켜 보세요. 로딩 상태를 더 세련되게 처리하고, 애플리케이션의 응답성을 개선할 수 있습니다. Suspense는 단순한 API 뒤에 복잡한 비동기 로직을 숨겨, 개발자가 더 선언적이고 직관적인 코드를 작성할 수 있게 해줍니다.