1-2편 useSuspenseQuery — 데이터 패칭까지 선언적으로

JIIJIIJ·2025년 9월 12일
1

React

목록 보기
30/35
post-thumbnail

들어가며 — 1–2편에서 이어가기

1–2편에서는 “로딩/에러 같은 불완전성은 컴포넌트 바깥의 경계(Suspense, ErrorBoundary)로 넘긴다”는 철학을 다뤘다. 이 철학 덕분에 컴포넌트는 성공 상태만 책임지고, 대기와 실패는 경계가 대신 담당하게 된다.

이제 남은 퍼즐은 데이터 패칭 자체다. 로딩과 에러는 경계로 다루게 되었는데, 정작 그 원인인 패칭 로직은 왜 여전히 컴포넌트 안에서 isLoading, isError로 분기하고 있을까?

이 문제에 대해 토스(Toss) 프론트엔드 팀의 Suspensive 라이브러리가 선구적 해결책을 제시했다. 이 아이디어는 이후 TanStack Query에도 통합되어 useSuspenseQuery라는 공식 API로 발전한다.

📖 참고 | Suspensive, TanStack Query v5: Suspense 가이드

기존 데이터 패칭의 명령형 흔적

function Profile() {
  const { data, isLoading, isError } = useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
  });

  if (isLoading) return <Loading />;
  if (isError) return <Error />;
  return <ProfileView user={data} />;
}

표면적으로는 간단하지만, 컴포넌트 곳곳에서 같은 분기가 반복되고 UI = f(state)라는 선언적 모델이 흐려진다. 무엇보다 1–2편의 경계 철학과도 어긋난다.


선언적 연장선: useSuspenseQuery로 경계에 직접 연결하기

TanStack Query(v5)는 useSuspenseQuery를 공식 지원한다. Suspensive가 제시한 패턴을 TanStack Query도 받아들여, 이제 패칭 단계에서 바로 경계와 연결된다. 핵심은 다음과 같다.

  • 로딩이면 Promise를 throw<Suspense>fallback 렌더
  • 에러면 Error를 throw<ErrorBoundary>가 에러 UI 렌더
  • 성공이면 data만 반환 → 컴포넌트는 성공 UI만 선언
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useSuspenseQuery } from '@tanstack/react-query';

function ProfileSection() {
  const { data } = useSuspenseQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
  });
  return <ProfileView user={data} />;
}

export function Page() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <ErrorBoundary fallbackRender={() => <ProfileError />}>
        <ProfileSection />
      </ErrorBoundary>
    </Suspense>
  );
}

내부 동작 (개념 요약)

switch (query.state.status) {
  case 'pending':  throw query.promise;          // Suspense → fallback  
  case 'error':    throw query.state.error;      // ErrorBoundary → 에러 UI  
  case 'success':  return { data: query.state.data }; // 성공만 컴포넌트로  
}

즉, 컴포넌트는 성공 UI만 기술하고, 로딩/에러는 경계가 처리한다. 1–2편의 경계 철학을 데이터 패칭 단계까지 일관되게 적용해 주는 연결점이다.

다만 실제 구현은 더 정교하다.

  • 캐시에 데이터가 없고 fetch 중일 때만 Promise를 던져 Suspense로 위임한다.
  • 데이터가 이미 있으면(stale 포함) 화면은 유지하고 백그라운드 갱신(fetchStatus: 'fetching')만 수행한다.
  • 에러도 Error를 던져 경계로 위임하지만, 중간 refetch 실패는 기존 데이터를 유지한다.

※ 위 코드는 실제 구현을 그대로 옮긴 것이 아니라, 이해를 돕기 위한 단순화된 개념 요약이다.


useSuspenseQuery 사용 원칙

이처럼 내부 동작은 “성공만 컴포넌트, 대기·실패는 경계”라는 철학을 구현한다.
이를 코드에서 일관되게 유지하려면 몇 가지 사용 원칙을 지켜야 한다.

1) 성공 상태만 컴포넌트에 남긴다

  • 로딩은 <Suspense fallback>
  • 에러는 <ErrorBoundary>
  • 컴포넌트 내부에서는 data만 취급

이 원칙은 Suspense 모드의 핵심이다. (TanStack)


2) 훅은 최상위에서, 같은 순서로 호출한다

  • if/for/try 안에 훅을 넣지 않는다.
  • 조건은 렌더 계층에서 분기한다.
// 좋은 예 (✅) — 상위에서 경계로 감싸고, 하위는 성공 UI만
function Page({ userId }) {
  return userId ? (
    <ErrorBoundary fallbackRender={() => <ProfileError />}>
      <Suspense fallback={<ProfileSkeleton />}>
        <ProfileSection userId={userId} />
      </Suspense>
    </ErrorBoundary>
  ) : (
    <EmptyState />
  );
}

