1편에서 우리는 React가 “무엇(What)을 기술하면 어떻게(How)는 런타임이 맡는” 선언적 모델임을 확인하였다.
UI를 상태의 함수, 즉 UI = f(state)로 바라보면, 컴포넌트는 가능한 성공 상태의 모습만 서술해야 한다. 비동기 대기나 실패와 같은 불완전한 흐름은 컴포넌트 경계 밖으로 밀어내는 것이 React가 지향하는 선언적 모델에 더 가깝다.
그렇다면 “로딩이나 에러 같은 불완전한 흐름은 어디서 처리해야 할까?”라는 문제가 남는다.
이 문제를 해결하기 위해 React 팀은 Suspense와 ErrorBoundary라는 기능을 제공한다. 이들은 단순한 편의 API가 아니라, “불완전한 상태를 컴포넌트 경계에서 선언적으로 다룬다”는 React 철학을 코드 차원에서 구현한 기능이다.
Suspense는 준비되지 않은 상태를 로딩 UI로 가시화하고, ErrorBoundary는 실패를 UI 모델의 일부로 흡수한다. 다시 말해, 성공 상태를 그리는 본연의 컴포넌트는 그대로 두되, 불완전성은 경계가 책임지는 구조로 전환한 것이다.
이번 글에서는 Suspense와 ErrorBoundary, 그리고 이를 확장한 토스의 suspensive 라이브러리를 통해, React가 “불확실성을 선언적으로 다루는 방식”을 살펴본다.
전통적인 방식에서는 개발자가 로딩과 에러를 명령형으로 직접 분기 처리한다.
if (loading) return <Loading />;
if (error) return <Error />;
return <Content />;
이 방식은 작을 때는 단순해 보이지만, 규모가 커질수록 중복과 혼잡이 쌓인다.
요점은 책임의 위치다. 명령형 분기는 “로딩/에러를 매 컴포넌트 내부로 끌어들이는 선택”이고, 이는 컴포넌트를 더 이상 state → UI의 순수 계산으로 두지 못하게 만든다. 반대로 경계는 불완전성을 컴포넌트 외부로 밀어낸다. 이를 통해 컴포넌트가 같은 상태에서 같은 UI를 산출하는 순수성을 유지할 수 있다.
React가 제시한 해법은 경계(Boundary)라는 개념이다.
즉, 개발자는 UI를 “성공 상태”만 묘사하고, 나머지는 경계가 대신 흡수한다.
컴포넌트는 입력(state/props)이 같으면 동일한 UI를 산출하고, 경계는 입력이 아직 준비되지 않았거나 실패한 경우를 대신 처리한다. 이렇게 내부의 순수성과 외부 세계의 불확실성이 분리될 때, 예측 가능성과 조합 가능성이 함께 얻어진다.
<Suspense fallback={<Loading />}>
<Profile />
</Suspense>
Suspense는 컴포넌트 트리에서 아직 준비되지 않은 데이터를 만났을 때, 해당 부분을 fallback UI로 대체하는 경계 역할을 한다. 이는 기존의 명령형 로딩 처리와는 완전히 다른 선언적 접근법이다.
결국 Suspense는 “로딩도 상태다”라는 선언이라고 볼 수 있다. 로딩을 제어 흐름이 아니라 표현 가능한 상태로 승격시켜, 분기를 걷어내고 선언을 남긴다.
function UserDashboard() {
return (
<div>
<Header /> {/* 항상 즉시 렌더링 */}
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile /> {/* 사용자 정보 로딩 중 */}
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<Posts /> {/* 게시글 로딩 중 */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* 댓글 로딩 중 */}
</Suspense>
</Suspense>
</div>
);
}
이런 구조를 통해 각 섹션은 독립적으로 로딩되며, 사용자는 준비된 부분부터 즉시 상호작용할 수 있다. 전체 페이지 로딩을 기다리는 대신, 점진적으로 콘텐츠가 나타나면서 자연스러운 몰입을 제공한다.
React 18의 Suspense는 서버에서도 작동하며, HTML을 부분적으로 스트리밍할 수 있다:
<Suspense fallback={<SearchSkeleton />}>
<SearchResults query={searchQuery} />
</Suspense>
사용자는 페이지 일부분부터 즉시 볼 수 있고, 나머지는 준비되는 대로 점진적으로 받아볼 수 있다.
예를 들어, 검색 결과 페이지를 생각해 보자. 상단의 검색창과 기본 레이아웃은 즉시 렌더링할 수 있지만, 실제 결과 리스트는 복잡한 DB 쿼리를 거쳐야 한다. 이때 Suspense 스트리밍을 사용하면, 사용자는 레이아웃과 스켈레톤 UI를 먼저 보며 기다릴 수 있고, 결과는 준비되는 대로 자연스럽게 채워진다.
Suspense는 "아직 준비되지 않은 상태"를 UI적으로 모델링한다. 로딩을 단순한 절차가 아니라 하나의 상태로 가시화하며, 클라이언트와 서버 양쪽에서 일관된 사고방식을 제시한다.
📖 참고
<ErrorBoundary fallback={<Error />}>
<Profile />
</ErrorBoundary>
ErrorBoundary는 자바스크립트 에러가 컴포넌트 트리 전체를 망가뜨리는 것을 방지하고, 에러 발생 구간만 fallback UI로 대체한다.
ErrorBoundary는 실패를 예외로 밀어 숨기지 않고, UI 모델의 일부로 정식 편입한다. 실패를 숨기지 않기에 복구 전략이 설계 가능해진다.
function App() {
return (
<div>
{/* 전역 에러 경계 - 예상치 못한 에러 포착 */}
<ErrorBoundary fallback={<GlobalErrorPage />}>
<Header />
{/* 지역 에러 경계 - 특정 기능별 에러 처리 */}
<ErrorBoundary fallback={<UserProfileError />}>
<UserProfile />
</ErrorBoundary>
<ErrorBoundary fallback={<PostsError />}>
<PostsList />
</ErrorBoundary>
<Footer />
</ErrorBoundary>
</div>
);
}
이런 구조를 통해 에러의 영향 범위를 제한하고, 각 섹션별로 적절한 복구 전략을 제공할 수 있다.
function CustomErrorBoundary({ children }) {
return (
<ErrorBoundary
fallback={({ error, resetError }) => {
if (error.name === 'NetworkError') {
return <NetworkErrorUI onRetry={resetError} />;
}
if (error.name === 'ValidationError') {
return <ValidationErrorUI error={error} />;
}
return <GenericErrorUI onRetry={resetError} />;
}}
>
{children}
</ErrorBoundary>
);
}
에러의 유형에 따라 다른 UI와 복구 옵션을 제공하여, 사용자에게 더 명확한 피드백과 해결 방안을 제시할 수 있다.
ErrorBoundary는 "실패조차 UI의 일부"라는 사고를 담고 있다. 에러는 숨겨야 할 예외가 아니라, 경계에서 명시적으로 다뤄야 하는 상태이다.
📖 참고
<Suspense fallback={<Loading />}>
<ErrorBoundary fallback={<Error />}>
<Profile />
</ErrorBoundary>
</Suspense>
Suspense와 ErrorBoundary를 함께 사용하면 비동기 데이터 처리의 모든 상태(로딩, 성공, 실패)를 선언적으로 다룰 수 있다.
function DataSection({ userId }) {
return (
<ErrorBoundary
fallback={({ error, resetError }) => (
<ErrorCard
message="사용자 정보를 불러올 수 없습니다"
onRetry={resetError}
/>
)}
>
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile userId={userId} />
<Suspense fallback={<PostsSkeleton />}>
<UserPosts userId={userId} />
</Suspense>
</Suspense>
</ErrorBoundary>
);
}
이 구조에서:
<ErrorBoundary fallback={<AppCrashPage />}>
<Suspense fallback={<AppLoadingSkeleton />}>
<ErrorBoundary fallback={<SectionErrorUI />}>
<Suspense fallback={<SectionLoadingSkeleton />}>
<CriticalUserData />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<OptionalErrorUI />}>
<Suspense fallback={<OptionalLoadingSkeleton />}>
<OptionalUserContent />
</Suspense>
</ErrorBoundary>
</Suspense>
</ErrorBoundary>
계층적 접근을 통해 핵심 기능과 부가 기능의 에러를 다르게 처리하며, 사용자 경험의 우선순위를 반영할 수 있다.
React에서 UI는 항상 "성공 상태"에 집중하고, 불완전성(로딩/에러)은 선언적 경계에서 다룬다. 이로써 개발자는 비즈니스 로직과 데이터 표현에만 집중할 수 있으며, 복잡한 상태 관리는 경계가 대신 처리한다.
📖 참고 | React Docs: Composing Suspense boundaries
지금까지 본 것처럼, React의 기본 Suspense와 ErrorBoundary는 로딩과 에러라는 불완전성을 선언적 경계로 다루도록 해준다.
하지만 여기서 한 걸음 더 들어가면, 또 다른 질문이 남는다. “로딩과 에러는 경계로 다룰 수 있게 되었는데, 그 원인인 데이터 패칭 자체는 어떻게 선언적으로 다룰 수 있을까?”
React 기본 기능만으로는 이 지점을 완전히 해결하기 어렵다. 예를 들어 React Query를 쓰더라도 여전히 이런 코드를 반복해야 한다
// 기존 React Query 방식 (명령형 제어)
function Profile() {
const { data, isLoading, isError } = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
});
if (isLoading) return <Loading />;
if (isError) return <Error />;
return <div>{data.name}</div>;
}
겉보기엔 단순해 보이지만, 페이지 곳곳에 이런 if (isLoading) / if (isError) 분기가 흩어져 쌓이면 로직과 UI가 섞이며 점점 명령형 제어 흐름으로 변해버린다.
Suspensive는 이 문제에 대한 해결책을 제시한다. Suspense 철학(“성공 상태만 서술”)을 데이터 패칭까지 확장하여, 로딩·에러·데이터 패칭 전체를 선언적으로 다룰 수 있게 해준다.
// 선언적 패턴 예시
function Profile() {
const { data } = useSuspenseQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
// suspense: true가 기본값이다
});
// 내부에 로딩 / 에러 분기문이 없다 — 성공 상태만 기술
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
그리고 컴포넌트 바깥에서는 단순히 경계를 선언해주면 된다:
import { Suspense, ErrorBoundary } from '@suspensive/react';
function App() {
return (
<ErrorBoundary
fallback={({ error, reset }) => (
<div>
<div>오류 발생: {error.message}</div>
<button onClick={reset}>다시 시도</button>
</div>
)}
>
<Suspense fallback={<Loading />}>
<Profile />
</Suspense>
</ErrorBoundary>
);
}
이제 컴포넌트는 성공 상태만 선언하고, 로딩/에러는 경계가 알아서 처리한다. 즉, Suspensive는 React가 제시한 “선언적 경계” 철학을 데이터 패칭까지 확장한 도구라 할 수 있다.
React의 기본 경계는 ‘로딩과 에러를 어디서 처리할지’는 다루지만, 데이터 패칭 자체는 별도로 처리해야 한다. Suspensive는 이 남은 절반을 메운다. 개발자는 성공 UI만 선언하고, 데이터의 준비·실패는 경계와 쿼리 훅의 합성 규칙으로 기술한다.
결과적으로 로딩·에러·성공 상태가 하나의 선언적 사이클로 연결된다.
Suspense와 ErrorBoundary는 로딩과 에러를 단순 조건문 분기에서 선언적 경계로 승격시킨다. 이는 곧, React가 불완전한 상태도 선언적으로 다룰 수 있음을 보여준다.
여기에 토스의 suspensive를 더하면, 로딩·에러·데이터 패칭까지 아우르는 보다 일관된 선언적 모델을 구축할 수 있다.
단, 모든 경우에 경계를 미세하게 쪼갠다고 해서 UX가 항상 좋아지는 것은 아니다. 너무 많은 Suspense/Boundary는 지나친 분절감을 만들 수 있다. 또한 ErrorBoundary는 비동기 에러, 이벤트 핸들러, 서버 예외를 자동 포착하지 않는다(별도 처리 필요). 선언은 원칙이고, 경계의 입도(granularity)와 포착 범위는 설계다.
📖 참고
다음 편(1-3)에서는 useSuspenseQuery를 중심으로, 데이터 패칭을 선언적 경계 안으로 끌어들이는 방법을 살펴본다.