요약
TanStack Query v5는 React Query의 후속 버전으로, GraphQL과 REST API를 하나의 통합된 인터페이스로 관리할 수 있는 강력한 서버 상태 관리 라이브러리입니다. 기존 버전 대비 개선된 TypeScript 지원, 더욱 직관적인 API, 그리고 프레임워크 독립적인 구조를 제공합니다. 특히 현재 Wisely 구조 상 BigCommerce Storefront GraphQL API와 자체 구축한 REST API를 동시에 사용하는 이커머스 환경에서, 일관된 캐싱 전략과 낙관적 업데이트를 통해 최적의 사용자 경험을 제공할 수 있습니다.
핵심 기능/개념 정리
TanStack Query v5 주요 변화점
항목 |
v4 (React Query) |
v5 (TanStack Query) |
패키지명 |
react-query |
@tanstack/react-query |
캐시 시간 |
cacheTime |
gcTime (Garbage Collection Time) |
타입 추론 |
부분적 지원 |
완전한 타입 추론 |
프레임워크 |
React 전용 |
React, Vue, Solid, Svelte 지원 |
에러 처리 |
기본적 에러 바운더리 |
향상된 에러 복구 메커니즘 |
Query Keys 관리 전략
// 중앙집중식 Query Keys 관리
export const QUERY_KEYS = {
// REST API Keys
USERS: ['users'],
USER_DETAIL: (id: string) => ['users', id],
USER_ORDERS: (userId: string) => ['users', userId, 'orders'],
// GraphQL Keys
CUSTOMER_ATTRIBUTES: ['customer', 'attributes'],
PRODUCT_CATALOG: ['products', 'catalog'],
CART_ITEMS: ['cart', 'items'],
// Hybrid Keys (REST + GraphQL 조합)
USER_PROFILE: (id: string) => ['users', id, 'profile'],
} as const
GraphQL vs REST API 사용 전략
API 타입 |
사용 시기 |
장점 |
TanStack Query 적용 |
GraphQL |
복잡한 데이터 관계, 실시간 업데이트 |
정확한 데이터 페칭, 타입 안전성 |
useQuery + Codegen |
REST API |
단순한 CRUD, 파일 업로드 |
구현 단순성, 캐싱 용이성 |
useQuery + useMutation |
하이브리드 |
초기 로딩(GraphQL) + 상태 변경(REST) |
각 API의 장점 극대화 |
통합 쿼리 키 관리 |
사용 예시 및 코드 스니펫
1. 기본 설정 및 Provider 구성
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 10 * 60 * 1000, // 10분간 fresh 상태 유지
gcTime: 1 * 60 * 60 * 1000, // 1시간 후 가비지 컬렉션
retry: (failureCount, error) => {
// HTTP 상태 코드에 따른 재시도 전략
if (error?.status === 404) return false
if (error?.status === 401) return false
return failureCount < 2
},
refetchOnWindowFocus: false, // 윈도우 포커스 시 재요청 비활성화
refetchOnMount: false, // 마운트 시 자동 재요청 비활성화
},
mutations: {
retry: 1,
onError: (error, variables, context) => {
console.error('Mutation 오류:', error)
// 전역 에러 토스트 등 처리
}
}
}
})
)
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
2. GraphQL + Codegen 통합 패턴
// codegen.ts - GraphQL 코드 생성 설정
import { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: {
[`https://store-${process.env.BIGCOMMERCE_STORE_HASH}.mybigcommerce.com/graphql`]: {
headers: {
Authorization: `Bearer ${process.env.BIGCOMMERCE_STOREFRONT_TOKEN}`,
}
}
},
documents: ['app/**/*.graphql'],
generates: {
'app/gql/graphql.ts': {
plugins: [
'typescript',
'typescript-operations',
]
},
'app/gql/hooks.ts': {
preset: 'import-types',
presetConfig: {
typesPath: './graphql'
},
plugins: [
'typescript-react-query'
],
config: {
fetcher: 'app/gql/fetcher#graphqlFetcher'
}
}
}
}
// app/gql/fetcher.ts - GraphQL 요청 함수
export const graphqlFetcher = async <T>(
query: string,
variables?: Record<string, any>,
customerToken?: string
): Promise<T> => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (customerToken) {
headers['Authorization'] = `Bearer ${customerToken}`
} else {
headers['Authorization'] = `Bearer ${process.env.NEXT_PUBLIC_STOREFRONT_TOKEN}`
}
const response = await fetch(
`https://store-${process.env.NEXT_PUBLIC_STORE_HASH}.mybigcommerce.com/graphql`,
{
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
}
)
const data = await response.json()
if (data.errors) {
throw new Error(data.errors[0].message)
}
return data.data
}
3. GraphQL 커스텀 훅 구현
// app/entities/customer/model/hooks/use-customer-attributes.ts
import { useQuery } from '@tanstack/react-query'
import { QUERY_KEYS } from 'app/shared/constants/query-keys'
import { CustomerAttributesDocument } from 'app/gql/graphql'
import { graphqlFetcher } from 'app/gql/fetcher'
import { getCustomerAccessToken } from 'app/shared/lib/auth-utils'
export function useCustomerAttributes() {
return useQuery({
queryKey: [QUERY_KEYS.CUSTOMER_ATTRIBUTES],
queryFn: async () => {
const customerToken = getCustomerAccessToken()
if (!customerToken) throw new Error('로그인이 필요합니다')
return graphqlFetcher(
CustomerAttributesDocument,
{},
customerToken
)
},
enabled: !!getCustomerAccessToken(), // 토큰이 있을 때만 실행
staleTime: 5 * 60 * 1000, // 5분간 캐시
gcTime: 30 * 60 * 1000, // 30분간 보관
})
}
// app/entities/customer/model/hooks/use-customer-update.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { CustomerUpdateDocument } from 'app/gql/graphql'
import { QUERY_KEYS } from 'app/shared/constants/query-keys'
interface CustomerUpdateData {
firstName?: string
lastName?: string
email?: string
}
export function useCustomerUpdate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (updateData: CustomerUpdateData) => {
const customerToken = getCustomerAccessToken()
if (!customerToken) throw new Error('로그인이 필요합니다')
return graphqlFetcher(
CustomerUpdateDocument,
{ input: updateData },
customerToken
)
},
onSuccess: (data, variables) => {
// 캐시 업데이트 - 낙관적 업데이트
queryClient.setQueryData(
[QUERY_KEYS.CUSTOMER_ATTRIBUTES],
(oldData: any) => ({
...oldData,
customer: {
...oldData?.customer,
...variables
}
})
)
// 관련 쿼리들 무효화
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.CUSTOMER_ATTRIBUTES]
})
},
onError: (error, variables, context) => {
console.error('고객 정보 업데이트 실패:', error)
// 에러 시 캐시 복구 로직도 추가 가능
}
})
}
4. REST API와 GraphQL 하이브리드 패턴
// app/features/cart/model/hooks/use-cart-management.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { QUERY_KEYS } from 'app/shared/constants/query-keys'
// GraphQL로 장바구니 조회 (복잡한 상품 정보 포함)
export function useCartItems() {
return useQuery({
queryKey: [QUERY_KEYS.CART_ITEMS],
queryFn: async () => {
return graphqlFetcher(GetCartDocument, {
cartId: getCartId()
})
},
staleTime: 2 * 60 * 1000, // 2분 (자주 변경되는 데이터)
})
}
// REST API로 장바구니 수정 (단순한 상태 변경)
export function useAddToCart() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ productId, quantity }: { productId: string, quantity: number }) => {
const response = await fetch('/api/cart/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, quantity })
})
if (!response.ok) throw new Error('장바구니 추가 실패')
return response.json()
},
onMutate: async (newItem) => {
// 낙관적 업데이트
await queryClient.cancelQueries({ queryKey: [QUERY_KEYS.CART_ITEMS] })
const previousCart = queryClient.getQueryData([QUERY_KEYS.CART_ITEMS])
queryClient.setQueryData([QUERY_KEYS.CART_ITEMS], (oldData: any) => {
// 임시로 새 아이템 추가
return {
...oldData,
cart: {
...oldData?.cart,
lineItems: [
...oldData?.cart?.lineItems,
{ productId: newItem.productId, quantity: newItem.quantity }
]
}
}
})
return { previousCart }
},
onError: (err, newItem, context) => {
// 오류 시 이전 상태로 복구
queryClient.setQueryData([QUERY_KEYS.CART_ITEMS], context?.previousCart)
},
onSettled: () => {
// 성공/실패 상관없이 최신 데이터 재요청
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CART_ITEMS] })
}
})
}
5. 복잡한 데이터 동기화 패턴
// app/features/order/model/hooks/use-order-sync.ts
export function useOrderSync(orderId: string) {
const queryClient = useQueryClient()
// GraphQL로 주문 상세 정보 조회
const orderQuery = useQuery({
queryKey: [QUERY_KEYS.ORDER_DETAIL, orderId],
queryFn: () => graphqlFetcher(GetOrderDocument, { orderId }),
enabled: !!orderId,
})
// REST API로 배송 상태 확인 (외부 API 연동)
const shippingQuery = useQuery({
queryKey: [QUERY_KEYS.SHIPPING_STATUS, orderId],
queryFn: () => fetch(`/api/shipping/status/${orderId}`).then(res => res.json()),
enabled: !!orderId && !!orderQuery.data?.order?.shippingTrackingNumber,
refetchInterval: 30 * 1000, // 30초마다 배송 상태 확인
})
// 주문 취소 (REST API)
const cancelMutation = useMutation({
mutationFn: (reason: string) =>
fetch(`/api/orders/${orderId}/cancel`, {
method: 'POST',
body: JSON.stringify({ reason })
}),
onSuccess: () => {
// GraphQL 캐시와 REST API 캐시 모두 무효화
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ORDER_DETAIL, orderId] })
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.USER_ORDERS] })
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.SHIPPING_STATUS, orderId] })
}
})
return {
order: orderQuery.data,
shipping: shippingQuery.data,
isLoading: orderQuery.isLoading || shippingQuery.isLoading,
cancelOrder: cancelMutation.mutateAsync,
isCancelling: cancelMutation.isPending,
}
}
실제 사용 시 주의점 / Best Practice
1. Query Keys 설계 원칙
// ✅ 좋은 Query Keys 설계
export const QUERY_KEYS = {
// 계층적 구조
USERS: ['users'],
USER_DETAIL: (id: string) => ['users', id],
USER_POSTS: (userId: string) => ['users', userId, 'posts'],
// 필터링 가능한 구조
PRODUCTS: (filters?: ProductFilters) => ['products', { ...filters }],
// API 타입 구분
GRAPHQL_CUSTOMER: ['graphql', 'customer'],
REST_CUSTOMER: ['rest', 'customer'],
} as const
// ❌ 피해야 할 Query Keys 패턴
const BAD_KEYS = {
USER_DATA: 'user-data', // 문자열 사용 X
USER_POSTS: ['user-posts'], // 매개변수 없는 정적 키
EVERYTHING: ['data'], // 너무 광범위한 키
}
2. 에러 처리 및 재시도 전략
// 에러 타입별 재시도 전략
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// GraphQL 에러
if (error.message?.includes('GraphQL')) {
return failureCount < 1 // GraphQL은 1회만 재시도
}
// REST API 에러
if (error.status >= 500) {
return failureCount < 3 // 서버 에러는 3회 재시도
}
if (error.status === 401) {
// 인증 에러 시 토큰 갱신 후 재시도
refreshAuthToken()
return failureCount < 1
}
return false // 기타 에러는 재시도 안함
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
}
}
})
// 전역 에러 바운더리
function GlobalErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<h2>문제가 발생했습니다</h2>
<details>{error.message}</details>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
)}
>
{children}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
)
}
3. 캐시 무효화 전략
// 스마트 캐시 무효화
export function useSmartInvalidation() {
const queryClient = useQueryClient()
return {
// 특정 사용자의 모든 데이터 무효화
invalidateUser: (userId: string) => {
queryClient.invalidateQueries({
predicate: (query) => {
const [firstKey, secondKey] = query.queryKey
return (
(firstKey === 'users' && secondKey === userId) ||
(firstKey === 'graphql' && query.queryKey.includes(userId))
)
}
})
},
// 장바구니 관련 모든 캐시 무효화
invalidateCart: () => {
queryClient.invalidateQueries({ queryKey: ['cart'] })
queryClient.invalidateQueries({ queryKey: ['products'] }) // 재고 정보 업데이트
},
// 부분 데이터 업데이트
updateProductInCache: (productId: string, updates: Partial<Product>) => {
queryClient.setQueriesData(
{ queryKey: ['products'] },
(oldData: any) => {
return oldData?.products?.map((product: Product) =>
product.id === productId ? { ...product, ...updates } : product
)
}
)
}
}
}
4. 성능 최적화 패턴
// 데이터 사전 로딩 (Prefetching)
export function usePrefetchStrategies() {
const queryClient = useQueryClient()
return {
// 상품 상세 페이지 사전 로딩
prefetchProduct: (productId: string) => {
queryClient.prefetchQuery({
queryKey: [QUERY_KEYS.PRODUCT_DETAIL, productId],
queryFn: () => graphqlFetcher(GetProductDocument, { productId }),
staleTime: 10 * 60 * 1000,
})
},
// 사용자 행동 기반 사전 로딩
prefetchUserFlow: async () => {
// 장바구니 데이터 사전 로딩
await queryClient.prefetchQuery({
queryKey: [QUERY_KEYS.CART_ITEMS],
queryFn: fetchCartItems,
})
// 배송지 정보 사전 로딩
await queryClient.prefetchQuery({
queryKey: [QUERY_KEYS.USER_ADDRESSES],
queryFn: fetchUserAddresses,
})
}
}
}
// 무한 스크롤 최적화
export function useInfiniteProducts(category?: string) {
return useInfiniteQuery({
queryKey: [QUERY_KEYS.PRODUCTS, 'infinite', category],
queryFn: ({ pageParam = 1 }) =>
graphqlFetcher(GetProductsDocument, {
first: 20,
after: pageParam,
categoryId: category
}),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.products.pageInfo.endCursor,
staleTime: 5 * 60 * 1000,
maxPages: 10, // 메모리 사용량 제한
})
}
5. 디버깅 및 모니터링
// 개발 환경 디버깅 설정
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
if (process.env.NODE_ENV === 'development') {
// 쿼리 상태 로깅
queryClient.getQueryCache().subscribe((event) => {
console.log('Query Event:', {
type: event.type,
query: event.query.queryKey,
state: event.query.state
})
})
// 뮤테이션 로깅
queryClient.getMutationCache().subscribe((event) => {
console.log('Mutation Event:', {
type: event.type,
mutation: event.mutation.options.mutationKey,
state: event.mutation.state
})
})
}
// 프로덕션 환경 성능 모니터링
const performanceLogger = {
onSuccess: (data: any, key: QueryKey) => {
const endTime = performance.now()
const startTime = performance.getEntriesByName(`query-${key.join('-')}`)[0]?.startTime
if (startTime) {
console.log(`Query ${key.join('-')} took ${endTime - startTime}ms`)
}
},
onError: (error: any, key: QueryKey) => {
// 에러 트래킹 서비스(Sentry 등)로 전송
console.error(`Query ${key.join('-')} failed:`, error)
}
}
참고자료 / 공식 문서 출처
공식 문서
실무 활용 가이드
성능 최적화 자료
GraphQL 관련 자료
추가 학습 자료
댓글