function ProfileSection({ userId }) {
  // 훅은 항상 최상위에서 호출 → React 규칙 준수
  // 조건은 렌더 계층에서 처리 → Suspense/ErrorBoundary 흐름 일관
  const { data } = useSuspenseQuery({
    queryKey: ['profile', userId],
    queryFn: () => fetchProfile(userId),
  });
  return <ProfileView user={data} />;
}

왜 조건문 안에 훅을 넣으면 안 될까?

  • React 훅은 렌더마다 같은 순서로 호출되어야 한다.
  • 조건문 안에 넣으면 호출 순서가 달라져 내부 상태 매칭이 꼬인다.
  • 특히 Suspense 환경에서는 훅이 호출되지 않으면 Promise/Error가 경계에 닿지 않아 흐름이 깨질 수 있다.

🚀 대안: Suspensive의 컴포넌트 API

조건부 데이터 패칭이 필요한 상황에서 “훅은 조건문에 넣을 수 없다”는 제약은 꽤 불편하다.
여기서 Suspensive 팀이 제안한 <SuspenseQuery /> 컴포넌트 API는 단순한 보조 도구가 아니라,
조건부 데이터 패칭을 선언적으로 표현할 수 있게 해주는 강력한 해법이다.

{userId && (
  <SuspenseQuery
    queryKey={['profile', userId]}
    queryFn={() => fetchProfile(userId)}
  />
)}
  • 조건부 훅 호출 → 조건부 컴포넌트 렌더링으로 문제를 치환한다.
  • 훅은 내부에서 항상 호출되고, 바깥에서는 렌더 여부만 결정하므로 Rules of Hooks를 위반하지 않는다.
  • 컴포넌트 자체가 Suspense/ErrorBoundary 위임 패턴을 내장하고 있어, 성공 UI만 선언하는 철학을 손쉽게 구현할 수 있다.

즉, <SuspenseQuery />는 단순한 우회책이 아니라 훅 규칙과 선언적 UI 철학을 동시에 지켜주는 안전한 패턴이다.


3) 조건부 데이터를 다뤄야 할 땐 컴포넌트 분리 또는 queryFn 내부 처리

최신 버전에서는 useSuspenseQueryenabled 옵션이 허용되지 않는 경우가 많다 — 예를 들면, 공식 문서상 enabled 옵션이 제외된 형태로 설명됨. (TanStack)
GitHub 토론에서도 “enabled is not included because … if you call useSuspenseQuery, data will be T, not T | undefined”라는 설명이 있다. (GitHub)

따라서 조건부 데이터 요청이 필요할 경우 아래 같은 패턴을 사용하는 것이 권장된다:

  • 패턴 A: 컴포넌트 분리
    조건별로 렌더링을 분리해, 조건이 만족하는 구간에서만 useSuspenseQuery를 호출한다.

    function Wrapper({ userId }: { userId?: string }) {
      if (!userId) {
        return <EmptyState />;
      }
      return (
        <ErrorBoundary fallbackRender={() => <ProfileError />}>
          <Suspense fallback={<ProfileSkeleton />}>
            <ProfileWithId userId={userId} />
          </Suspense>
        </ErrorBoundary>
      );
    }
    
    function ProfileWithId({ userId }: { userId: string }) {
      const { data } = useSuspenseQuery({
        queryKey: ['profile', userId],
        queryFn: () => fetchProfile(userId),
      });
      return <ProfileView user={data} />;
    }
    
  • 패턴 B: queryFn 내부 조건 처리
    훅은 항상 호출하되, queryFn 내부에서 조건을 검사해 “유효하지 않은 경우는 즉시 리턴”하거나 기본 값을 반환하는 방식.

    function ProfileSection({ userId }: { userId?: string }) {
      const { data } = useSuspenseQuery({
        queryKey: ['profile', userId],
        queryFn: () => {
          if (!userId) {
            // 조건이 유효하지 않으면 아무 요청도 하지 않음
            // 여기서는 기본값 혹은 빈 객체를 리턴하거나 throw 하지 않음
            // 주의: 반환 타입과 downstream 처리를 고려해야 함
            return Promise.resolve(null as any);
          }
          return fetchProfile(userId);
        },
      });
    
      if (!userId) {
        return <EmptyState />;
      }
      return <ProfileView user={data} />;
    }
    

4) 쿼리 병렬 실행 vs 워터폴 주의

