2025년 04월 10일

4

서버에서 prefetch를 할 경우 Suspense는 불필요한 것일까

Tanstack Query

HS
Hyungseok Kwon
@hskwon5170
robot

이 글은 prefetch, Suspense, useSuspenseQuery데이터 갱신 에 대한 글이에요.

서버 컴포넌트에서 데이터를 미리 불러오고(pre-fetch) 이를 클라이언트에 전달하는 패턴이 있지만, 이 방식이 클라이언트 데이터 갱신과 별개로 작동한다는 점이 핵심이에요. 서버에서 prefetch된 데이터는 초기 로드 시에는 별다른 fallback 없이 바로 보여지지만, 클라이언트에서 staleTime 이후 다시 데이터를 요청할 때는 Suspense의 fallback UI가 필요하죠. useSuspenseQuery는 로딩과 에러 관리를 선언적으로 처리하며, 이를 Suspense와 함께 사용하면 더 간단하게 UI 상태를 제어할 수 있어요. 결론적으로, 서버 prefetch와 클라이언트의 데이터 refetch는 별개이기 때문에, 클라이언트 갱신 시에는 Suspense fallback이 필요하며, useSuspenseQuery는 이를 더욱 쉽게 구현할 수 있게 도와준다는 내용입니다.

image

Tanstack Query의 prefetch와 Suspense

서버 컴포넌트에서 이미 prefetch를 해서 캐싱을 해놓는데 Suspense를 이용해 Fallback UI를 보여줄 필요가 있는걸까?

서버 컴포넌트에서 prefetch를 하고, dehydratedState를 자식 컴포넌트로 전달하는 패턴을 사용하면서 든 궁금증이였습니다. 이미 서버에서 데이터를 prefetch하여 렌더링 준비를 마친 HTML을 생성하고 클라이언트로 넘겨주는 패턴을 사용할경우 Suspense 컴포넌트를 이용해 fallback UI를 보여줄 필요가 과연 생길까?라는 생각이였는데요. 우선 명심해야 할 것은 서버 컴포넌트에서의 prefetch와 클라이언트에서의 데이터 갱신은 서로 다른 사이클로 동작한다는 것 입니다.

서버 컴포넌트에서의 prefetch가 실행되는 조건

  • 유저가 처음 페이지에 접근할 때
  • 페이지를 새로고침할 때
  • revalidatePath 혹은 revalidateTag, router.refresh를 사용하여 강제로 서버 컴포넌트를 갱신할때
  • 라우트의 paramssearchParams가 변경되어 페이지가 다시 로드될 때

클라이언트 컴포넌트에서의 데이터 갱신

  • staleTime이 지났을때
  • invalidateQueries를 실행했을 때 등

이 경우 클라이언트 상태가 갱신될 뿐 서버 컴포넌트 재실행을 트리거하지는 않습니다. 결과적으로 클라이언트에서 데이터가 갱신되더라도 서버의 prefetch 데이터는 자동으로 업데이트 되지 않습니다.

그래서 결론은 위와 같은 패턴에서도 Suspense는 "여전히 필요하다" 입니다.

결과적으로 prefetch가 다시 실행되는 조건은 위에 언급된 상황들인데요. 클라이언트 컴포넌트에서 기본 default stale time이 지난경우 서버에서 다시 prefetch가 되지 않고 클라이언트에서 데이터가 다시 요청되는데 이때 해당 자식 컴포넌트를 Suspense로 감쌀경우 fallback UI를 보여줄 수 있게 되는 것입니다.

useQuery vs useSuspenseQuery 비교

특성useQueryuseSuspenseQuery
로딩 상태컴포넌트 내에서 isLoading으로 처리Suspense 컴포넌트에 위임
에러 처리errorthrow해야함자동으로 throw (개발자가 throw 할 필요 없음)

useSuspenseQuery

자식 컴포넌트에서 useSuspenseQuery를 이용해 캐싱된 데이터를 이용하면서 fallback UI를 보여줄 필요가 있는 경우, 해당 자식 컴포넌트를 Suspense로 감싸면 됩니다.
이로써 로딩 상태와 관련된 관심사를 분리하고, 선언적으로 코드를 작성할 수 있습니다.

또한 useSuspenseQuery는 에러 발생 시 자동으로 throw하기 때문에, 별도의 에러 처리 로직이 필요 없습니다.
반면 useQuery는 수동으로 throw 해야 ErrorBoundary에서 잡을 수 있습니다.

1// useQuery 사용 시 에러 처리 코드 2const { data, error } = useQuery({ ... }); 3if (error) throw error; // ErrorBoundary가 에러를 포착하도록 수동으로 throw 4 5// useSuspenseQuery 사용 시 에러 처리 코드 불필요 6const { data } = useSuspenseQuery({ ... }); 7// 에러 발생 시 자동으로 throw되어 ErrorBoundary가 포착

전체 구현 예시

서버 컴포넌트

1// 서버 컴포넌트 2async function ProductPage({ id }) { 3 // 서버에서 prefetch 4 await prefetchQuery({ 5 queryKey: ['product', id], 6 queryFn: () => fetchProduct(id), 7 staleTime: 60000 // 1분 8 }); 9 10 return ( 11 <HydrationBoundary state={dehydrate(queryClient)}> 12 <ErrorBoundary fallback={<ProductError />}> 13 <Suspense fallback={<ProductSkeleton />}> 14 <ProductDetails id={id} /> 15 </Suspense> 16 </ErrorBoundary> 17 </HydrationBoundary> 18 ); 19}

클라이언트 컴포넌트

1'use client' 2 3function ProductDetails({ id }) { 4 const { data } = useSuspenseQuery({ 5 queryKey: ['product', id], 6 queryFn: () => fetchProduct(id), 7 staleTime: 60000 8 }); 9 10 return <div>{data.name} - {data.price}</div>; 11}

결론

  • 서버 컴포넌트의 prefetch와 클라이언트의 데이터 갱신은 별개의 사이클로 동작합니다.
  • 초기 로드 시에는 prefetch 덕분에 Suspense fallback UI가 보이지 않습니다. (서버에서 이미 데이터를 prefetch했기때문에)
  • 클라이언트에서 staleTime 이후 refetch가 발생할 경우 서버컴포넌트의 prefetch가 자동으로 트리거되지 않기 때문에 Suspense의 fallback UI를 보여줄 필요가 있습니다.
  • useSuspenseQuery를 사용하면 로딩 상태와 에러 처리를 선언적으로 관리할 수 있습니다.