useSuspenseQuery로 보다 깔끔하게 처리하기 (+조건부 패칭 패턴)

Sara Jo·2025년 9월 17일
post-thumbnail

개발을 하다 보면 데이터 패칭 로딩/에러 상태 관리가 점점 코드 이곳저곳으로 스며들게 된다. useSuspenseQuery는 로딩/에러 관리를 React의 Suspense와 Error Boundary로 넘겨서, 컴포넌트는 “데이터가 있다고 가정하고” 깔끔하게 렌더하도록 도와준다.

데이터 패칭이나 로딩 및 에러 관리를 할 때 매번 useQuery만 사용해왔는데, v5에 새로 도입된 useSuspenseQuery의 전체적인 개념에 대해 공부해보았다.


⚡️ TL;DR

  • useSuspenseQuery는 로딩 중에 컴포넌트를 중단(suspend) 하고, 에러는 Error Boundary로 던진다. 그래서 훅이 리턴하는 data항상 정의됨(non-undefined)이라고 가정하고 코드를 작성할 수 있다.
  • 옵션/리턴 차이: enabled, placeholderData없고, statussuccess | error만 존재한다.
  • 여러 개를 병렬로 기다릴 땐 useSuspenseQueries가 있는데, 각 쿼리에도 enabled/placeholderData가 없다.
  • v5부터 useQuery({ suspense: true })는 제거되었고, 전용 훅( useSuspenseQuery, useSuspenseInfiniteQuery, useSuspenseQueries)을 써야 한다.

useSuspenseQuery: 로딩/에러는 리액트가 책임진다

로딩중이면 Promise를 던져 Suspensefallback이 보이고, 에러면 Error Boundary로 throw된다. 그래서 로딩/에러 분기 코드를 JSX에서 지우고, 성공 화면만 집중해서 구현할 수 있다. 타입 관점에서도 data가 항상 존재하므로 깔끔하다.


useQuery와 뭐가 다를까?

옵션 차이

  • useSuspenseQuery: enabled, placeholderData 없음. throwOnError는 기본 규칙이 고정.
  • useQuery: enabled, placeholderData 등 다양한 제어 가능.

리턴 차이

  • useSuspenseQuery: data는 항상 정의됨, isPlaceholderData 없음, statussuccess | error만 존재
  • 추가 주의: 취소(cancellation) 동작 안 함.

에러 처리 기본값

  • “캐시에 데이터가 전혀 없을 때만” Error Boundary로 던지는 기본 throwOnError가 적용된다. 모두 던지고 싶다면 수동으로 if (error && !isFetching) throw error 패턴을 쓰면 된다. 재시도 UI는 QueryErrorResetBoundary와 함께.

v5 마이그레이션 포인트

  • useQuery({ suspense: true })는 제거 → 전용 훅을 사용해야 함.

언제 사용해야 할까?

  • 페이지/섹션 레벨 로딩 UI를 단순화하고 싶을 때: 로딩 중엔 fallback, 에러는 Error Boundary로. 컴포넌트는 “성공 상태”만 그린다.
  • TypeScript에서 data를 확실하게 non-null로 쓰고 싶을 때.
  • 여러 개 쿼리를 동시suspense로 다루고 싶을 때: useSuspenseQueries 사용. (각 쿼리도 enabled, placeholderData 불가)

반대로, 매우 조건적인(파라미터 없으면 대기) 쿼리는 enabled가 없어서 살짝 불편하다. 이런 경우는 컴포넌트 렌더 자체를 파라미터 준비 이후로 미루는 패턴으로 바꾸면 된다.


🧪 기본 예시: Suspense + Error Boundary

import React from 'react'
import { QueryClient, QueryClientProvider, QueryErrorResetBoundary, useSuspenseQuery } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'

const qc = new QueryClient()

function UserCard({ id }: { id: string }) {
  const { data } = useSuspenseQuery({
    queryKey: ['user', id],
    queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()),
  })
  return <div>{data.name}</div>
}

export default function App() {
  return (
    <QueryClientProvider client={qc}>
      <QueryErrorResetBoundary>
        {({ reset }) => (
          <ErrorBoundary
            onReset={reset}
            fallbackRender={({ resetErrorBoundary, error }) => (
              <div>
                문제가 발생했어: {String(error)}
                <button onClick={() => resetErrorBoundary()}>다시 시도</button>
              </div>
            )}
          >
            <React.Suspense fallback={<div>불러오는 중…</div>}>
              <UserCard id="42" />
            </React.Suspense>
          </ErrorBoundary>
        )}
      </QueryErrorResetBoundary>
    </QueryClientProvider>
  )
}
  • Suspense 모드에선 로딩/에러 플래그 대신 Suspense와 Error Boundary를 사용한다. 에러 재시도는 QueryErrorResetBoundaryreact-error-boundaryonReset을 연결하는 공식 패턴을 그대로 쓴다.

🔀 조건부(enabled) 패칭이 필요할 때: 실무 패턴 3가지

