로딩은 Suspense에게, 에러는 ErrorBoundary에게 - FE14-Part4-Team5/reser-on-do GitHub Wiki
React에서 서버 데이터를 가져올 때 흔히 사용하던 방식은 다음과 같았습니다:
const { data, isLoading, isError } = useQuery(...);
if (isLoading) return <LoadingUI />;
if (isError) return <ErrorUI />;
return <Component data={data} />;
처음엔 직관적이고 단순했지만, 프로젝트가 커질수록 다음과 같은 문제들이 반복되었습니다:
- 로딩/에러 처리 로직이 모든 페이지에서 반복됨
- 페이지마다 UI 스타일이 달라 UX 일관성이 떨어짐
- 상태 분기 로직이 컴포넌트에 섞여 테스트와 유지보수가 어려워짐
→ 점점 일관성과 책임 분리에 대한 고민이 생겼습니다.
초기에는 useQuery
에 suspense: true
옵션을 붙여 구조 단순화를 시도했습니다.
하지만 다음과 같은 문제가 발생했습니다:
- fetch 실패 시 에러가
ErrorBoundary
로 전달되지 않음 - fallback UI는 보이지만 쿼리 재요청 흐름 연결이 불안정
- 상태 분기 로직은 여전히 컴포넌트 내부에 남아 있음
그 당시엔 왜 잘 작동하지 않는지 명확히 몰랐지만,
React Query v5에서 전용 훅이 도입되었다는 것을 알게된 후 그 원인을 명확히 알 수 있었습니다:
React Query v5에서는 다음 전용 훅 사용이 권장됩니다:
useSuspenseQuery()
useSuspenseInfiniteQuery()
이 훅들은 아래 옵션이 자동으로 내장되어 있습니다:
suspense: true
useErrorBoundary: true
→ 결국 우리가 기대한 구조는 전용 훅을 써야만 제대로 작동하는 것이었습니다.
따라서 Suspense
와 ErrorBoundary
로 감싸기만 해도
로딩/에러 처리가 자동화되고, 컴포넌트는 UI에 집중할 수 있게 됩니다.
"React는 비동기 에러를 기본적으로 처리하지 못한다"
React의 ErrorBoundary는 렌더링 중 동기 에러만 감지합니다.
즉, JSX 내undefined
접근 같은 에러는 잡을 수 있지만,
비동기 fetch 에러는 기본적으로 감지할 수 없습니다.
문제 | 설명 |
---|---|
ErrorBoundary가 catch하지 못함 | fetch 실패처럼 비동기 에러는 감지하지 못함 |
로딩/에러 UI 분기 중복 | 컴포넌트마다 isLoading , isError 처리 반복 |
코드 흐름 단절 | UI와 상태 로직이 섞여 테스트/유지보수 어려움 |
→ 그래서 우리는 useSuspenseQuery()
내부의 throw Error
, throw Promise
동작에 주목하게 되었습니다.
상태 | 내부 동작 |
---|---|
로딩 중 |
throw Promise → Suspense fallback 호출 |
에러 발생 |
throw Error → ErrorBoundary fallback 전달 |
useSuspenseQuery()는 fetch 실패 시 내부적으로 에러를 던지기 때문에, React의 Suspense와 ErrorBoundary를 통해 비동기 에러까지 일관된 방식으로 처리할 수 있습니다.
단순히 상태 분기를 줄이는 것 이상의 의미를 가집니다.
ErrorBoundary
fallback UI에서 resetErrorBoundary()
를 호출하면
컴포넌트가 리마운트되며 동일한 queryKey로 쿼리를 다시 실행합니다.
우리 팀은 fallback UI 내에 다시 시도 버튼을 두고 resetErrorBoundary() 호출 시 해당 쿼리를 다시 시도하게 할 수 있도록 적용했습니다.
const ErrorUI = ({
error,
resetErrorBoundary,
}: {
error: Error;
resetErrorBoundary: () => void;
}) => {
return (
<div>
...
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
);
};
→ 사용자가 직접 재시도할 수 있는 흐름을 만들 수 있습니다.
const { data, isLoading, isError } = useQuery(...);
if (isLoading) return <LoadingUI />;
if (isError) return <ErrorUI />;
return <Component data={data} />;
- 상태 분기 반복
- 컴포넌트가 모든 로직을 직접 책임
- 테스트 어려움
라우터:
<ErrorBoundary FallbackComponent={ErrorUI}>
<Suspense fallback={<MyProfileLoadingUI />}>
<MyProfilePage />
</Suspense>
</ErrorBoundary>
쿼리 훅:
export const useMyProfileQuery = () => {
return useSuspenseQuery<GetMeResponse>({
queryKey: ['myProfile'],
queryFn: () => usersService.getMe(),
});
};
- 상태 처리를 외부에 위임
- UI 구성에만 집중 가능
항목 | 기존 방식 | 전환 방식 (v5 기준) |
---|---|---|
로딩 처리 | isLoading |
Suspense fallback |
에러 처리 | isError |
ErrorBoundary fallback |
쿼리 재시도 | 수동 처리 |
resetErrorBoundary() 로 자동 재요청 |
코드 복잡도 | 높음 | 낮음, 책임 분리 |
유지보수성 | 낮음 | 높음 |
React Query 기준 | ❌ 비권장 | ✅ 권장 |
Suspense + ErrorBoundary + useSuspenseQuery
조합은 단순한 코드 최적화가 아닌, React에서의 비동기 상태 흐름을 구조화하는 전략적 선택입니다.
- ✅ 로딩/에러 상태를 외부로 분리
- ✅ 재사용성과 테스트 편의성 향상
- ✅ 사용자 주도적 재요청 흐름 지원
- ✅ React Query v5의 권장 방식에 부합