로딩은 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 일관성이 떨어짐
  • 상태 분기 로직이 컴포넌트에 섞여 테스트와 유지보수가 어려워짐

→ 점점 일관성과 책임 분리에 대한 고민이 생겼습니다.


🧩 개선 시도와 전환의 계기

초기에는 useQuerysuspense: true 옵션을 붙여 구조 단순화를 시도했습니다.
하지만 다음과 같은 문제가 발생했습니다:

  • fetch 실패 시 에러가 ErrorBoundary로 전달되지 않음
  • fallback UI는 보이지만 쿼리 재요청 흐름 연결이 불안정
  • 상태 분기 로직은 여전히 컴포넌트 내부에 남아 있음

그 당시엔 왜 잘 작동하지 않는지 명확히 몰랐지만,
React Query v5에서 전용 훅이 도입되었다는 것을 알게된 후 그 원인을 명확히 알 수 있었습니다:


🔧 왜 전용 훅을 사용해야 했는가?

React Query v5에서는 다음 전용 훅 사용이 권장됩니다:

useSuspenseQuery()
useSuspenseInfiniteQuery()

이 훅들은 아래 옵션이 자동으로 내장되어 있습니다:

suspense: true
useErrorBoundary: true

→ 결국 우리가 기대한 구조는 전용 훅을 써야만 제대로 작동하는 것이었습니다.

따라서 SuspenseErrorBoundary로 감싸기만 해도
로딩/에러 처리가 자동화되고, 컴포넌트는 UI에 집중할 수 있게 됩니다.


❗ ErrorBoundary만으로는 부족한 이유

"React는 비동기 에러를 기본적으로 처리하지 못한다"

React의 ErrorBoundary는 렌더링 중 동기 에러만 감지합니다.
즉, JSX 내 undefined 접근 같은 에러는 잡을 수 있지만,
비동기 fetch 에러는 기본적으로 감지할 수 없습니다.

문제 설명
ErrorBoundary가 catch하지 못함 fetch 실패처럼 비동기 에러는 감지하지 못함
로딩/에러 UI 분기 중복 컴포넌트마다 isLoading, isError 처리 반복
코드 흐름 단절 UI와 상태 로직이 섞여 테스트/유지보수 어려움

→ 그래서 우리는 useSuspenseQuery() 내부의 throw Error, throw Promise 동작에 주목하게 되었습니다.


✅ TanStack Query의 보완 역할

상태 내부 동작
로딩 중 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>
  );
};

→ 사용자가 직접 재시도할 수 있는 흐름을 만들 수 있습니다.


🔄 기존 방식 vs 전환 방식

기존 방식

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의 권장 방식에 부합

📚 참고 자료

⚠️ **GitHub.com Fallback** ⚠️