useQuery로 충분한데 왜 useSuspenseQuery를 선택했나

useQuery로 충분한데 왜 useSuspenseQuery를 선택했나

2026년 2월 14일

viewer 앱의 공유 페이지(/share/:shareId)를 구현하면서, 데이터 페칭 방식을 선택해야 했습니다.

처음에는 당연히 useQuery를 쓰면 된다고 생각했지만, useSuspenseQuery를 선택했습니다.

그리고 useSuspenseQueries라는 것도 있었는데, 이건 구현을 마친 뒤에야 알았습니다. 이 과정에서 정리한 기록을 공유합니다.

1. 문제 상황

viewer 앱의 공유 페이지에는 두 가지 데이터가 반드시 필요했다.

  • 포스트 데이터: 원문 URL, 제목, 파비콘
  • 하이라이트 데이터: 사용자가 선택해 둔 텍스트 조각들

두 데이터를 조합해서 그리드 카드 형태의 UI를 렌더링해야 했다.

<a href={getShareableLink(highlights, post)}>{post.title}</a>;
{
  highlights.map((highlight) => <div>{highlight.text}</div>);
}

두 데이터 중 하나라도 없으면 페이지 자체가 성립하지 않았다. 포스트가 없으면 원문 링크를 만들 수 없고, 하이라이트가 없으면 그리드를 채울 수 없다.

“데이터가 없으면 아무것도 보여줄 수 없는” 페이지였다.

2. 내가 처음 한 생각

처음 든 생각은 이것이었다.

“당연히 useQuery 쓰면 되지 않나?”

const { data: post, isLoading: isPostLoading } = useQuery(
  postQueryOptions(shareId),
);
const { data: highlights, isLoading: isHighlightsLoading } = useQuery(
  annotationQueryOptions(shareId),
);

if (isPostLoading || isHighlightsLoading) {
  return <LoadingSpinner />;
}

if (!post || !highlights) {
  return <NotFoundFallback />;
}

// 이제 렌더링

직관적이고 명확하다. 로딩 중이면 스피너, 데이터가 없으면 에러 화면, 있으면 렌더링.

그런데 이렇게 작성하다 보니 몇 가지가 거슬렸다.

타입이 불안정하다. useQuery에서 반환하는 data의 타입은 Post | undefined다. 위에서 null 체크를 했더라도 렌더링 블록에서 post?.title처럼 옵셔널 체이닝을 여전히 써야 하거나, 타입스크립트가 undefined 가능성을 계속 경고한다.

로딩 분기가 컴포넌트 안에 섞인다. 렌더링 로직과 데이터 페칭 상태 처리가 한 컴포넌트 안에 뒤섞인다. 컴포넌트가 “데이터를 가져오는 역할”과 “렌더링하는 역할”을 동시에 맡는다.

3. 실제로 해 본 선택

관점을 바꿔서 이렇게 질문하기 시작했다.

“이 컴포넌트가 렌더링될 때, 데이터가 항상 존재한다고 보장할 수 없을까?”

useSuspenseQuery를 선택했다.

const { data: post } = useSuspenseQuery(postQueryOptions(currentShareId));
const { data: highlights } = useSuspenseQuery(
  annotationQueryOptions(currentShareId),
);

useSuspenseQuery는 데이터가 준비되기 전까지 컴포넌트 렌더링 자체를 중단(suspend)시킨다. React의 가 이 중단을 감지해서 fallback UI를 대신 보여주고, 데이터가 준비되면 컴포넌트를 다시 렌더링한다.

// SharePage.tsx
export const SharePage = () => {
  return (
    <ErrorBoundary fallback={<NotFoundFallback />}>
      <Suspense fallback={null}>
        <SharedPageContent /> {/_ 여기서 suspend가 발생할 수 있음 _/}
      </Suspense>
    </ErrorBoundary>
  );
};

const SharedPageContent = () => {
  // 이 시점에 도달했다면, post와 highlights는 반드시 존재한다
  const { data: post } = useSuspenseQuery(postQueryOptions(currentShareId));
  const { data: highlights } = useSuspenseQuery(
    annotationQueryOptions(currentShareId),
  );

  // isLoading 체크 없이 바로 렌더링
  return <a href={getShareableLink(highlights, post)}>{post.title}</a>;
};

