본문으로 건너뛰기
Tech Blog

Suspense 안쪽과 useSuspenseQuery

글 복사 완료!

Suspense는 fallback 스위치가 아니에요. TanStack Query가 그걸 따라간 모양도 봐요.

·11분·

컴포넌트 안에서 if (isLoading) return <Spinner /> 를 또 쓰고 있을 때마다 한숨이 나왔어요. 화면 어딘가에는 항상 스피너 하나가 떠 있는데, 그게 누구의 책임인지 매번 정해야 했거든요. 그러다 <Suspense fallback={<Spinner />}> 로 옮긴 뒤에도 한참은 fallback 채우는 스위치 정도로만 썼어요. 그 안쪽이 어떻게 돌아가는지 모르고요.

Suspense는 무엇을 멈추는가

React 공식 문서는 Suspense를 한 줄로 정의해요. 자식이 로딩을 마칠 때까지 fallback 을 표시한다는 거죠. 그런데 여기서 "로딩"이 뭘 의미하는지가 함정이에요. useEffect 안에서 fetch 를 돌리는 건 Suspense 가 모릅니다. setTimeout 으로 데이터를 늦게 세팅해도 모르고요.

Suspense 가 감지하는 트리거는 딱 세 가지예요. 각각 어떤 모양인지 한 줄로 보면 이래요.

// 1. 라이브러리가 Suspense 지원 선언 (예: TanStack Query)
const { data } = useSuspenseQuery({ queryKey: ["user"], queryFn: fetchUser });
 
// 2. React.lazy 로 코드 스플리팅
const HeavyChart = lazy(() => import("./HeavyChart"));
 
// 3. use(promise) 훅으로 promise 를 직접 펼치기
const user = use(userPromise);

세 가지 다 "여기서 잠깐 멈춰, 데이터나 코드가 올 때까지" 라는 신호를 React 에 보내요. 그 신호를 받아주는 게 가장 가까운 <Suspense> 경계고요.

그래서 Suspense 의 진짜 역할은 fallback 을 보여주는 게 아니에요. 트리 안 어딘가에서 "아직 못 그려요" 신호가 올라올 때 그 신호를 받아내는 경계 를 만드는 거죠. 한 경계 안의 자식들은 기본적으로 단일 단위로 취급돼서 함께 등장합니다.

"By default, the whole tree inside Suspense is treated as a single unit." - React Suspense Reference

경계 안쪽 자식 하나만 데이터가 늦어져도 형제까지 같이 fallback 에 묶여요. 점진적으로 보이게 하려면 경계를 더 작게 쪼개야 하거든요.

안쪽에서 일어나는 일

컴포넌트가 렌더 도중에 "잠깐, 데이터 아직 안 왔어" 라고 말하는 방법이 필요해요. React 18 시절엔 비공식적으로 promise 를 throw 해서 React 가 catch 하는 패턴을 라이브러리들이 쓰고 있었거든요. React 19 부터는 이 메커니즘이 use(promise) 라는 공식 훅으로 정착했어요.

use 의 동작은 공식 문서가 한 문장으로 설명합니다.

"The component calling use suspends while the Promise is pending, displaying a Suspense fallback if one is available." - React use Reference

pending 일 때 컴포넌트가 멈추고, resolve 되면 본문이 다시 그려져요. reject 되면 가장 가까운 ErrorBoundary 가 받고요.

라이브러리 없이도 직접 써볼 수 있어요.

import { Suspense, use } from "react";
 
const userPromise = fetchUser("42");
 
function UserName() {
  const user = use(userPromise);
  return <h1>{user.name}</h1>;
}
 
export default function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserName />
    </Suspense>
  );
}

UserName 안에서 use(userPromise) 가 promise 를 펼치는 동안 Suspense 가 fallback 을 보여주고, resolve 되면 user.name 이 그려지는 흐름이에요. 컴포넌트 안에서 isLoading 분기를 안 짜도 되는 게 핵심이고요.

여기서 미묘한 함정이 하나 있어요. userPromise 를 컴포넌트 바깥, 그러니까 모듈 스코프에서 만들었거든요. 컴포넌트 안에서 직접 fetchUser() 를 호출하면 매 렌더마다 새 promise 가 만들어져서 React 가 무한 루프에 빠져요. 그래서 promise 는 캐시된 형태로 컴포넌트에 들어와야 합니다. 캐싱이나 재시도, invalidate 같은 책임을 매번 손으로 짜는 게 부담이라 TanStack Query 같은 라이브러리가 사이를 메우는 거예요.

여기까지가 Suspense 가 보는 세 가지 외부 상태에요. 컴포넌트는 자기가 pending 인지 결과물인지 신경 쓰지 않아요. 그냥 promise 를 펼치고, React 가 "이 promise 가 어느 단계인지" 보고 fallback 과 본문을 갈아끼우는 거죠.

useSuspenseQuery 가 isPending 을 가져갔다

