웹페이지에서는 데이터 요청 상태에 따라 UI 는 다른상태를 보여줘야한다
데이터가 패칭중일 때 로딩UI, 에러가 났을 때 에러 UI 가 있다.
이 상황을 처리하기 위해 다양한 방법을 사용할 수 있는데, 그 중 Suspense 와 Error Boundary 를 활용한 선언전 데이터 패칭 처리 방법을 알아보고자 한다.
전통적인 데이터 패칭 처리
전통적으으로 데이터 패칭은 컴포넌트 내부에서 data loading, error 의 상태를 사용하여 반환되는 컴포넌트에 대한 분기 처리로 이루어져있다.
비동기 함수 queryFn 가 데이터 요청 동안 isLoading,error 상태에 따라 로딩 중일 땐 <Loader/> 를 반환하고 에러가 발생했을 땐 <Error/>를 분기 처리하여 반환하고 사용자에게 정확한 정보를 전달한다.
const SampleContents = () => {
const [sampleDatas, setSampleDatas] = useState();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState();
useEffect(() => {
(async () => {
try {
setIsLoading(true); // isLoading 상태변경
const { data } = await queryFn(); // axios get 요청 예시 함수
setSampleDatas(data);
} catch (e) {
setError(e);
} finally {
setIsLoading(false); // 성공이든 실패이든 isLoading은 false
}
})();
}, []);
if (isLoading) <Loader />;
if (error) <Error error={error} />;
return (
<div>
{sampleDatas.map((data) => (
<Content data={data} />
))}
</div>
);
};
export default SampleContents;
Tanstack React Query
React-Query 는 서버 상태를 효과적으로 관리할 수 있는 캐싱 라이브러리이다.
React-Query API 들이 제공하는 속성을 활용하면 좀 더 간편하게 비동기 로직을 처리할 수 있다.
const SampleContents = () => {
const {
data: sampleDatas,
isLoading,
error,
} = useQuery({ queryKey, queryFn });
if (isLoading) <Loader />;
if (error) <Error error={error} />;
return (
<div>
{sampleDatas.map(data => (
<Content data={data} />
))}
</div>
);
};
export default SampleContents;
React Query 의 API useQuery 가 반환하는 isLoading,error 를 통해 useEffect 를 제거하고 useState 를 줄이는 등 컴포넌트 내부 로직을 조금 더 간소화하여 첫번쨰 예시와 마찬가지로 데이터 요청 상태에 따라 반환되는 컴포넌트 렌더링 여부를 작성할 수 있다.
선언적 데이터 패칭
선언적 데이터 패칭은 React 의 주요 개념인 선언적 프로그래밍에 기반한다.
앞선 두 예시 (전통적 방식, useQuery)는 여전히 명령형 처리에 가깝다.
컴포넌트 안에서 로딩 상태일 때 이걸 보여주고 에러 상태일 때 저거 보여줘 라고 일일이 분기 처리 명령을 해야하기 떄문이다.
하지만 선언적 방식에서는 컴포넌트가 데이터가 성공적으로 도착했을 때의 UI 만 신경쓴다
에러 상태이거나 로딩과 같은 예외 상황을 처리하는 것은 컴포넌트 외부 즉 부모가 책임지도록 하는 것이다.
이를 통해 복잡도를 낮추고, UI 가 무엇을 보여줄지에 집중하여 정의할 수 있다.
이를 선언적 처리를 가능하게 하는 도구가 바로 suspense 이다.
1. 정의
Suspense란?
Suspense 는 자식 트리가 로딩 중일 때(데이터가 아직 안 왔을 때), 실제 UI 대신 대체 UI(fallback)를 보여주는 React 컴포넌트다.
사용하는 이유
- 로딩 상태를 관리하는 코드를 최소화
- 비동기 데이터 로딩을 더 직관적이고 간단하게 처리
- tanstack query 의 isLoading, isError 등의 상태를 관리하지 않고 suspense 와 Error Boundary 를 사용하여 자동화된 에러 처리를 할 수 있다 .
import { Suspense } from 'react';
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
핵심은 로딩 상태를 컴포넌트 내부(if (isLoading))에서 처리하는 대신, 부모의 경계에서 선언적으로 처리하게 해준다는 점이다.
Error Boundary란?
Error Boundary는 컴포넌트에서 발생하는 에러를 잡아서 UI 상에 표시하는 기능을 제공하는 컴포넌트이다. Suspense 로 로딩 상태를 처리할 때 비동기 작업에서 에러가 발생할 수 있으므로, Error Boundary 를 추가하여 에러 처리를 한다.
2. Suspense 제대로 이해하기
2-1. Suspense가 아무 비동기나 잡아주지는 못한다
Suspense를 활성화하는 데이터 소스는 제한적이다.
- React.lazy로 코드 스플리팅된 컴포넌트 로딩
- Suspense를 지원하는 프레임워크(Next.js, Relay 등)의 데이터 페칭
- use(resource)로 캐시된 Promise 읽기
2-2. Suspense가 바꿔주는 것: 로딩 책임의 위치
기존의 명령형 방식은 보통 이렇다. 컴포넌트가 직접 데이터를 확인하고 로딩을 보여준다.
function Summary() {
const [data, setData] = useState<Summary | null>(null);
useEffect(() => {
fetchSummary().then(setData);
}, []);
// 컴포넌트가 직접 로딩을 신경 써야 함
if (!data) return <Loading />;
return <Chart data={data} />;
}
반면 Suspense 방식은 로딩 UI를 어디서 보여줄지를 상위 경계로 위임한다.
<Suspense fallback={<ChartSkeleton />}>
<SummaryChart />
</Suspense>
이렇게 하면 얻는 장점
- 차트 컴포넌트는 데이터가 무조건 있다고 가정한 렌더링에만 집중할 수 있다.
- 로딩 UX를 컴포넌트 밖에서 일관되게 관리할 수 있다.
2-3. 꼭 알아야 하는 Suspense 주의사항
공식 문서에서 강조하는 몇 가지 주의점이 있다.
- 초기화: 첫 마운트 전에 일시 중단된 트리의 state는 보존되지 않는다. 데이터가 준비되면 React는 다시 처음부터 렌더링을 시도한다.
- 업데이트: 이미 보여주던 UI가 업데이트 중에 다시 suspend되면, 기본적으로 fallback 화면으로 되돌아갈 수 있다. (이때 startTransition을 쓰면 UX를 부드럽게 유지할 수 있다.)
- 중첩: fallback 자체가 또 suspend되면, 더 바깥쪽 부모 Suspense가 그 fallback을 대신 처리한다.
3. Error Boundary 제대로 이해하기
3-1. Error Boundary가 잡는 에러 범위
Error Boundary는 자식 트리에서 발생한 에러 중에서도 다음 상황만 잡는다.
- 렌더링 도중
- 라이프사이클 메서드 실행 중
- Constructor(생성자) 실행 중
사용 예시
import * as React from 'react';
class ErrorBoundary extends React.Component<
{ fallback: React.ReactNode; children: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: unknown, info: unknown) {
// 여기서 에러 로깅을 수행한다.
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
getDerivedStateFromError나 componentDidCatch 메서드를 활용하여 에러를 감지하고, fallback UI 를 표시하는 컴포넌트를 만들어서 사용할 수 있다.
const App = () => {
return (
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<Router />
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
);
};
Suspense 로 비동기 작업의 로딩 상태를 처리하는 동안, 에러가 발생하면, ErrorBoundary 에서 처리할 수 있게 한다.
3-2. Error Boundary의 한계
Error Boundary는 다음 에러들은 잡지 못한다.
- onclick 과 같은 이벤트 핸들러 내부 에러
- 비동기 콜백
- SSR(서버 사이드 렌더링)에서 발생한 에러
- Error Boundary 자기 자신에서 터진 에러
그래서 실무에서는 보통 두 가지 전략을 쓴다.
- 에러를 렌더링 단계로 전파되는 형태로 던지게 만든다. (TanStack Query가 이걸 아주 잘해준다.)
- 이벤트나 비동기 에러는 별도의 try-catch와 토스트 UI, 로깅으로 처리한다.
4. Suspense + ErrorBoundary
결국 이 둘을 합쳐서 처리하는 경우가 많다. Suspense가 로딩 처리를, Error Boundary가 에러 처리를 맡도록 역할을 분리하는 것이다.
이렇게 하면 페이지마다 isLoading, isError 분기문이 많아지는 것을 막고, 앱 전체의 로딩/에러 UX를 한 군데에서 표준화할 수 있다.
보통 이 조합을 팀마다 AsyncBoundary 같은 이름의 컴포넌트로 추상화해서 사용한다.
5. react-error-boundary로 함수형 컴포넌트처럼 쓰기
0) 왜 라이브러리를 쓰나?
React의 Error Boundary는 원래 클래스 컴포넌트로만 만들 수 있다. 그리고 앞서 말했듯 잡을 수 있는 에러 범위도 한정적이다.
그래서 실무에서는 다음 이유들로 react-error-boundary 라이브러리를 많이 사용한다.
- ErrorBoundary를 재사용 가능한 컴포넌트로 쉽게 만들 수 있다.
- 함수형 컴포넌트처럼 선언적으로 사용할 수 있다.
- 리셋이나 재시도 UX까지 표준화하기 편하다.
1) 가장 단순한 예시
fallback (정적 UI) : 그냥 깨지지 않게만 가장 단순하게 고정된 UI만 보여줄 때 쓴다.
import { ErrorBoundary } from "react-error-boundary";
function App() {
return (
<ErrorBoundary fallback={<div>문제가 발생했어요. 잠시 후 다시 시도해주세요.</div>}>
<Dashboard />
</ErrorBoundary>
);
}
- 언제 쓰나? 페이지 전체가 깨졌을 때 단순 안내 문구만 띄우거나, 재시도 버튼이 굳이 필요 없을 때.
- 한계: 에러 객체(error)를 못 받아서 디테일한 UI를 못 만들고, 재시도 기능(resetErrorBoundary)을 붙이기 어렵다.
2) FallbackComponent
react-error-boundary는 fallback, FallbackComponent, fallbackRender 세 가지 방식을 제공하는데, 그중 가장 추천하는 방식이다.
FallbackComponent — 재사용 가능한 에러 UI 컴포넌트 : 이 컴포넌트는 error와 resetErrorBoundary를 props로 받는다.
import type { FallbackProps } from "react-error-boundary";
function HealthErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert">
<p>데이터를 불러오지 못했어요.</p>
<pre style={{ whiteSpace: "pre-wrap" }}>{error.message}</pre>
<button onClick={() => resetErrorBoundary()}>다시 시도</button>
</div>
);
}
사용할 때는 이렇게 넘겨주면 된다.
import { ErrorBoundary } from "react-error-boundary";
function HealthPage() {
return (
<ErrorBoundary FallbackComponent={HealthErrorFallback}>
<HealthDashboard />
</ErrorBoundary>
);
}
- 언제 쓰나? 서비스 전역에서 공통 에러 UI를 재사용하고 싶을 때. 로그나 스타일을 통일하기 아주 좋다.
3) fallbackRender
렌더 프롭(Render Prop) 형태다.
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>추천 식단을 불러오지 못했어요.</p>
<button onClick={() => resetErrorBoundary()}>재시도</button>
</div>
)}
>
<NutritionCardBody />
</ErrorBoundary>
- 언제 쓰나? 해당 컴포넌트에서만 쓰는 작은 fallback UI가 필요하거나, 현재 렌더링 컨텍스트를 섞어서 보여줘야 할 때. 다만 코드가 길어지면 유지보수가 힘드니 FallbackComponent로 분리하는 게 낫다.
4) onError
에러가 잡혔을 때 Sentry 같은 곳으로 로그를 보내려면 onError를 쓴다.
<ErrorBoundary
FallbackComponent={HealthErrorFallback}
onError={(error, info) => {
// Sentry.captureException(error, { extra: info });
console.error("Captured:", error);
}}
>
<Routes />
</ErrorBoundary>
- 우리가 만드려는 서비스처럼 의료 관련 서비스라면, 에러 로그에 사용자의 민감한 데이터가 섞여 들어가지 않도록 마스킹 처리에 신경 써야 한다.
5) 재시도 UX의 핵심: resetErrorBoundary와 resetKeys
resetErrorBoundary는 말 그대로 에러 상태를 초기화하고 다시 렌더링을 시도하게 해준다.
그런데 더 중요한 건 resetKeys다. 사용자가 탭을 이동하거나 필터를 바꿨는데도, 계속 에러 화면만 떠 있으면 안 되니까, 특정 상태가 바뀌면 자동으로 리셋되게 해주는 기능이다.
function HealthDetailPage({ userId, itemId }) {
return (
<ErrorBoundary
FallbackComponent={HealthErrorFallback}
resetKeys={[userId, itemId]} // userId나 itemId가 바뀌면 에러 상태 자동 초기화
>
<HealthDetailContent userId={userId} itemId={itemId} />
</ErrorBoundary>
);
}
6) 이벤트/비동기 에러 잡기: useErrorBoundary
앞서 말했듯 Error Boundary 는 이벤트 핸들러(onClick)나 비동기 에러를 잡지 못한다.
이를 해결하기 위해 useErrorBoundary 훅을 쓴다.
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
function SaveButton() {
const { showBoundary } = useErrorBoundary();
const onClick = async () => {
try {
await fetch("/api/save", { method: "POST" }).then((r) => {
if (!r.ok) throw new Error("저장 실패");
});
} catch (e) {
// 이벤트/비동기 에러는 원래 못 잡지만, showBoundary로 강제로 넘김
showBoundary(e as Error);
}
};
return <button onClick={onClick}>저장</button>;
}
- 언제 유용할까? 저장/삭제 버튼 클릭 실패 시 단순히 토스트만 띄우는 게 아니라, 해당 영역을 일관된 에러 UI로 바꿔버리고 싶을 때 사용한다.
6. TanStack Query
TanStack Query는 Suspense와 Error Boundary 연동을 공식적으로 지원한다.
6-1. queryFn에서 에러는 반드시 throw
TanStack Query가 에러 상태로 판단하게 하려면, queryFn이 반드시 throw 하거나 rejected Promise를 반환해야 한다.
특히 fetch API는 404, 500 에러에도 reject되지 않으므로 직접 에러를 던져주는 헬퍼 함수가 필요하다.
export async function fetchJSON<T>(input: RequestInfo, init?: RequestInit) {
const res = await fetch(input, init);
if (!res.ok) {
throw new HttpError(res.status, await res.text());
}
return (await res.json()) as T;
}
6-2. useSuspenseQuery + QueryErrorResetBoundary
useSuspenseQuery란?
suspense 는 하나의 api 요청이 해결되기 전까지 다음 작업을 진행하지 않기 때문에, 하나의 요청이 끝난 후에 다음 요청이 발생하는 순차적인 작업 흐름을 가진다.
suspense 내부에 여러 API 요청이 발생할 경우, 각 요청이 병렬적으로실행되지 않고 순차적으로 해결되며 네트워크 병목이 일어난다.
이 문제를 해결하기 위해서 Tanstack 에서 제공하는 useSuspenseQueries 를 사용하면 된다.
useSuspenseQueries 는 여러 API 요청을 병렬적으로 처리할 수 있도록 도와주고, 이를 통해 네트워크 병목을 방지하여 성능을 최적화할 수 있따.
import { useSuspenseQuery } from "@tanstack/react-query";
import queryKey from "./queryKey.keys";
import api from "./../services/api";
const useGetRestaurantsQuery = () => {
return useSuspenseQuery({
queryKey: queryKey.default.restaurants,
queryFn: api.getRestaurants,
});
};
TanStack Query 공식 문서가 제시하는 핵심은 쿼리의 재시도 로직을 Error Boundary와 연결하는 것이다. 그 역할을 QueryErrorResetBoundary가 해준다.
import { PropsWithChildren, Suspense } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
export function AsyncBoundary({ pendingFallback, rejectedFallback, children }: AsyncBoundaryProps) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
// 1. 쿼리 리셋 함수를 ErrorBoundary의 onReset에 연결
<ErrorBoundary onReset={reset} fallbackRender={rejectedFallback}>
{/* 2. 로딩은 Suspense가 처리 */}
<Suspense fallback={pendingFallback}>
{children}
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
이 구조를 사용하면 에러 발생 시 사용자가 다시시도버튼을 누를 때, TanStack Query의 캐시를 초기화하고(reset) 데이터를 다시 불러오는 흐름이 자연스럽게 완성된다.
7. 서비스에 적용해보기
7-1. 차트 영역만 안전하게 감싸기
페이지 전체를 죽이지 않고, 로딩이 오래 걸리거나 에러가 잦은 차트 영역만 감싸는 패턴이다.
import { AsyncBoundary } from './AsyncBoundary';
function ChartSection({ userId }) {
return (
<AsyncBoundary
pendingFallback={<ChartSkeleton />}
rejectedFallback={({ resetErrorBoundary }) => (
<div className="card">
<p>차트 데이터를 불러오지 못했어요.</p>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
)}
>
<HealthRadarChart userId={userId} />
</AsyncBoundary>
);
}
7-2. 페이지 레벨 vs 컴포넌트 레벨 나누기
React 문서에서도 모든 컴포넌트를 각각 감싸지 말고, 어디에 경계를 둘지 고민해야한다고 나와있다. 우리 서비스라면 이렇게 나눌 수 있다.
- 페이지 레벨: 진입 자체가 실패했다 → 전체 에러 화면 (네트워크 확인, 새로고침 유도)
- 컴포넌트 레벨: 추천 카드 하나만 실패했다 → 나머지는 정상적으로 보여주고, 그 카드만 재시도 버튼 표시
이렇게 계층화하면 전체가 흰 화면이 되는 최악의 상황을 막을 수 있고 사용자 경험을 높일 수 있다.
https://tanstack.com/query/latest/docs/framework/react/reference/useSuspenseQueries
useSuspenseQueries | TanStack Query React Docs
tsx const result = useSuspenseQueries(options) Options The same as for , except that each query can't have: suspense throwOnError enabled placeholderData Returns Same structure as , except that for ea...
tanstack.com
React | TanStack Query React Docs
tanstack.com
Suspense | TanStack Query React Docs
React Query can also be used with React's Suspense for Data Fetching APIs. For this, we have dedicated hooks: Additionally, you can use the useQuery().promise and React.use() (Experimental) When using...
tanstack.com
'FE' 카테고리의 다른 글
| 올바른 HTTP 클라이언트 선택하기 - fetch, Axios, Ky (1) | 2026.01.05 |
|---|---|
| 의료 서비스 - 보안 강화 방법 (0) | 2026.01.03 |
| 건강검진 결과 시각화 : 차트 라이브러리 선정 및 구현 전략 (0) | 2026.01.01 |
| PDF 업로드가 느린 이유와 런칭을 위한 성능 최적화 (0) | 2025.12.30 |
| TanStack Query 낙관적 업데이트: onMutate + Rollback을 찜 해제에 적용 (0) | 2025.12.19 |