01 SOFTWARE
SDK

React Query

React 훅으로 데이터 페칭 및 캐싱하기

React Query

SDK는 TanStack Query (React Query)를 통합하여 데이터 페칭, 캐싱, 동기화를 쉽게 할 수 있습니다.

TanStack Query를 사용하면 자동 캐싱, 재검증, 백그라운드 업데이트 등의 이점을 얻을 수 있습니다.

훅 종류

SDK는 TanStack Query의 원형을 살린 다음 훅들을 제공합니다:

  • useQuery - 목록 조회
  • useSuspenseQuery - Suspense와 함께 목록 조회
  • useQueryById - 단일 항목 조회
  • useSuspenseQueryById - Suspense와 함께 단일 항목 조회
  • useInfiniteQuery - 무한 스크롤
  • useSuspenseInfiniteQuery - Suspense와 함께 무한 스크롤

useQuery

컬렉션의 목록을 조회합니다.

기본 사용법

'use client'

import { client } from '@/lib/client'

export default function ProductsPage() {
  const { data, isLoading, error } = client.query.useQuery(
    {
      collection: 'products',
      options: {
        limit: 10,
        where: { status: { equals: 'published' } }
      }
    }
  )

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>

  return (
    <div>
      {data?.map(product => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  )
}

반환값

{
  data?: T[]              // 컬렉션 데이터 배열
  isLoading: boolean      // 첫 로딩 상태
  isFetching: boolean     // 백그라운드 재검증 포함
  error: Error | null     // 에러 정보
  refetch: () => void     // 수동 재검증
  // ... 기타 TanStack Query 속성
}

쿼리 옵션

client.query.useQuery(
  {
    collection: 'products',
    options: {
      // API 쿼리 파라미터
      limit: 10,
      page: 1,
      sort: '-createdAt',
      where: { status: { equals: 'published' } }
    }
  },
  {
    // TanStack Query 옵션
    staleTime: 5 * 60 * 1000,        // 5분
    gcTime: 10 * 60 * 1000,          // 10분
    refetchOnWindowFocus: false,
    enabled: true,                    // 조건부 쿼리
    retry: 3,
  }
)

실전 예제

검색 기능

'use client'

import { client } from '@/lib/client'
import { useState } from 'react'

export default function SearchPage() {
  const [query, setQuery] = useState('')

  const { data, isLoading } = client.query.useQuery({
    collection: 'products',
    options: {
      where: {
        or: [
          { name: { like: `%${query}%` } },
          { description: { like: `%${query}%` } },
        ]
      },
      limit: 20,
    }
  })

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products..."
      />

      {isLoading && <div>Searching...</div>}

      <div>
        {data?.map(product => (
          <div key={product.id}>{product.name}</div>
        ))}
      </div>
    </div>
  )
}

페이지네이션

'use client'

import { client } from '@/lib/client'
import { useState } from 'react'

export default function ProductsPage() {
  const [page, setPage] = useState(1)

  const { data, isLoading } = client.query.useQuery(
    {
      collection: 'products',
      options: {
        page,
        limit: 20,
        sort: '-createdAt',
      }
    }
  )

  return (
    <div>
      {isLoading && <div>Loading...</div>}

      <div>
        {data?.map(product => (
          <div key={product.id}>{product.name}</div>
        ))}
      </div>

      <div>
        <button
          onClick={() => setPage(p => p - 1)}
          disabled={page <= 1}
        >
          Previous
        </button>

        <span>Page {page}</span>

        <button
          onClick={() => setPage(p => p + 1)}
          disabled={!data || data.length < 20}
        >
          Next
        </button>
      </div>
    </div>
  )
}

조건부 쿼리

'use client'

import { client } from '@/lib/client'

export default function UserProductsPage({ userId }: { userId?: string }) {
  const { data } = client.query.useQuery(
    {
      collection: 'products',
      options: {
        where: { userId: { equals: userId! } },
        limit: 10,
      }
    },
    {
      enabled: !!userId, // userId가 있을 때만 쿼리 실행
    }
  )

  if (!userId) return <div>Please select a user</div>

  return (
    <div>
      {data?.map(product => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  )
}

useSuspenseQuery

React Suspense와 함께 사용하는 훅입니다. enabled 옵션이 없고, 항상 데이터를 반환합니다.

'use client'

import { Suspense } from 'react'
import { client } from '@/lib/client'

function ProductList() {
  const { data } = client.query.useSuspenseQuery({
    collection: 'products',
    options: { limit: 10 }
  })

  // data는 항상 존재 (undefined가 아님)
  return (
    <div>
      {data.map(product => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  )
}

export default function ProductsPage() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ProductList />
    </Suspense>
  )
}

useQueryById

단일 항목을 ID로 조회합니다.

기본 사용법

'use client'

import { client } from '@/lib/client'

export default function ProductDetailPage({ id }: { id: string }) {
  const { data: product, isLoading } = client.query.useQueryById({
    collection: 'products',
    id,
  })

  if (isLoading) return <div>Loading...</div>
  if (!product) return <div>Product not found</div>

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>Price: {product.price}</p>
    </div>
  )
}

실전 예제

동적 라우트

'use client'

import { client } from '@/lib/client'
import { useParams } from 'next/navigation'

export default function PostPage() {
  const params = useParams()
  const id = params.id as string

  const { data: post, isLoading, error } = client.query.useQueryById(
    {
      collection: 'posts',
      id,
    },
    {
      enabled: !!id,
    }
  )

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
  if (!post) return <div>Post not found</div>

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

useSuspenseQueryById

Suspense와 함께 단일 항목을 조회합니다.

'use client'

import { Suspense } from 'react'
import { client } from '@/lib/client'

function ProductDetail({ id }: { id: string }) {
  const { data: product } = client.query.useSuspenseQueryById({
    collection: 'products',
    id,
  })

  if (!product) return <div>Not found</div>

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}</p>
    </div>
  )
}

export default function ProductPage({ id }: { id: string }) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ProductDetail id={id} />
    </Suspense>
  )
}

