TanStack Query(React Query)

천천히조금씩·2025년 6월 4일

React ...에 대하여

목록 보기
6/6

🎯 TanStack Query(React Query) 란?

  • TanStack Query란?
    서버로부터 데이터 가져오기, 데이터 캐싱, 캐시 제어 등 데이터를 쉽고 효율적으로 관리할 수 있는 라이브러리이다.
  • React Query라는 이름으로 시작했지만, v4부터 Vue나 Svelte 등의 다른 프레임워크에서도 활용할 수 있도록 기능이 확장되며 TanStack Query라는 이름으로 변경되었다.
  • 공식 문서 링크
  • 참고한 블로그

✍ 대표적인 기능

  • 데이터 가져오기 및 캐싱
  • 동일 요청의 중복 제거
  • 신선한 데이터 유지
  • 무한 스크롤, 페이지네이션 등의 성능 최적화
  • 네트워크 재연결, 요청 실패 등의 자동 갱신

📌 데이터 캐싱

  • TanStack Query를 활용해서 데이터를 가져올 때는 항상 쿼리 키(queryKey)를 지정하게 된다.
  • 쿼리 키는 캐시된 데이터와 비교해 새로운 데이터를 가져올 지, 캐시된 데이터를 사용할 지 결정하는 기준이 된다.
import { useQuery } from '@tanstack/react-query'

export default function DelayedData() {
  const { data } = useQuery({
    queryKey: ['delay'],
    queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json()
  })
  return <div>{JSON.stringify(data)}</div>
}
  • 다음 이미지는 쿼리 키와 일치하는 캐시된 데이터가 없을 때, 서버에서 새로운 데이터를 가져오는 과정이다.

  • 서버에서 데이터를 가져오면 그 데이터는 캐시되고 그 이후 요청부터는 캐시된 데이터를 사용할 수 있다.

  • 반대로 쿼리 키와 일치하는 캐시된 데이터가 있으면 서버에 요청하지 않고 캐시된 데이터를 사용한다.

  • 같은 데이터를 가져오는 요청이 여러 번 발생해도, 캐시된 데이터를 사용하게 되어 중복 요청을 줄일 수 있다.


📌 데이터의 신선도

  • TanStack Query는 캐시한 데이터를 fresh 상태나 stale 상태로 구분하여 관리한다.

  • 캐시된 데이터가 신선하다면 해당 데이터를 사용하고, 데이터가 상했다면 서버에 다시 요청에 새로운(신선한) 데이터를 가져온다.

  • 데이터가 상하는 데까지 걸리는 시간은 staleTime 옵션으로 지정한다.

  • 신선함의 여부는 isStale로 확인할 수 있다.

import { useQuery } from '@tanstack/react-query'

export default function DelayedData() {
  const { data, isStale } = useQuery({
    queryKey: ['delay'],
    queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
    staleTime: 1000 * 10 // 10초 후 상함. 즉, 10초 동안 신선함.
  })
  return (
    <>
      <div>데이터가 {isStale ? '상했어요..' : '신선해요!'}</div>
      <div>{JSON.stringify(data)}</div>
    </>
  )
}

📌 사용하기

✍ 설치 및 구성

아래 명령어를 통해 TanStack Query를 설치한다. ESLint 플러그인을 사용할 수도 있다.

npm i @tanstack/react-query
npm i -D @tanstack/eslint-plugin-query 
  • ESLint 플러그인의 권장 규칙을 사용하면, 실수를 피하는 데 도움이 된다.
  • extends 옵션의 배열 아이템으로 plugin:@tanstack/eslint-plugin-query/recommended를 추가한다.
