선언적인 비동기 처리: Suspense와 Error Boundary로 로딩/에러 우아하게 처리하기

크리스의 컴포넌트 코드는 항상 산만하다. 정작 보여주고 싶은 건 '사용자 프로필'인데, 코드를 열어보면 로딩 체크와 에러 체크가 절반 이상을 차지한다.
// ❌ 명령형(Imperative) 방식: "어떻게(How)"에 집중
function UserProfile() {
const { data, isLoading, isError } = useQuery({ ... });
// 1. 로딩 체크
if (isLoading) return <Spinner />;
// 2. 에러 체크
if (isError) return <ErrorMessage />;
// 3. 데이터가 없을 때 체크
if (!data) return null;
// 4. 드디어 본론 (Happy Path)
return <div>{data.name}</div>;
}이런 코드를 "명령형"이라고 한다. 상태에 따라 무엇을 그려야 할지 하나하나 명령하고 있다.
만약 이 귀찮은 체크 로직을 전부 걷어내고, "데이터는 무조건 있다"고 가정하고 코드를 짤 수는 없을까?
리액트의 Suspense와 Error Boundary를 사용하면 이 꿈같은 "선언적(Declarative)" 처리가 가능하다.
1. Suspense: "준비될 때까지 기다려"
Suspense는 리액트 18부터 본격적으로 지원하는 기능으로, 컴포넌트가 "아직 렌더링 할 준비가 안 됐다(Suspend)"는 신호를 보내면, 리액트가 가장 가까운 부모의 Suspense를 찾아 대신 로딩 화면(Fallback)을 보여주는 기술이다.
TanStack Query와 Suspense 결합하기
v5부터는 useSuspenseQuery라는 전용 훅을 제공한다. 이 훅은 데이터가 없으면 컴포넌트 실행을 '일시 정지'시킨다. isLoading을 반환하지도 않는다. 왜냐하면 이 줄을 통과했다면 데이터는 무조건 존재하기 때문이다.
// ✅ 선언적(Declarative) 방식: "무엇(What)"에 집중
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile() {
// isLoading, isError가 없다. data는 undefined가 아님을 보장받는다.
const { data } = useSuspenseQuery({
queryKey: ['user'],
queryFn: fetchUser
});
return <div>{data.name}</div>;
}이제 부모 컴포넌트에서 로딩 처리를 책임진다.
// ParentComponent.tsx
import { Suspense } from 'react';
function App() {
return (
// 자식이 준비될 때까지 이 스피너를 보여준다.
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
);
}이렇게 하면 로딩 상태를 컴포넌트마다 일일이 구현하지 않고, 원하는 구역(Section) 단위로 묶어서 관리할 수 있다.
2. Error Boundary: "에러는 위에서 잡는다"
로딩뿐만 아니라 에러 처리도 마찬가지다. 자바스크립트의 try-catch가 에러를 상위로 전파하듯이, 리액트 컴포넌트에서 발생한 에러도 상위로 거품처럼 올라간다(Bubbling).
이것을 포착해서 대신 에러 화면을 보여주는 것이 Error Boundary다. 직접 구현하기보다는 검증된 라이브러리인 react-error-boundary를 사용하는 것이 좋다.
// App.tsx
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<ErrorBoundary fallback={<div>에러가 발생했습니다!</div>}>
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
);
}다시 시도하기 (Reset)
에러 화면에 "다시 시도" 버튼이 없다면 사용자는 답답할 것이다. QueryErrorResetBoundary를 사용하면 에러 발생 시 쿼리를 재설정(Reset)하고 다시 요청을 보낼 수 있다.
// App.tsx
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset} // 쿼리 재설정 함수 연결
fallbackRender={({ resetErrorBoundary }) => (
<div>
에러 발생!
{/* 버튼을 누르면 쿼리가 초기화되고 다시 Suspense가 작동함 */}
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
)}
>
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}3. 선언적 처리의 장단점
크리스는 모든 코드를 Suspense로 바꾸려고 한다. 하지만 주의할 점이 있다.
장점
단점 및 주의사항
핵심 정리
로딩과 에러를 처리하는 가장 모던한 방법을 익혔다. 이제 사용자 경험(UX)을 극대화할 차례다. 사용자가 "좋아요" 버튼을 눌렀을 때, 서버 응답을 기다리지 않고 즉시 하트를 채워주면 어떨까?
"useMutation과 Optimistic Update: 사용자에게 0.1초의 딜레이도 느끼게 하지 마라" 편에서 계속된다.