TanStack Query: useQuery vs useSuspenseQuery 완벽 가이드
TanStack Query의 useQuery와 useSuspenseQuery 훅의 차이점을 실제 코드 예시와 함께 상세히 비교하고, 각각의 장단점과 적절한 사용 시기를 알아봅니다.
TanStack Query: useQuery vs useSuspenseQuery
들어가며
React 애플리케이션에서 서버 상태 관리는 항상 복잡한 문제였습니다. 로딩 상태, 에러 처리, 캐싱, 재시도 로직 등을 모두 고려해야 하죠. TanStack Query(구 React Query)는 이런 문제들을 우아하게 해결해주는 라이브러리입니다.
TanStack Query v5에서는 기존의 useQuery와 함께 새로운 useSuspenseQuery 훅이 도입되었습니다. 이 두 훅은 비슷해 보이지만 서로 다른 패러다임을 따르고 있어, 언제 어떤 것을 사용해야 할지 고민이 될 수 있습니다.
이 글에서는 두 훅의 차이점을 실제 코드 예시와 함께 자세히 살펴보고, 각각의 장단점과 적절한 사용 시기를 알아보겠습니다.
useQuery vs useSuspenseQuery: 핵심 차이점
1. 로딩 상태 관리 철학
useQuery는 명령형(Imperative) 접근 방식을 취합니다. 개발자가 직접 로딩 상태를 확인하고 UI를 제어해야 합니다.
useSuspenseQuery는 선언형(Declarative) 접근 방식을 취합니다. React의 Suspense 시스템에 로딩 상태를 위임하고, 개발자는 성공 상태에만 집중할 수 있습니다.
2. 주요 차이점 요약
| 특징 | useQuery | useSuspenseQuery |
|---|---|---|
| 로딩 상태 | isPending, isLoading 플래그로 수동 처리 | React Suspense로 자동 처리 |
| 에러 처리 | 컴포넌트 내에서 직접 처리 | Error Boundary로 위임 |
| 데이터 타입 | TData | undefined | TData (항상 정의됨) |
| 조건부 실행 | enabled 옵션 지원 | enabled 옵션 불가 |
| 이전 데이터 유지 | placeholderData, keepPreviousData | React Transitions 권장 |
| TypeScript 안전성 | 타입 가드 필요 | 자동으로 타입 안전 보장 |
실제 사용 예시로 비교해보기
시나리오: 사용자 프로필 페이지
먼저 공통으로 사용할 API 함수를 정의해보겠습니다.
// API 함수
interface User {
id: string
name: string
email: string
avatar?: string
}
const fetchUser = async (userId: string): Promise<User> => {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error(`사용자를 찾을 수 없습니다: ${response.status}`)
}
return response.json()
}
const fetchUserPosts = async (userId: string) => {
const response = await fetch(`/api/users/${userId}/posts`)
if (!response.ok) {
throw new Error(`게시글을 불러올 수 없습니다: ${response.status}`)
}
return response.json()
}
1. useQuery 사용 예시
import { useQuery } from '@tanstack/react-query'
function UserProfileWithUseQuery({ userId }: { userId: string }) {
const {
data: user,
isLoading,
isError,
error,
isSuccess
} = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // 조건부 실행 가능
staleTime: 5 * 60 * 1000, // 5분간 신선함 유지
retry: 3, // 3번 재시도
})
// 로딩 상태 처리
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-2">사용자 정보를 불러오는 중...</span>
</div>
)
}
// 에러 상태 처리
if (isError) {
return (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<h3 className="font-bold">오류가 발생했습니다</h3>
<p>{error?.message}</p>
<button
onClick={() => window.location.reload()}
className="mt-2 bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600"
>
다시 시도
</button>
</div>
)
}
// 성공 상태 - 여전히 user가 undefined일 수 있어 타입 체크 필요
if (isSuccess && user) {
return (
<div className="bg-white shadow-lg rounded-lg p-6">
<div className="flex items-center space-x-4">
{user.avatar && (
<img
src={user.avatar}
alt={user.name}
className="w-16 h-16 rounded-full"
/>
)}
<div>
<h2 className="text-2xl font-bold text-gray-800">{user.name}</h2>
<p className="text-gray-600">{user.email}</p>
</div>
</div>
{/* 종속 쿼리 - 사용자 정보가 있을 때만 실행 */}
<UserPostsWithUseQuery userId={user.id} />
</div>
)
}
return null
}
// 종속 쿼리 컴포넌트
function UserPostsWithUseQuery({ userId }: { userId: string }) {
const { data: posts, isLoading, error } = useQuery({
queryKey: ['user-posts', userId],
queryFn: () => fetchUserPosts(userId),
enabled: !!userId, // 부모 쿼리가 성공한 후에만 실행
})
if (isLoading) {
return <div className="mt-4 text-gray-500">게시글을 불러오는 중...</div>
}
if (error) {
return <div className="mt-4 text-red-500">게시글 로딩 실패</div>
}
return (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3">최근 게시글</h3>
<div className="space-y-2">
{posts?.map((post: any) => (
<div key={post.id} className="p-3 bg-gray-50 rounded">
<h4 className="font-medium">{post.title}</h4>
<p className="text-sm text-gray-600">{post.excerpt}</p>
</div>
))}
</div>
</div>
)
}
2. useSuspenseQuery 사용 예시
import { useSuspenseQuery } from '@tanstack/react-query'
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary' // npm install react-error-boundary
function UserProfileWithSuspenseQuery({ userId }: { userId: string }) {
// useSuspenseQuery는 enabled 옵션이 없음
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => {
if (!userId) {
// enabled 대신 queryFn에서 조건 처리
return Promise.resolve(null)
}
return fetchUser(userId)
},
staleTime: 5 * 60 * 1000,
retry: 3,
})
// 로딩이나 에러 상태 체크 불필요
// data는 항상 정의되어 있음 (TypeScript에서 User 타입으로 추론)
if (!user) {
return <div>유효하지 않은 사용자 ID입니다.</div>
}
return (
<div className="bg-white shadow-lg rounded-lg p-6">
<div className="flex items-center space-x-4">
{user.avatar && (
<img
src={user.avatar}
alt={user.name}
className="w-16 h-16 rounded-full"
/>
)}
<div>
<h2 className="text-2xl font-bold text-gray-800">{user.name}</h2>
<p className="text-gray-600">{user.email}</p>
</div>
</div>
{/* 종속 쿼리 */}
<Suspense fallback={<div className="mt-4 text-gray-500">게시글을 불러오는 중...</div>}>
<UserPostsWithSuspenseQuery userId={user.id} />
</Suspense>
</div>
)
}
function UserPostsWithSuspenseQuery({ userId }: { userId: string }) {
const { data: posts } = useSuspenseQuery({
queryKey: ['user-posts', userId],
queryFn: () => fetchUserPosts(userId),
})
return (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3">최근 게시글</h3>
<div className="space-y-2">
{posts.map((post: any) => (
<div key={post.id} className="p-3 bg-gray-50 rounded">
<h4 className="font-medium">{post.title}</h4>
<p className="text-sm text-gray-600">{post.excerpt}</p>
</div>
))}
</div>
</div>
)
}
3. 앱 전체 구조 비교
// useQuery를 사용한 앱 구조
function AppWithUseQuery() {
const [userId, setUserId] = useState<string>('123')
return (
<div className="max-w-2xl mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">사용자 프로필 (useQuery)</h1>
<div className="mb-4">
<input
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="사용자 ID 입력"
className="border border-gray-300 rounded px-3 py-2 w-full"
/>
</div>
{/* 각 컴포넌트에서 개별적으로 로딩/에러 처리 */}
<UserProfileWithUseQuery userId={userId} />
</div>
)
}
// useSuspenseQuery를 사용한 앱 구조
function AppWithSuspenseQuery() {
const [userId, setUserId] = useState<string>('123')
return (
<div className="max-w-2xl mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">사용자 프로필 (useSuspenseQuery)</h1>
<div className="mb-4">
<input
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="사용자 ID 입력"
className="border border-gray-300 rounded px-3 py-2 w-full"
/>
</div>
{/* 전역적인 로딩/에러 처리 */}
<ErrorBoundary
fallback={({ error, resetErrorBoundary }) => (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<h3 className="font-bold">오류가 발생했습니다</h3>
<p>{error.message}</p>
<button
onClick={resetErrorBoundary}
className="mt-2 bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600"
>
다시 시도
</button>
</div>
)}
onReset={() => window.location.reload()}
>
<Suspense fallback={
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-2">로딩 중...</span>
</div>
}>
{userId && <UserProfileWithSuspenseQuery userId={userId} />}
</Suspense>
</ErrorBoundary>
</div>
)
}
복잡한 시나리오: 검색 기능 구현
useQuery로 검색 구현
function SearchWithUseQuery() {
const [searchTerm, setSearchTerm] = useState('')
const [debouncedTerm, setDebouncedTerm] = useState('')
// 디바운싱
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedTerm(searchTerm)
}, 300)
return () => clearTimeout(timer)
}, [searchTerm])
const {
data,
isLoading,
error,
isFetching,
isPreviousData
} = useQuery({
queryKey: ['search', debouncedTerm],
queryFn: () => searchUsers(debouncedTerm),
enabled: debouncedTerm.length > 2, // 3글자 이상일 때만 검색
keepPreviousData: true, // 이전 결과 유지하면서 새 데이터 로딩
staleTime: 1000 * 30, // 30초
})
return (
<div className="p-4">
<div className="relative">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="사용자 검색..."
className="w-full border rounded px-3 py-2"
/>
{isFetching && (
<div className="absolute right-2 top-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
</div>
)}
</div>
{searchTerm.length > 0 && searchTerm.length <= 2 && (
<p className="text-gray-500 mt-2">3글자 이상 입력해주세요</p>
)}
{error && (
<p className="text-red-500 mt-2">검색 중 오류가 발생했습니다</p>
)}
{isLoading && searchTerm.length > 2 && (
<div className="mt-4 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-2">검색 중...</p>
</div>
)}
{data && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm text-gray-600">
{data.length}개의 결과
</p>
{isPreviousData && (
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
업데이트 중...
</span>
)}
</div>
<div className="space-y-2">
{data.map((user: User) => (
<div key={user.id} className="p-3 border rounded hover:bg-gray-50">
<h3 className="font-semibold">{user.name}</h3>
<p className="text-gray-600">{user.email}</p>
</div>
))}
</div>
</div>
)}
</div>
)
}
useSuspenseQuery로 검색 구현
function SearchWithSuspenseQuery() {
const [searchTerm, setSearchTerm] = useState('')
const [debouncedTerm, setDebouncedTerm] = useState('')
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedTerm(searchTerm)
}, 300)
return () => clearTimeout(timer)
}, [searchTerm])
return (
<div className="p-4">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="사용자 검색..."
className="w-full border rounded px-3 py-2"
/>
{searchTerm.length > 0 && searchTerm.length <= 2 && (
<p className="text-gray-500 mt-2">3글자 이상 입력해주세요</p>
)}
{debouncedTerm.length > 2 && (
<ErrorBoundary
fallback={({ error, resetErrorBoundary }) => (
<div className="mt-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<p>검색 중 오류가 발생했습니다: {error.message}</p>
<button
onClick={resetErrorBoundary}
className="mt-2 bg-red-500 text-white px-3 py-1 rounded"
>
다시 시도
</button>
</div>
)}
>
<Suspense fallback={
<div className="mt-4 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-2">검색 중...</p>
</div>
}>
<SearchResults searchTerm={debouncedTerm} />
</Suspense>
</ErrorBoundary>
)}
</div>
)
}
function SearchResults({ searchTerm }: { searchTerm: string }) {
const { data } = useSuspenseQuery({
queryKey: ['search', searchTerm],
queryFn: () => searchUsers(searchTerm),
staleTime: 1000 * 30,
})
// React.startTransition을 사용하여 이전 결과 유지
return (
<div className="mt-4">
<p className="text-sm text-gray-600 mb-2">
{data.length}개의 결과
</p>
<div className="space-y-2">
{data.map((user: User) => (
<div key={user.id} className="p-3 border rounded hover:bg-gray-50">
<h3 className="font-semibold">{user.name}</h3>
<p className="text-gray-600">{user.email}</p>
</div>
))}
</div>
</div>
)
}
마이그레이션 고려사항
useQuery에서 useSuspenseQuery로 전환할 때
-
조건부 쿼리 처리
typescript// Before (useQuery) const { data } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), enabled: !!userId }) // After (useSuspenseQuery) // 조건부 렌더링으로 처리 if (!userId) return <div>사용자를 선택해주세요</div> const { data } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) }) -
이전 데이터 유지
typescript// Before (useQuery) const { data, isPreviousData } = useQuery({ queryKey: ['posts', page], queryFn: () => fetchPosts(page), keepPreviousData: true }) // After (useSuspenseQuery + React Transitions) const [page, setPage] = useState(1) const [isPending, startTransition] = useTransition() const { data } = useSuspenseQuery({ queryKey: ['posts', page], queryFn: () => fetchPosts(page) }) const handlePageChange = (newPage: number) => { startTransition(() => { setPage(newPage) }) } -
에러 처리 구조 변경
typescript// Before - 컴포넌트별 에러 처리 function Component() { const { data, error } = useQuery({...}) if (error) return <ErrorMessage error={error} /> return <SuccessView data={data} /> } // After - Error Boundary로 집중 function App() { return ( <ErrorBoundary fallback={ErrorFallback}> <Suspense fallback={<Loading />}> <Component /> </Suspense> </ErrorBoundary> ) }
언제 어떤 것을 사용할까?
useQuery를 선택해야 하는 경우
-
세밀한 로딩 상태 제어가 필요한 경우
- 여러 부분에서 다른 로딩 UI를 보여줘야 할 때
- 백그라운드 리페치 상태를 사용자에게 표시해야 할 때
-
조건부 쿼리가 많은 경우
- 폼 데이터나 사용자 입력에 따라 쿼리를 켜고 꺼야 할 때
- 복잡한 의존성을 가진 쿼리들
-
점진적 마이그레이션
- 기존 프로젝트에서 단계적으로 도입할 때
- 팀이 Suspense 패턴에 익숙하지 않을 때
-
이전 데이터 유지가 중요한 경우
- 페이지네이션, 필터링 등에서 깜빡임 없는 UX가 필요할 때
useSuspenseQuery를 선택해야 하는 경우
-
선언적 코드를 선호하는 경우
- 컴포넌트 로직을 단순화하고 싶을 때
- 로딩/에러 상태 처리를 외부로 위임하고 싶을 때
-
TypeScript 타입 안전성이 중요한 경우
data가 항상 정의되어 있음을 보장받고 싶을 때- 타입 가드 코드를 줄이고 싶을 때
-
현대적인 React 패턴을 활용하고 싶은 경우
- Concurrent Features와 함께 사용할 때
- Server Components와 Streaming을 활용할 때
-
일관된 에러 처리 전략이 있는 경우
- 앱 전체에서 통일된 에러 UI를 제공하고 싶을 때
성능 고려사항
쿼리 워터폴 문제
useSuspenseQuery는 컴포넌트 트리에서 직렬로 실행되는 경향이 있습니다 (근거 없음):
// 문제가 있는 패턴 - 직렬 실행
function UserDashboard({ userId }: { userId: string }) {
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
})
return (
<div>
<UserProfile user={user} />
<Suspense fallback={<div>프로젝트 로딩 중...</div>}>
<UserProjects userId={userId} /> {/* user 쿼리 완료 후 시작 */}
</Suspense>
</div>
)
}
// 개선된 패턴 - 병렬 실행
function UserDashboard({ userId }: { userId: string }) {
return (
<Suspense fallback={<div>로딩 중...</div>}>
<UserProfile userId={userId} />
<UserProjects userId={userId} />
</Suspense>
)
}
function UserProfile({ userId }: { userId: string }) {
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
})
return <div>{user.name}</div>
}
function UserProjects({ userId }: { userId: string }) {
const { data: projects } = useSuspenseQuery({
queryKey: ['projects', userId],
queryFn: () => fetchUserProjects(userId)
})
return <div>{projects.length} 프로젝트</div>
}
캐시 활용
두 훅 모두 같은 QueryClient 캐시를 공유하므로 함께 사용할 수 있습니다:
// useQuery로 프리페치
function HomePage() {
useQuery({
queryKey: ['user', 'current'],
queryFn: fetchCurrentUser,
staleTime: Infinity, // 캐시에 오래 보관
})
return <Link to="/profile">프로필 보기</Link>
}
// useSuspenseQuery로 사용 - 이미 캐시된 데이터 활용
function ProfilePage() {
const { data: user } = useSuspenseQuery({
queryKey: ['user', 'current'],
queryFn: fetchCurrentUser
})
return <UserProfile user={user} />
}
실전 팁
1. Error Boundary 최적화
import { QueryErrorResetBoundary } from '@tanstack/react-query'
function App() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div className="error-container">
<h2>문제가 발생했습니다</h2>
<details>
<summary>오류 상세 정보</summary>
<pre>{error.message}</pre>
</details>
<button onClick={resetErrorBoundary}>
다시 시도
</button>
</div>
)}
>
<Router>
<Routes>
<Route path="/" element={
<Suspense fallback={<PageLoader />}>
<HomePage />
</Suspense>
} />
</Routes>
</Router>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
)
}
2. 로딩 상태 최적화
// 스켈레톤 UI 활용
function ProductListSkeleton() {
return (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-gray-300 h-48 rounded mb-2"></div>
<div className="bg-gray-300 h-4 rounded mb-1"></div>
<div className="bg-gray-300 h-4 w-2/3 rounded"></div>
</div>
))}
</div>
)
}
function ProductList() {
return (
<Suspense fallback={<ProductListSkeleton />}>
<ProductListContent />
</Suspense>
)
}
3. 점진적 마이그레이션 전략
// 1단계: useQuery + suspense 옵션 (v4 스타일)
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
suspense: true // [v5에서는 deprecated](https://tanstack.com/query/v5/docs/react/guides/migrating-to-v5#removed-features)
})
// 2단계: 조건부로 useSuspenseQuery 도입
const USE_SUSPENSE = process.env.NODE_ENV === 'development'
function UserComponent() {
if (USE_SUSPENSE) {
return <UserWithSuspense />
}
return <UserWithUseQuery />
}
// 3단계: 완전 전환
function UserComponent() {
const { data } = useSuspenseQuery({
queryKey: ['user'],
queryFn: fetchUser
})
return <UserProfile user={data} />
}
결론
useQuery와 useSuspenseQuery는 각각 다른 철학과 사용 사례를 가지고 있습니다.
useQuery는 제어와 유연성을 제공합니다. 복잡한 로직, 조건부 쿼리, 세밀한 상태 관리가 필요한 경우에 적합합니다.
useSuspenseQuery는 단순함과 일관성을 제공합니다. 현대적인 React 패턴을 활용하여 선언적이고 타입 안전한 코드를 작성하고 싶을 때 적합합니다.
두 접근 방식 모두 장단점이 있으므로, 프로젝트의 요구사항, 팀의 경험, 그리고 장기적인 유지보수 전략을 고려하여 선택하는 것이 중요합니다. 무엇보다 두 훅은 같은 캐시를 공유하므로, 필요에 따라 혼용하여 사용할 수 있다는 점을 기억하세요.
React의 생태계가 Concurrent Features와 Suspense 쪽으로 발전하고 있는 만큼, useSuspenseQuery를 익혀두는 것은 미래를 위한 좋은 투자가 될 것입니다.