React|서버 상태와 비동기 데이터

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

4
선언적인 비동기 처리: 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>;
}

이런 코드를 "명령형"이라고 한다. 상태에 따라 무엇을 그려야 할지 하나하나 명령하고 있다.

만약 이 귀찮은 체크 로직을 전부 걷어내고, "데이터는 무조건 있다"고 가정하고 코드를 짤 수는 없을까?

리액트의 SuspenseError 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로 바꾸려고 한다. 하지만 주의할 점이 있다.

장점

  • 코드 가독성: 컴포넌트 내부에는 비즈니스 로직(Happy Path)만 남아서 매우 깔끔하다.
  • UX 일관성: 로딩 스피너의 위치와 모양을 상위에서 일괄적으로 관리할 수 있다.
  • Waterfall 방지: 여러 개의 비동기 컴포넌트를 하나의 Suspense로 묶으면 병렬로 처리되는 효과를 낼 수 있다.
  • 단점 및 주의사항

  • 사용자 경험: 화면 전체가 하얀색으로 변했다가 툭 튀어나오는 것보다, 기존 화면을 유지한 채 로딩 인디케이터만 보여주는 게 더 나을 때도 있다. (이때는 useTransition을 사용해야 한다.)
  • 에러 전파: 에러가 최상위까지 올라가면 앱 전체가 셧다운 될 수 있으므로, 적절한 위치에 Error Boundary를 배치해야 한다.
  • 핵심 정리

  • 명령형 vs 선언형: if (loading)은 명령형이고, <Suspense>는 선언형이다. 선언형이 가독성과 유지보수성이 좋다.
  • useSuspenseQuery: TanStack Query v5에서 Suspense를 트리거 하기 위해 사용하는 전용 훅이다. data의 존재를 보장한다.
  • Error Boundary: 컴포넌트 트리 하위에서 발생한 렌더링 에러를 포착하여 대체 UI를 보여준다.
  • 조합: SuspenseError Boundary를 조합하여 비동기 데이터의 3가지 상태(성공, 로딩, 에러)를 우아하게 분리할 수 있다.
  • 로딩과 에러를 처리하는 가장 모던한 방법을 익혔다. 이제 사용자 경험(UX)을 극대화할 차례다. 사용자가 "좋아요" 버튼을 눌렀을 때, 서버 응답을 기다리지 않고 즉시 하트를 채워주면 어떨까?

    "useMutation과 Optimistic Update: 사용자에게 0.1초의 딜레이도 느끼게 하지 마라" 편에서 계속된다.


    🔗 참고 링크

  • TanStack Query - Suspense
  • React Docs - Suspense
  • react-error-boundary