useSuspenseQuery 제대로 사용하기

2025년 12월 18일

프로젝트에서 TanStack Query의 useQuery를 사용하여 데이터를 조회하고 있었습니다.

const UserDashboard = () => {
  const { data: user, isLoading: userLoading } = useQuery(userQueries.detail(userId));
  const { data: posts, isLoading: postsLoading } = useQuery(userQueries.posts(userId));
  const { data: comments, isLoading: commentsLoading } = useQuery(userQueries.comments(userId));
 
  if (userLoading || postsLoading || commentsLoading) {
    return <LoadingSpinner />;
  }
 
  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
      <CommentList comments={comments} />
    </div>
  );
};

여러 데이터를 조회할 경우, 각 쿼리의 로딩 상태를 개별적으로 관리해야 하기 때문에 번거롭습니다.


useSuspenseQuery 도입

useSuspenseQuery를 사용하면 isLoading 없이 로딩과 에러 처리를 상위 컴포넌트에서 처리할 수 있습니다.

// App.tsx
function App() {
  return (
    <ErrorBoundary fallback={<ErrorPage />}>
      <Suspense fallback={<LoadingSpinner />}>
        <UserDashboard />
      </Suspense>
    </ErrorBoundary>
  );
}
 
// UserDashboard.tsx
function UserDashboard() {
  const { data: user } = useSuspenseQuery(userQueries.detail(userId));
  const { data: posts } = useSuspenseQuery(userQueries.posts(userId));
  const { data: comments } = useSuspenseQuery(userQueries.comments(userId));
 
  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
      <CommentList comments={comments} />
    </div>
  );
}

로딩 상태의 조건문이 제거되어 코드가 간결해졌지만, 아래의 문제가 발생합니다.


의도치 않은 Request Waterfall 현상

이 코드는 언뜻 보기에 세 개의 API 요청이 병렬로 실행될 것 같지만, 실제로는 순차적으로 실행됩니다. 만약 세 API가 각각 100ms씩 걸린다면, 사용자는 총 300ms 동안 로딩 스피너를 보게 됩니다.

원인은 useSuspenseQuery의 동작 방식에 있습니다. 데이터가 캐시에 없으면 API 요청을 시작함과 동시에 Promise를 throw합니다. React는 이를 감지해 즉시 컴포넌트 렌더링을 중단하고 상위 Suspense의 fallback을 표시합니다.

결국 첫 번째 쿼리에서 Promise가 throw되는 순간 그 아래 코드는 실행되지 않습니다. 첫 번째 응답이 도착해 컴포넌트가 다시 렌더링될 때 두 번째 쿼리가 실행되고, 같은 방식으로 세 번째 쿼리까지 이어지며 전체 로딩 시간이 불필요하게 늘어납니다.


방법 1: 컴포넌트 분리

이 문제를 해결하려면 각 쿼리를 별도의 컴포넌트로 추출하고 각각 Suspense로 감싸야 합니다.

function UserDashboard({ userId }: { userId: string }) {
  return (
    <ErrorBoundary fallback={<ErrorPage />}>
      <Suspense fallback={<UserSkeleton />}>
        <UserInfoSection userId={userId} />
      </Suspense>
 
      <Suspense fallback={<PostsSkeleton />}>
        <PostsSection userId={userId} />
      </Suspense>
 
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentsSection userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}
 
function UserInfoSection({ userId }: { userId: string }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId)
  });
  // ...
}
 
function PostsSection({ userId }: { userId: string }) {
  const { data: posts } = useSuspenseQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchPosts(userId)
  });
  // ...
}

이제 각 섹션이 독립적으로 로딩되고 병렬 요청도 가능해지지만, 다른 문제가 생깁니다.

  • 데이터 흐름 파악의 어려움: 페이지 전체에서 어떤 API들이 호출되는지 한눈에 보이지 않고 자식 컴포넌트 내부로 숨어버립니다.
  • 불필요한 컴포넌트 파편화: 단지 로딩 UI 분리와 워터폴 방지를 위해 원래 하나였을 컴포넌트를 강제로 쪼개야 하며, 이 과정에서 파일이 늘어나고 코드가 파편화됩니다.

방법 2: SuspenseQuery 컴포넌트

