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: useSuspenseQueries

그러면 useSuspenseQueries로 한번에 보내면 되지 않을까요?

function UserDashboard({ userId }: { userId: string }) {
  const [user, posts, comments] = useSuspenseQueries({
    queries: [
      userQueries.detail(userId),
      userQueries.posts(userId),
      userQueries.comments(userId),
    ],
  });
 
  return (
    <div>
      <UserProfile user={user.data} />
      <PostList posts={posts.data} />
      <CommentList comments={comments.data} />
    </div>
  );
}

세 요청이 병렬로 실행되니 워터폴은 해결됩니다. 하지만 모든 쿼리가 하나의 Suspense 경계를 공유하기 때문에, 가장 느린 요청이 끝날 때까지 아무것도 보여줄 수 없습니다. 예를 들어 user와 posts는 50ms만에 끝났는데 comments가 2초 걸린다면, 이미 도착한 데이터도 2초 동안 화면에 나타나지 않습니다.

영역별로 독립적인 로딩 UI를 보여주고 싶다면 다른 방법이 필요합니다.


방법 3: 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 }) => {
    return <div>{data.name}</div>;  // data는 T (undefined 아님)
  }}
</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처럼 파일을 쪼개지 않아도 병렬 요청이 가능합니다.
  • 영역별 독립 로딩: useSuspenseQueries와 달리 각 섹션이 독립적인 Suspense 경계를 가져서, 먼저 끝난 영역부터 보여줄 수 있습니다.
  • 높은 코드 응집도: 어떤 API를 호출하고 어떤 UI를 렌더링하는지가 한 블록 안에 함께 있어서, 페이지의 데이터 흐름을 한눈에 파악할 수 있습니다.