Framework/React & RN

TanStack Query v5로 GraphQL과 REST API 통합 관리하기

Joonfluence 2025. 8. 24.

요약

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 관련 자료

추가 학습 자료

반응형

댓글