파일은 하나로 유지하면서, 워터폴을 방지하고 영역별 로딩을 처리하고 싶다면 Render Props 패턴SuspenseQuery 컴포넌트가 좋은 방법입니다. (@suspensive/react-query 라이브러리 참고)

useSuspenseQuery 호출부를 별도의 컴포넌트로 캡슐화하여, 부모 컴포넌트의 실행 흐름을 방해하지 않도록 설계합니다.

// src/components/common/SuspenseQuery.tsx
import {
  useSuspenseQuery,
  UseSuspenseQueryOptions,
  UseSuspenseQueryResult
} from '@tanstack/react-query';
import type { ReactNode } from 'react';
 
interface SuspenseQueryProps<TData = unknown, TError = Error> {
  children: (result: UseSuspenseQueryResult<TData, TError>) => ReactNode;
}
 
export function SuspenseQuery<TData = unknown, TError = Error>({
  children,
  ...options
}: UseSuspenseQueryOptions<TData, TError> & SuspenseQueryProps<TData, TError>) {
  return <>{children(useSuspenseQuery(options))}</>;
}

워터폴 방지와 구조적 응집도 확보

방법 1에서는 워터폴을 피하기 위해 각 섹션을 물리적인 파일이나 별도 컴포넌트로 쪼개야 했습니다. 하지만 SuspenseQuery를 사용하면 훅의 호출 위치가 컴포넌트 내부로 격리되므로, 부모 컴포넌트의 렌더링이 통째로 중단되지 않습니다. 덕분에 한 파일 내에서도 여러 개의 API 요청이 병렬로 실행되며, 평탄한 구조를 유지하면서도 독립적인 Suspense 경계를 가질 수 있습니다.

Render Props 패턴

SuspenseQuery가 자식 함수를 실행하는 시점은 데이터 로드가 완료된 이후입니다. 이 실행 시점의 차이 덕분에 함수 내부의 data는 자연스럽게 Non-nullable 상태가 됩니다. 결과적으로 undefined를 대비한 불필요한 조건문이나 옵셔널 체이닝 없이 사용할 수 있습니다.

<SuspenseQuery {...userQuery}>
  {({ data }) => {
    // data는 무조건 존재 (T | undefined가 아니라 T)
    return <div>{data.name}</div>;  // ?.name 불필요
  }}
</SuspenseQuery>

실제 적용 예시

function UserDashboard({ userId }: { userId: string }) {
  return (
    <ErrorBoundary fallback={<ErrorPage />}>
      <Suspense fallback={<UserSkeleton />}>
        <SuspenseQuery {...userQueries.detail(userId)}>
          {({ data: user }) => (
            <section>
              <h2>{user.name}</h2>
              <p>{user.email}</p>
            </section>
          )}
        </SuspenseQuery>
      </Suspense>
 
      <Suspense fallback={<PostsSkeleton />}>
        <SuspenseQuery {...userQueries.posts(userId)}>
          {({ data: posts }) => (
            <section>
              <h3>작성한 글 ({posts.length})</h3>
              <ul>
                {posts.map(post => (
                  <li key={post.id}>{post.title}</li>
                ))}
              </ul>
            </section>
          )}
        </SuspenseQuery>
      </Suspense>
 
      <Suspense fallback={<CommentsSkeleton />}>
        <SuspenseQuery {...userQueries.comments(userId)}>
          {({ data: comments }) => (
            <section>
              <h3>작성한 댓글 ({comments.length})</h3>
              <ul>
                {comments.map(comment => (
                  <li key={comment.id}>{comment.content}</li>
                ))}
              </ul>
            </section>
          )}
        </SuspenseQuery>
      </Suspense>
    </ErrorBoundary>
  );
}

  • 방법 1은 각 섹션을 별도 컴포넌트로 분리해야 했지만, SuspenseQuery를 사용하면 한 파일 안에서 해결할 수 있습니다.
  • 선언적 UI와 코드 응집도: 하나의 블록 안에 어떤 API를 호출하는지와 그 결과로 어떤 UI를 렌더링하는지가 함께 모여 있어 코드 응집도가 높습니다. 페이지 전체의 데이터 흐름을 한눈에 파악할 수 있는 평탄한 구조를 유지할 수 있습니다.