useSuspenseQuery를 쓰면서 가장 불편했던 점이 enabled가 없다는 점이었다. “컴포넌트는 그대로 두고, 조건이 맞을 때만 패치”하거나 “안 보이도록 유지”해야 할 때가 있다. 그런 경우에 다음 패턴들을 사용할 수 있다.

1) “보일 때만” useSuspenseQuery 마운트

해당 섹션을 별도 컴포넌트로 쪼개고, 보여줄 때만 서스펜스 훅을 마운트한다. 레이아웃은 유지하되, 내용은 자리 차지용 플레이스홀더로 대체 가능.

function OptionalPanel({ visible, id }: { visible: boolean; id: string }) {
  return (
    <div style={{ minHeight: 180 }}>
      {visible ? (
        <React.Suspense fallback={<div style={{ height: 180 }}>로딩…</div>}>
          <PanelBody id={id} />
        </React.Suspense>
      ) : (
        <div style={{ height: 180, opacity: 0.4 }}>숨김 상태</div>
      )}
    </div>
  )
}

function PanelBody({ id }: { id: string }) {
  const { data } = useSuspenseQuery({
    queryKey: ['detail', id],
    queryFn: () => fetch(`/api/detail/${id}`).then(r => r.json()),
  })
  return <div>{data.title}</div>
}
  • 핵심: 마운트되는 순간 패치가 시작된다. enabled처럼 “마운트된 채 대기”는 불가. 보여줄 타이밍을 렌더 레벨에서 제어한다.

2) 하이브리드: 그 부분만 useQuery({ enabled })

“마운트 유지 + 지금은 패치 금지/재개”를 원하면, 해당 섹션만 useQuery로 바꿔 enabled로 제어한다.

import { useQuery } from '@tanstack/react-query'

function OptionalPanelEnabled({ shouldFetch, id }: { shouldFetch: boolean; id: string }) {
  const { data, isLoading } = useQuery({
    queryKey: ['detail', id],
    queryFn: () => fetch(`/api/detail/${id}`).then(r => r.json()),
    enabled: shouldFetch, // 조건 충족 시에만 네트워크 요청
  })

  if (!shouldFetch) return <div style={{ height: 180, opacity: 0.4 }}>조건 미충족</div>
  if (isLoading) return <div style={{ height: 180 }}>로딩…</div>
  return <div>{data?.title}</div>
}
  • 장점: 마운트 유지하면서 패치 제어가 간단.
  • 단점: 이 섹션은 서스펜스식 로딩/에러 UI가 아니라 isLoading 등 분기를 직접 관리해야 한다. 그래도 실무에선 제일 직관적이다.

3) useSuspenseQueries필수 쿼리만, 선택 쿼리는 동적 구성

대시보드처럼 여러 쿼리를 병렬로 기다릴 땐, 항상 필요한 쿼리만 useSuspenseQueries 배열에 넣고, 조건부 쿼리는 조건에 따라 배열에서 빼기/넣기로 처리한다.

import { useSuspenseQueries } from '@tanstack/react-query'

function Dashboard({ id, showAnalytics }: { id: string; showAnalytics: boolean }) {
  const queries = [
    { queryKey: ['user', id], queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()) },
    ...(showAnalytics
      ? [{ queryKey: ['analytics', id], queryFn: () => fetch(`/api/analytics/${id}`).then(r => r.json()) }]
      : []),
  ] as const

  const results = useSuspenseQueries({ queries })
  const user = results[0].data
  const analytics = showAnalytics ? results[1].data : null

  return (
    <>
      <h2>{user.name}</h2>
      {showAnalytics ? <pre>{JSON.stringify(analytics, null, 2)}</pre> : <em>Analytics 숨김</em>}
    </>
  )
}
  • 주의: useSuspenseQueries의 각 아이템에도 enabled/placeholderData가 없다. 배열 구성 자체를 조건에 따라 바꾸는 식으로 제어할 수 있다. 또한 모든 쿼리가 끝난 뒤에 리마운트되므로, 필요하면 staleTime을 적절히 올려 불필요한 재패치를 막을 수 있다. 취소 역시 동작하지 않는다.

✨ 깜빡임/업데이트 UX 다듬기

Suspense 모드에선 placeholderData가 없고, 쿼리 키가 바뀌면 해당 경계가 다시 fallback으로 내려앉을 수 있다. 키 전환이 잦다면 전환을 startTransition으로 감싸 깜빡임을 줄일 수 있다. (공식 가이드에서 권장하는 방법)

import { startTransition } from 'react'

startTransition(() => {
  setPostId(nextId) // 쿼리 키를 바꾸는 상태 전환
})

✅ 마무리

  • 서스펜스 전용 훅은 로딩/에러 분기를 리액트에게 맡기고, 성공 UI만 그리게 해준다. enabled/placeholderData가 없다는 제약이 있지만,

    • 보일 때만 마운트(패턴 1),
    • 하이브리드로 useQuery({ enabled })(패턴 2),
    • 병렬일 땐 useSuspenseQueries필수만 넣고 동적 구성(패턴 3)
      로 대부분의 요구사항을 만족시킬 수 있다.

📚참고

https://tanstack.com/query/latest/docs/framework/react/reference/useSuspenseQuery

0개의 댓글