useSuspenseQuery란?
- TanStack Query(v5)에서 사용 가능한 훅
- 자동 로딩 관리: Suspense를 활용해 로딩 상태를 별도 처리 없이 자동 관리
- 간결한 코드: 데이터를 동기적으로 사용하는 형태로 코드 간결화
- 에러 경계 필요: 예외 처리를 위해 ErrorBoundary와 함께 사용 권장
useQuery와 비교
useQuery와 useSuspenseQuery를 각각 적용한 코드를 비교해보자
Before (useQuery)
function Page() { const { data, isLoading, isError, error } = useQuery({ queryKey: ['products'], queryFn: fetchProducts, }); // ❗컴포넌트 내부에서 상태에 따른 분기처리 필요 if (isLoading) return <div>로딩 중...</div>; if (isError) return <div>에러 발생: {error.message}</div>; return ( <div> <h3>상품 목록 (useQuery)</h3> <div className="gap-4 flex flex-col"> {data.map((product) => ( <div key={product.id} className="border border-green-400"> <strong>{product.title}</strong> </div> ))} </div> </div> ); }
After (useSuspenseQuery)
function ProductsSuspense() { const { data: productsData } = useSuspenseQuery({ queryKey: ['products'], queryFn: fetchProducts, }); return ( <div className="flex"> <div className="flex-1"> <h3>상품 목록 (useSuspenseQuery)</h3> <div className="gap-4 flex flex-col"> {productsData.map((product) => ( <div key={product.id} className="border border-green-400"> <strong>{product.title}</strong> </div> ))} </div> </div> </div> ); } function Page() { return ( // ❗ Suspense와 ErrorBoundary로 감싸주어 로딩과 에러 처리를 위임합니다. <ErrorBoundary FallbackComponent={ErrorFallback}> <Suspense fallback={<div>로딩 중...</div>}> <ProductsSuspense /> </Suspense> </ErrorBoundary> ); }
→ 로딩과 에러에 대한 처리를 Suspense와 ErrorBoundary에 위임하기 때문에 내부 컴포넌트 로직이 더 깔끔해짐
⚠️ 주의할 점
useSuspenseQuery를 사용할때 주의할점이 있다.
대표적으로 네트워크 waterfall현상이 발생할 수 있다는 점이다.
위 컴포넌트에서 useSuspenseQuery를 하나 더 호출해보자
function ProductsSuspense() { // 두개의 suspenseQuery 호출 const { data: productsData } = useSuspenseQuery({ queryKey: ['products'], queryFn: fetchProducts, }); const { data: usersData } = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers, }); return ( <div className="flex"> <div className="flex-1"> <h3>상품 목록 (useSuspenseQuery)</h3> <div className="gap-4 flex flex-col"> {productsData.map((product) => ( <div key={product.id} className="border border-green-400"> <strong>{product.title}</strong> </div> ))} </div> </div> <div className="flex-1"> <h3>유저 목록 (useSuspenseQuery)</h3> <div className="gap-4 flex flex-col"> {usersData.map((users) => ( <div key={users.id} className="border border-green-400"> <strong>{users.username}</strong> </div> ))} </div> </div> </div> ); }
이런 경우 네트워크waterfall이 발생하게 되는데, product api 응답 후에 user api 요청이 시작되게 된다.
컴포넌트는 user api 응답 후에 랜더링이 완료되며, 이는 LCP시점이 늦어진다는 뜻이다.
어떻게 하면 두 api를 동시에 호출하여 LCP를 개선할 수 있을까?
✅ 해결 방법 #1
function ProductsSuspense() { // useSuspenseQueries를 사용하여 두 개의 쿼리를 동시에 호출합니다. const results = useSuspenseQueries({ queries: [ { queryKey: ['products'], queryFn: fetchProducts, }, { queryKey: ['users'], queryFn: fetchUsers, }, ], }); // 첫 번째 쿼리는 products, 두 번째 쿼리는 users에 해당합니다. const productsData = results[0].data; const usersData = results[1].data; return ( <div className="flex"> <div className="flex-1"> <h3>상품 목록 (useSuspenseQueries)</h3> <div className="gap-4 flex flex-col"> {productsData.map((product) => ( <div key={product.id} className="border border-green-400"> <strong>{product.title}</strong> </div> ))} </div> </div> <div className="flex-1"> <h3>유저 목록 (useSuspenseQueries)</h3> <div className="gap-4 flex flex-col"> {usersData.map((user) => ( <div key={user.id} className="border border-green-400"> <strong>{user.username}</strong> </div> ))} </div> </div> </div> ); } function Page() { return ( <ErrorBoundary FallbackComponent={ErrorFallback}> <Suspense fallback={<div>로딩 중...</div>}> <ProductsSuspense /> </Suspense> </ErrorBoundary> ); } export default Page;
첫번째 해결방법은 바로 useSuspenseQueries를 사용하는 방법이다.
useQueries와 마찬가지로, 여러 쿼리를 동시에 호출하는 훅이다.
✅ 해결 방법 #2
// 에러 발생 시 보여줄 컴포넌트 function ErrorFallback({ error }) { return <div>에러가 발생했습니다: {error.message}</div>; } function Products() { const { data: productsData } = useSuspenseQuery({ queryKey: ['products'], queryFn: fetchProducts, }); return ( <div className="flex-1"> <h3>상품 목록 (useSuspenseQuery)</h3> <div className="gap-4 flex flex-col"> {productsData.map((product) => ( <div key={product.id} className="border border-green-400"> <strong>{product.title}</strong> </div> ))} </div> </div> ); } function Users() { const { data: usersData } = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers, }); return ( <div className="flex-1"> <h3>유저 목록 (useSuspenseQuery)</h3> <div className="gap-4 flex flex-col"> {usersData.map((user) => ( <div key={user.id} className="border border-green-400"> <strong>{user.username}</strong> </div> ))} </div> </div> ); } function Page() { return ( <div className="flex"> <ErrorBoundary FallbackComponent={ErrorFallback}> <Suspense fallback={<div>상품 로딩 중...</div>}> <Products /> </Suspense> <Suspense fallback={<div>유저 로딩 중...</div>}> <Users /> </Suspense> </ErrorBoundary> </div> ); } export default Page;
이번에는 컴포넌트 트리 구조를 쪼개서 각 쿼리별로 하나의 컴포넌트가 담당하게 하는 방법이다.
이렇게 하면 각 컴포넌트를 감싸는 suspense가 존재하기 때문에 각각의 에러와 로딩 처리가 이뤄질 수 있다.
✅ 해결 방법 #3
function Products() { const { data: productsData } = useSuspenseQuery({ queryKey: ['products'], queryFn: fetchProducts, }); return ( <div className="flex-1"> <h3>상품 목록 (useSuspenseQuery)</h3> <div className="gap-4 flex flex-col"> {productsData.map((product) => ( <div key={product.id} className="border border-green-400"> <strong>{product.title}</strong> </div> ))} </div> </div> ); } function Users() { const { data: usersData } = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers, }); return ( <div className="flex-1"> <h3>유저 목록 (useSuspenseQuery)</h3> <div className="gap-4 flex flex-col"> {usersData.map((user) => ( <div key={user.id} className="border border-green-400"> <strong>{user.username}</strong> </div> ))} </div> </div> ); } function Page() { const queryClient = getQueryClient(); useEffect(() => { queryClient.prefetchQuery({ queryKey: ['products'], queryFn: fetchProducts }); queryClient.prefetchQuery({ queryKey: ['users'], queryFn: fetchUsers }); }, [queryClient]); return ( <div className="flex"> <ErrorBoundary FallbackComponent={ErrorFallback}> <Suspense fallback={<div>로딩 중...</div>}> <Products /> <Users /> </Suspense> </ErrorBoundary> </div> ); } export default Page;
마지막 해결방법은 부모 레벨의 컴포넌트에서 prefetchQuery를 호출하는 방법이다.
이렇게 하면 api호출은 모두 동시에 이뤄지고,
자식 컴포넌트가 마운트되는 시점에서는 호출할 쿼리를 이미 가지고 있게 되기 때문에 추가로 네트워크 요청이 발생하지 않는다. (staletime을 5초 이상으로 넉넉히 설정하는 편이 좋다)
해결 방법 #3 응용
export default function Page() { // 서버에서 쿼리 클라이언트를 생성 후 쿼리 미리 가져오기(prefetch) const queryClient = getQueryClient(); queryClient.prefetchQuery({ queryKey: ['products'], queryFn: fetchProducts }); queryClient.prefetchQuery({ queryKey: ['users'], queryFn: fetchUsers }); // 미리 가져온 데이터를 dehydrat const dehydratedState = dehydrate(queryClient); return ( // HydrationBoundary에 미리 가져온 상태를 전달하여 클라이언트 쪽에서 재사용합니다. <div className="flex"> <HydrationBoundary state={dehydratedState}> <ErrorBoundary FallbackComponent={ErrorFallback}> <Suspense fallback={<div>로딩 중...</div>}> <Products /> <Users /> </Suspense> </ErrorBoundary> </HydrationBoundary> </div> ); }
부모 컴포넌트가 서버 컴포넌트라면, 서버 사이드에서 데이터를 prefetch할 수 있다.
이 데이터를 HydrationBoundary로 감싸 클라이언트 사이드로 전달하면, 클라이언트는 별도의 요청 없이 해당 상태를 바로 사용할 수 있다.
조심해야할 부분
위와같이 useSuspenseQuery를 사용하는 컴포넌트 하위컴포넌트에서 useSuspenseQuery를 호출하여도 네트워크 waterfall이 발생한다.
결론
useSuspenseQuery는 Suspense와 조합하여 에러 및 로딩 상태에 대한 처리 부담을 줄여주지만 네트워크 waterfall과 같은 문제가 발생할 수 있다.
이를 해결하기 위해서는 컴포넌트 트리 설계나 prefetch가 필요할 수 있으니 useSuspensQuery를 사용할때는 이러한 점을 주의해서 사용해야한다.
이곳에서 예제를 확인해보실 수 있습니다