module.exports = {
  root: true,
  env: { browser: true, es2020: true },
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react-hooks/recommended',
    'plugin:@tanstack/eslint-plugin-query/recommended'
  ],
  ignorePatterns: ['dist', '.eslintrc.cjs'],
  parser: '@typescript-eslint/parser',
  plugins: ['react-refresh'],
  rules: {
    'react-refresh/only-export-components': [
      'warn',
      { allowConstantExport: true }
    ],
    // TanStack Query 권장 규칙! (plugin:@tanstack/eslint-plugin-query/recommended)
    // '@tanstack/query/exhaustive-deps': 'error',
    // '@tanstack/query/stable-query-client': 'error',
    // '@tanstack/query/no-rest-destructuring': 'warn'
  }
}
  • @tanstack/query/exhaustive-deps : 쿼리 함수에서 사용하는 외부 변수는 쿼리 키에 추가해야 한다.
  • @tanstack/query/stable-query-client : 애플리케이션에서 하나의 쿼리 클라이언트를 생성해 사용해야 한다.
  • @tanstack/query/no-rest-destructuring : 쿼리의 반환에서 나머지 매개변수(...rest)를 사용하면 안 된다.

프로젝트 범위를 <QueryClientProvider>로 랩핑하고, 사용할 queryClient 인스턴스를 연결하면 사용할 준비가 끝난다.

import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
import DelayedData from '~/components/DelayedData'

const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <DelayedData />
    </QueryClientProvider>
  )
}

📌 핵심 기능

✍ useQuery

가장 기본적인 쿼리 훅으로, 컴포넌트에서 데이터를 가져올 때 사용한다.

const 반환 = useQuery<데이터타입>(옵션)

💻 지연 응답 API 예제
응답 데이터는 간단한 메시지와 응답 시간을 포함한다.

import { useQuery } from '@tanstack/react-query'

type ResponseValue = {
  message: string
  time: string
}

export default function DelayedData() {
  const { data } = useQuery<ResponseValue>({
    queryKey: ['delay'],
    queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
    staleTime: 1000 * 10 // 10초
  })
  return <div>{data?.time}</div>
}

✍ 옵션

  • 수많은 옵션 중 몇 가지만 정리한다.
  • 옵션들은 너무 다양하므로 위에 첨부한 블로그를 참고하자.

💻 queryKey

  • 쿼리 키는 쿼리를 식별하는 고유한 값으로 배열 형태로 지정한다.
  • 다중 아이템 쿼리 키를 사용할 때는 아이템의 순서가 중요하다.
// 단일 아이템 쿼리 키
useQuery({ queryKey: ['hello'] })

// 다중 아이템 쿼리 키
useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2 }] })

// 서로 같은 쿼리
useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2 }] })
useQuery({ queryKey: ['hello', 'world', 123, { b: 2, c: undefined, a: 1 }] })

// 서로 다른 쿼리
useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2 }] })
useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2, c: 3 }] })
useQuery({ queryKey: ['hello', 'world'] })
useQuery({ queryKey: [123, 'world', { a: 1, b: 2, c: 3 }, 'hello'] })
  • 아래 예제에서 wait의 값이 다르면, 각각 별개의 요청을 전송한다.
import { useQuery } from '@tanstack/react-query'

type ResponseValue = {
  message: string
  time: string
}

export default function DelayedData({ wait = 1000 }: { wait: number }) {
  const { data } = useQuery<ResponseValue>({
    queryKey: ['delay', wait],
    queryFn: async () => (await fetch(`https://api.heropy.dev/v0/delay?t=${wait}`)).json(),
    staleTime: 1000 * 10
  })
  return <div>{data?.time}</div>
}
import { QueryProvider } from './queryProvider'
import DelayedData from './components/DelayedData'

export default function App() {
  return (
    <QueryProvider>
      <DelayedData />
      <DelayedData wait={2000} />
      <DelayedData wait={3000} />
    </QueryProvider>
  )
}

❗기본적으로 쿼리 함수(queryFn)에서 사용하는 변수는 쿼리 키에 포함되어야 한다.
그러면 변수가 변경될 때마다 자동으로 다시 가져올 수 있다.

  • 만약 변수와는 상관없이 항상 하나의 쿼리로 처리하고 싶다면 ESLint의 exhaustive-deps 규칙을 비활성화한다.