공식 문서에서는 여러 쿼리를 같은 컴포넌트에서 useSuspenseQuery로 호출하면 순차 실행(waterfall)이 발생한다고 언급한다. (TanStack)
즉, 종속 쿼리는 자연스럽게 직렬 실행되지만, 병렬로 실행하고 싶다면 다른 전략이 필요하다.

공식 가이드에서는 이런 상황을 해결하기 위해 useSuspenseQueries를 사용하거나, 쿼리들을 컴포넌트로 분리하는 방식을 제안한다. (TanStack)
예:

// 병렬 실행이 필요한 쿼리들
const queries = [
  { queryKey: ['a'], queryFn: fetchA },
  { queryKey: ['b'], queryFn: fetchB },
];
const results = useSuspenseQueries({ queries });

이렇게 하면 쿼리들이 병렬로 실행된다. (GitHub)

5) 에러 전파 및 수동 throw

공식 문서에서는 기본적으로 에러를 던지지 않는 경우가 있으며, 이미 캐시에 데이터가 존재하면 에러를 무시하고 기존 데이터를 유지할 수 있다고 명시한다. (TanStack)
따라서 모든 에러를 ErrorBoundary로 처리하고 싶다면 명시적으로 다음과 같이 처리해야 한다:

const { data, error, isFetching } = useSuspenseQuery({ /* ... */ });
if (error && !isFetching) {
  throw error;
}

6) 에러 리셋과 재시도 흐름

에러가 발생한 후 재시도나 상태 초기화를 지원하려면, QueryErrorResetBoundary 또는 useQueryErrorResetBoundary를 사용해서 바운더리 내부 쿼리의 오류 상태를 리셋할 수 있게 구성해야 한다. (TanStack)

예:

import { QueryErrorResetBoundary } from '@tanstack/react-query';

function App() {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => (
          <div>
            에러 발생
            <button onClick={() => resetErrorBoundary()}>다시 시도</button>
          </div>
        )}>
          <Page />
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

실전 적용 예시

목록 + 상세 (Master / Detail)

function Layout() {
  const [selectedId, setSelectedId] = useState();
  return (
    <div className="grid grid-cols-[320px_1fr] gap-16">
      <List onSelect={setSelectedId} />
      <div>
        {selectedId ? (
          <ErrorBoundary fallbackRender={() => <DetailError />}>
            <Suspense fallback={<DetailSkeleton />}>
              <Detail id={selectedId} />
            </Suspense>
          </ErrorBoundary>
        ) : (
          <EmptyHint />
        )}
      </div>
    </div>
  );
}

function Detail({ id }) {
  const { data } = useSuspenseQuery({
    queryKey: ['detail', id],
    queryFn: () => fetchDetail(id),
  });
  return <DetailView data={data} />;
}

이 구조는 컴포넌트 내부에서 isLoading 또는 isError 같은 제어 로직 없이, 경계와 Suspense를 통해 대기/에러를 위임하는 흐름을 유지한다.


마무리

useSuspenseQuery패칭 단계에서 Promise/Error를 던져 경계로 직접 연결함으로써, 1–2편에서 정리한 선언적 경계 철학을 데이터 패칭까지 확장했다.

이제 컴포넌트는 성공 상태만 선언하면 되고, 대기와 실패는 경계가 흡수한다.
그 결과 코드 구조는 단순해지고, 로딩·에러 UI의 위치가 일관되며, 화면 전반의 예측 가능성조합 가능성이 크게 높아진다.

더 중요한 점은, 이 방식이 “로딩과 에러도 UI의 일부다”라는 기존 사고를 넘어,
UI와 데이터 패칭 전반에 React의 선언적 철학을 한층 더 밀어넣는 계기를 제공한다는 것이다.
Suspense와 ErrorBoundary는 단순 보조 도구가 아니라, 애플리케이션 아키텍처를 정리하는 경계의 언어로 자리 잡아 간다.

또한 Suspensive 같은 라이브러리의 아이디어는 useSuspenseQuery에 흡수되면서,
커뮤니티 전체가 조건부 데이터 패칭을 선언적으로 풀어내는 표준적 방식으로 이동하고 있다.
이는 단순한 API 진화가 아니라, React 데이터 패칭 모델이 점차 경계 기반 선언형 처리 쪽으로 정착해 가고 있음을 보여준다.

즉, useSuspenseQuery는 React의 선언형 프로그래밍 모델이 데이터 패칭 영역으로 확장되는 과정의 일부이며,
이를 통해 로딩·에러까지 경계 중심으로 일관되게 다루는 방식이 점차 자리 잡아 가고 있음을 확인할 수 있다.

profile
다크모드가 보기 좋아요

0개의 댓글