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를 렌더링하는지가 한 블록 안에 함께 있어서, 페이지의 데이터 흐름을 한눈에 파악할 수 있습니다.