💻 queryFn

  • 쿼리 함수는 데이터를 가져오는 비동기 함수로, 꼭 데이터를 반환하거나 오류를 던져야 한다.
  • 던져진 오류는 반환되는 error 객체로 확인할 수 있고 기본적으로는 null이다.
import { useQuery } from '@tanstack/react-query'

type ResponseValue = {
  message: string
  time: string
}

export default function DelayedData() {
  const { data, error } = useQuery<ResponseValue>({
    queryKey: ['delay'],
    queryFn: async () => {
      const res = await fetch('https://api.heropy.dev/v0/delay?t=1000')
      const data = await res.json()
      if (!data.time) {
        throw new Error('문제가 발생했습니다!')
      }
      return data
    },
    staleTime: 1000 * 10,
    retry: 1
  })
  return (
    <>
      {data && <div>{JSON.stringify(data)}</div>}
      {error && <div>{error.message}</div>}
    </>
  )
}

💻 select

  • 선택 함수(select)를 사용하면 가져온 데이터를 변형할 수 있다.
  • queryFn이 반환하는 데이터를 인수로 받아서 select에서 처리하고 반환하면 최종 데이터가 된다.
  • 최종 데이터의 타입은 useQuery의 3번째 제네릭 타입으로 선언할 수 있다.

💻 사용자 정보 API 예제

import { useQuery } from '@tanstack/react-query'

type Users = User[]
interface User {
  id: string
  name: string
  age: number
}

export default function UserNames() {
  const { data } = useQuery<Users, Error, string[]>({
    queryKey: ['users'],
    queryFn: async () => {
      const res = await fetch('https://api.heropy.dev/v0/users')
      const { users } = await res.json()
      return users
    },
    staleTime: 1000 * 10,
    select: data => data.map(user => user.name)
  })
  return (
    <>
      <h2>User Names</h2>
      <ul>{data?.map((name, i) => <li key={i}>{name}</li>)}</ul>
    </>
  )
}

💻 placedholderData

  • 새로운 데이터를 가져오는 과정에서 쿼리가 무효화되어 일시적으로 데이터가 없는 상태(undefined)가 되면 화면이 깜빡일 수 있다.
  • 이 때 placedholderData 옵션을 사용하면, 쿼리 함수가 호출되는 pending에서 임시로 표시할 데이터를 미리 지정할 수 있다.
// ...

export default function Movies() {
  // ...
  
  const { data: movies } = useQuery<Movie[]>({
    queryKey: ['movies', searchText], // 검색어
    queryFn: async () => {
      const res = await fetch(`https://omdbapi.com?apikey=7035c60c&s=${searchText}`)
      const { Search: movies } = await res.json()
      return movies
    },
    placeholderData: prev => prev
  })
  
  // ...
}
  • placeholderData 옵션에는 함수를 지정할 수 있으며, 이 함수는 새로운 데이터를 가져오기 직전의 이전(Previous) 데이터를 받을 수 있어서 이를 반환해 임시 데이터로 사용할 수 있습니다.

💻 structuralSharing

  • 해당 옵션은 새로운 데이터를 가져올 때 이전 데이터와 비교해 변경되지 않은 부분은 이전 데이터를 재사용하도록 지정할 수 있다.
  • 이를 통해 메모리 사용량을 최적화하고 불필요한 리렌더링을 방지할 수 있다.
// 이전 데이터
const prevUser = {
  id: 'abc123',
  name: 'Neo',
  age: 22,
  contact: {
    email: 'neo@gmail.com',
    address: {
      country: 'Korea',
      city: 'Seoul'
    }
  }
}

// 새로운 데이터
const newUser = {
  id: 'abc123',
  name: 'Neo',
  age: 22,
  contact: {
    email: 'neo@gmail.com',
    address: {
      country: 'Korea',
      city: 'Suwon' // 변경된 부분!
    }
  }
}
  • structuralSharing 옵션이 true이면 변경된 부분만 새롭게 업데이트하고 변경되지 않은 부분은 이전 데이터의 참조를 재사용하고 옵션이 false이면, 모든 객체가 새로운 참조로 생성한다.
  • ❗매우 큰 중첩 객체를 다룰 경우 구조적인 비교 자체가 성능에 부담일 수 있으므로 옵션을 false로 지정하는 것이 더 유리할 수 있다.