useInfiniteQuery

무한 스크롤을 구현합니다.

기본 사용법

'use client'

import { client } from '@/lib/client'

export default function InfiniteProductsPage() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = client.query.useInfiniteQuery({
    collection: 'products',
    pageSize: 20,
    options: {
      sort: '-createdAt',
    }
  })

  if (isLoading) return <div>Loading...</div>

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.map(product => (
            <div key={product.id}>{product.name}</div>
          ))}
        </div>
      ))}

      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  )
}

자동 무한 스크롤

Intersection Observer를 사용한 자동 로딩:

'use client'

import { client } from '@/lib/client'
import { useEffect, useRef } from 'react'

export default function InfiniteProductsPage() {
  const loadMoreRef = useRef<HTMLDivElement>(null)

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = client.query.useInfiniteQuery({
    collection: 'products',
    pageSize: 20,
  })

  useEffect(() => {
    if (!loadMoreRef.current || !hasNextPage) return

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && !isFetchingNextPage) {
          fetchNextPage()
        }
      },
      { threshold: 1.0 }
    )

    observer.observe(loadMoreRef.current)
    return () => observer.disconnect()
  }, [hasNextPage, isFetchingNextPage, fetchNextPage])

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.map(product => (
            <div key={product.id}>{product.name}</div>
          ))}
        </div>
      ))}

      {hasNextPage && (
        <div ref={loadMoreRef}>
          {isFetchingNextPage && <div>Loading more...</div>}
        </div>
      )}
    </div>
  )
}

서버 사이드 Prefetching

SSR/SSG를 위한 데이터 prefetch:

// app/products/page.tsx
import { client } from '@/lib/client'
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import ProductList from './ProductList'

export default async function ProductsPage() {
  // 서버에서 데이터 prefetch
  await client.query.prefetchQuery({
    collection: 'products',
    options: { limit: 10 }
  })

  return (
    <HydrationBoundary state={dehydrate(client.queryClient)}>
      <ProductList />
    </HydrationBoundary>
  )
}
// app/products/[id]/page.tsx
import { client } from '@/lib/client'
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import ProductDetail from './ProductDetail'

export default async function ProductPage({ params }: { params: { id: string } }) {
  await client.query.prefetchQueryById({
    collection: 'products',
    id: params.id,
  })

  return (
    <HydrationBoundary state={dehydrate(client.queryClient)}>
      <ProductDetail id={params.id} />
    </HydrationBoundary>
  )
}

캐싱 전략

Stale Time

데이터가 "신선한" 상태로 유지되는 시간:

client.query.useQuery(
  { collection: 'products' },
  { staleTime: 5 * 60 * 1000 } // 5분 동안 신선함
)
  • staleTime 동안은 캐시에서 즉시 데이터 반환
  • 시간 초과 후에는 백그라운드에서 재검증

GC Time (이전 cacheTime)

메모리에서 캐시를 유지하는 시간:

client.query.useQuery(
  { collection: 'products' },
  { gcTime: 10 * 60 * 1000 } // 10분 동안 캐시 유지
)

Refetch 전략

client.query.useQuery(
  { collection: 'products' },
  {
    refetchOnWindowFocus: true,   // 윈도우 포커스 시 재검증
    refetchOnMount: true,         // 마운트 시 재검증
    refetchInterval: 30000,       // 30초마다 재검증
  }
)

캐시 관리

invalidateQueries

// 특정 컬렉션의 모든 쿼리 무효화
client.query.invalidateQueries('products')

// 특정 타입만 무효화
client.query.invalidateQueries('products', 'list')
client.query.invalidateQueries('products', 'detail')
client.query.invalidateQueries('products', 'infinite')

getQueryData / setQueryData

// 캐시된 데이터 가져오기
const products = client.query.getQueryData('products', 'list')
const product = client.query.getQueryData('products', 'detail', '123')

// 캐시 데이터 직접 설정
client.query.setQueryData('products', 'list', newProducts)
client.query.setQueryData('products', 'detail', '123', updatedProduct)

Query Keys

SDK는 자동으로 query key를 생성합니다. 직접 사용하려면 collectionKeys 헬퍼를 사용하세요:

import { collectionKeys } from '@01.software/sdk'

// Query key 생성
collectionKeys('products').all              // ['products']
collectionKeys('products').lists()          // ['products', 'list']
collectionKeys('products').list({ limit: 10 })  // ['products', 'list', { limit: 10 }]
collectionKeys('products').details()        // ['products', 'detail']
collectionKeys('products').detail('123')    // ['products', 'detail', '123']
collectionKeys('products').infinites()      // ['products', 'infinite']

에러 처리

const { data, error, isError } = client.query.useQuery({
  collection: 'products',
})

if (isError) {
  return <div>Error: {error.message}</div>
}

로딩 상태

const { data, isLoading, isFetching } = client.query.useQuery({
  collection: 'products'
})

// isLoading: 첫 로딩 (캐시 없음)
// isFetching: 모든 로딩 (백그라운드 재검증 포함)

스켈레톤 UI

function ProductList() {
  const { data, isLoading } = client.query.useQuery({
    collection: 'products',
    options: { limit: 10 }
  })

  if (isLoading) {
    return (
      <div className="grid grid-cols-3 gap-4">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="skeleton h-48" />
        ))}
      </div>
    )
  }

  return (
    <div className="grid grid-cols-3 gap-4">
      {data?.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

다음 단계

On this page