TanStack Query 에서 Suspense 모드로 진입하는 가장 깔끔한 방법은 전용 훅을 쓰는 거예요. useSuspenseQuery, useSuspenseInfiniteQuery, useSuspenseQueries 가 있어요. 일반 useQuery 와 시그니처가 비슷한데 반환 타입이 다릅니다.

useSuspenseQuery 가 어떻게 Suspense 를 발동시키는지부터 잠깐 짚을게요. 훅 내부에서 React 가 알아챌 수 있는 promise 를 노출해요. 캐시에 데이터가 있으면 그 자리에서 즉시 반환하고, 없거나 만료됐으면 fetch 를 시작하면서 promise 가 pending 상태로 노출되거든요. 그러면 컴포넌트가 자동으로 suspend 되고, 가장 가까운 <Suspense> 경계가 fallback 을 그려주는 거죠. 앞 섹션에서 본 use 훅 흐름에 라이브러리가 캐시 관리와 재시도까지 묶어 둔 셈이에요.

가장 큰 차이는 isPending 이 사라진다는 점이에요. 데이터를 기다리는 책임이 컴포넌트 안에서 Suspense 경계로 빠져나갔거든요.

"When using suspense mode, status states and error objects are not needed and are then replaced by usage of the React.Suspense component." - TanStack Query Suspense Guide

data 는 항상 정의된 값으로 들어와요. TypeScript 가 그렇게 강제합니다.

useQueryuseSuspenseQuery 를 코드로 비교하면 차이가 더 명확해져요.

// useQuery: 분기 처리가 컴포넌트 안에 남아요
function Profile({ userId }: { userId: string }) {
  const { data, isPending, error } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
  });
 
  if (isPending) return <Spinner />;
  if (error) return <ErrorView error={error} />;
 
  return <h1>{data.name}</h1>;
}
 
// useSuspenseQuery: 분기가 사라져요
function Profile({ userId }: { userId: string }) {
  const { data } = useSuspenseQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
  });
 
  return <h1>{data.name}</h1>;
}

대신 호출하는 쪽이 <Suspense fallback={<Spinner />}> 로 감싸야 하고, 에러 표시는 <ErrorBoundary> 가 책임집니다. 그래서 useSuspenseQuery 는 옵션 세 개를 막아두었어요. enabled, throwOnError, placeholderData 가 그것이에요. enabled 는 "데이터가 있을 수도, 없을 수도 있다"는 가능성을 만드는 옵션인데, Suspense 모델에선 데이터가 항상 있어야 해서요. placeholderData 도 같은 이유로 의미가 사라집니다.

에러는 누가 받는가

이제 에러 흐름이 살짝 헷갈려져요. useSuspenseQuery 는 에러를 throw 해서 가장 가까운 ErrorBoundary 로 보내요. 그래서 Suspense 경계 바깥에 ErrorBoundary 도 함께 둬야 해요.

함정은 TanStack Query 의 기본 throwOnError 동작에 있어요. 기본값이 (error, query) => typeof query.state.data === 'undefined' 거든요. 캐시에 stale 한 데이터가 있으면 에러를 throw 하지 않고 옛 데이터를 그대로 보여줍니다. 사용자 입장에선 "어 데이터가 보이긴 하는데 새로고침이 안 됐네?" 가 되는 거예요.

모든 에러를 ErrorBoundary 로 보내고 싶으면 수동으로 throw 해야 해요.

function Profile({ userId }: { userId: string }) {
  const { data, error, isFetching } = useSuspenseQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
  });
 
  if (error && !isFetching) throw error;
 
  return <h1>{data.name}</h1>;
}

isFetching 체크가 들어가는 이유는, 재요청 중일 때마저 throw 하면 화면 깜빡임이 심해지기 때문이에요. 한 번은 캐시된 데이터로 보여주고, 재요청도 실패하면 그때 ErrorBoundary 로 넘기는 거죠.

한 걸음 더

한 컴포넌트 안에 useSuspenseQuery 를 여러 개 두면 직렬로 fetch 돼요. 첫 번째 쿼리가 멈추는 동안 두 번째 쿼리는 시작도 안 하거든요. 병렬화하려면 useSuspenseQueries 를 써야 합니다.

const [users, teams, projects] = useSuspenseQueries({
  queries: [
    { queryKey: ["users"], queryFn: fetchUsers },
    { queryKey: ["teams"], queryFn: fetchTeams },
    { queryKey: ["projects"], queryFn: fetchProjects },
  ],
});

세 쿼리가 동시에 시작되고, 셋 다 끝나면 본문이 함께 그려져요. 한 경계 안에서 단일 단위로 노출되는 Suspense 의 기본 동작이 그대로 적용됩니다.

경계를 어디에 그어야 할지 본격적으로 고민되기 시작하면 Suspense 경계는 어디에 그어야 하나 가 출발점이 될 거예요. SSR 맥락의 글이지만 경계 설계 원칙은 클라이언트에서도 같거든요.

참고 자료

관련 글