✍ 반환

  • 반환 속성도 너무나도 많기 때문에 몇 가지만 정리하겠다. 나머지는 블로그 참고!

💻 상태 확인

  • isFetchingqueryFn이 아직 실행 중인지의 여부로, 데이터를 가져오는 중을 나타낸다.
  • isPending은 캐시된 데이터가 없고 쿼리가 아직 완료되지 않은 상태의 여부로, initialData 혹은 placeholderData 옵션으로 데이터를 제공하면 출력 대기(Pending)가 필요하지 않으므로 false를 반환한다.
    enabled 옵션을 false로 지정하면 쿼리가 대기 상태로 시작하므로 true를 반환한다.
  • isLoading은 isFetching && isPending와 같은 의미로, 쿼리의 첫 번째 가져오기가 진행 중인 경우를 나타낸다.
// ...

export default function DelayedData() {
  const { data, isFetching, isPending, isLoading } = useQuery<ResponseValue>({
    queryKey: ['delay'],
    queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
    staleTime: 1000 * 10
  })
  return (
    <>
      <div>isFetching: {JSON.stringify(isFetching)}</div>
      <div>isPending: {JSON.stringify(isPending)}</div>
      <div>isLoading: {JSON.stringify(isLoading)}</div>
      <div>{data?.time}</div>
    </>
  )
}

💻 다시 가져오기

  • refetch 함수를 사용하면 데이터를 항상 새롭게 다시 가져올 수 있다.
// ...

export default function DelayedData() {
  const { data, isStale, refetch } = useQuery<ResponseValue>({
    queryKey: ['delay'],
    queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
    staleTime: 1000 * 10
  })
  return (
    <>
      <div>{data?.time}</div>
      <div>데이터가 상했나요?: {JSON.stringify(isStale)}</div>
      <button onClick={() => refetch()}>데이터 가져오기!</button>
    </>
  )
}
  • 만약 staleTime을 기반으로 데이터를 가져오려면 queryClient.fetchData()메서드를 사용할 수 있다.
import { useQuery, useQueryClient, queryOptions } from '@tanstack/react-query'
// ...

const options = queryOptions<ResponseValue>({
  queryKey: ['delay'],
  queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
  staleTime: 1000 * 10
})

export default function DelayedData() {
  const queryClient = useQueryClient()
  const { data, isStale } = useQuery(options)
  
  function fetchData() {
    const data = await queryClient.fetchQuery(options)
    console.log(data) // 캐시된 데이터 or 새로 가져온 데이터
  }
  return (
    <>
      <div>{data?.time}</div>
      <div>데이터가 상했나요?: {JSON.stringify(isStale)}</div>
      <button onClick={fetchData}>데이터 가져오기!</button>
    </>
  )
}
  • ❗단, queryKeystaleTime을 기존 쿼리와 동일하게 제공해야 한다.(queryFn은 생략 가능)

  • 만약 캐시된 데이터가 필요하다면 queryClient.getQueryData()를 사용한다.

  • 데이터가 상해도 새로 가져오지 않고, 캐시된 데이터만 반환한다. (없다면 undefined)


😄 마치며

간단히 TanStack Query(React Query)에 대해 정리를 해봤는데 생각보다 내용이 방대한 라이브러리인 것 같다.
유용한 기능들을 많이 가지고 있어서 앞으로 프로젝트를 할 때 실제로 사용해보고 싶은 생각이 들었다.

위에서 설명한 것들 외에도 여러 옵션들과 기능들이 추가로 있으니 필요할 때 찾아보자.
참고 링크

profile
지금이라도 시작해보자..!

1개의 댓글

comment-user-thumbnail
2025년 10월 27일

잘해내실 수 있습니다!

답글 달기