핵심은 역할 분리다.

  • <Suspense>: 로딩 상태 처리 (fallback UI)
  • <ErrorBoundary>: 에러 상태 처리 (에러 UI)
  • SharedPageContent: 데이터가 있다는 전제로 렌더링만 담당

그리고 useSuspenseQuery는 data 타입에서 undefined를 제거해준다.

// useQuery: data는 Post | undefined
const { data } = useQuery(postQueryOptions(shareId));
data?.title; // 옵셔널 체이닝 필요

// useSuspenseQuery: data는 Post (항상 존재)
const { data } = useSuspenseQuery(postQueryOptions(shareId));
data.title; // 바로 접근 가능

반면 ProtectedRoute에서는 useQuery를 그대로 사용했다.

const { data: user, isLoading } = useQuery(userInfoQueryOptions);

if (isLoading) {
  return <>{loadingFallback}</>;
}

if (!user) {
  return <Navigate to={redirectPath} replace />;
}

인증 라우트는 로딩 중에 리다이렉트하면 안 되고, user 존재 여부에 따라 다른 동작(리다이렉트 vs 렌더링)을 해야 한다. 이런 분기 처리가 필요한 경우엔 useSuspenseQuery보다 useQuery가 더 적합하다.

4. 결과

useSuspenseQuery를 사용한 결과

  • 컴포넌트 내부에서 isLoading, isError 분기가 사라졌다
  • data가 항상 존재함이 타입 레벨에서 보장됐다
  • 로딩/에러 처리를 상위에서 중앙화할 수 있었다

useQuery와 useSuspenseQuery의 차이를 정리하면 이렇다.

| 구분 | useQuery | useSuspenseQuery | | :-------------- | :-------------------------------- | :------------------------------------ | --------------- | | 로딩 처리 | isLoading 상태값으로 직접 처리 | <Suspense> fallback으로 위임 | | 에러 처리 | isError 상태값으로 직접 처리 | <ErrorBoundary>로 위임 | | data 타입 | T | undefined | T (항상 존재) | | 적합한 상황 | 로딩/에러에 따라 분기가 필요할 때 | 데이터 없이 렌더링 자체가 불가능할 때 |

5. 지금 다시 해본다면?

useSuspenseQueries를 처음부터 고려했을 것이다.

현재 코드는 useSuspenseQuery를 두 번 순서대로 호출한다.

const { data: post } = useSuspenseQuery(postQueryOptions(currentShareId));
const { data: highlights } = useSuspenseQuery(
  annotationQueryOptions(currentShareId),
);

여기서 문제가 있다. Suspense 기반 쿼리는 데이터가 준비될 때까지 컴포넌트 렌더링을 중단한다. 즉, 첫 번째 useSuspenseQuery가 suspend되면 컴포넌트 실행 자체가 멈춘다. 데이터가 준비된 후 다시 렌더링될 때 비로소 두 번째 useSuspenseQuery가 실행된다.

두 쿼리가 서로 독립적임에도 순차적으로 요청되는 워터폴(waterfall) 문제다.

❌ 워터폴 postQuery 요청 → 완료 → highlightsQuery 요청 → 완료 → 렌더링

✅ 병렬 postQuery 요청 ─┐ ├→ 모두 완료 → 렌더링 highlightsQuery ─┘

useSuspenseQueries는 이 문제를 해결한다. 여러 쿼리를 배열로 넘기면 병렬로 요청하고, 모두 완료될 때까지 suspend한다.

const [{ data: post }, { data: highlights }] = useSuspenseQueries({
  queries: [
    postQueryOptions(currentShareId),
    annotationQueryOptions(currentShareId),
  ],
});

두 API 요청이 동시에 출발하므로 전체 로딩 시간을 줄일 수 있다. data 타입에서 undefined가 제거되는 것도 동일하다.

정리하면

  • 데이터 없이 렌더링이 불가능한 컴포넌트 → useSuspenseQuery
  • 로딩/에러 상태에 따라 분기 처리가 필요한 경우 → useQuery
  • 독립적인 여러 쿼리를 Suspense와 함께 병렬로 처리하고 싶은 경우 → useSuspenseQueries