[Frontend] React-Query

이권민·2025년 11월 23일

목차

  1. React Query
  2. 주요 메서드
  3. queryKey 설계 팁
  4. SWR과의 비교

React Query

💡 fetching, caching, 서버 데이터 동기화를 지원하는 라이브러리. 비동기 쿼리의 전 과정 관리

사용 이유

  • 서버 상태(Server State) 중심의 상태 관리
  • 전역 상태 관리 라이브러리로 관리하기 어려운 서버 데이터 처리에 특화
  • 캐싱을 통한 성능 최적화
  • 네트워크 재연결, 포커스 변화 등 자동 refetch
  • 무한 스크롤, 페이지네이션 등의 복잡한 패턴을 쉽게 구현

캐싱

  • 반복적인 비동기 데이터 호출 방지
    • fresh ↔ stale 상태와 캐시 수명 구분
    • 적절하게 서버데이터 갱신 필요 (fresh한 데이터로)
  • 서버에 대한 부하 감소
  • 서버 상태 관리 (예를들면 로딩중, 에러, 성공 등의 상태)를 간편하게 처리

Refetch 트리거 옵션

  • refetchOnWindowFocus: 브라우저 포커스 시 refetch (default: true)
  • refetchOnMount: 컴포넌트가 새로 mount되면 refetch (default: true)
  • refetchOnReconnect: 네트워크 재연결 시 refetch (default: true)

데이터 수명 관련 옵션

  • staleTime
    • 데이터가 fresh → stale 로 바뀌는 시간
    • stale 상태일 때만 refetch 실행
  • cacheTime
    • 데이터가 inactive(컴포넌트 언마운트 등) 이후 캐시에 남아있는 시간
    • 기본값: 5분
    • 이후 GC가 실행되어 메모리에서 제거됨

Client 데이터와 Server 데이터간의 분리

  • 프로젝트 규모가 커지게 되면 전역 상태 관리 라이브러리로 관리하기 힘들어짐
  • 해당 라이브러리들은 Client쪽에 로직 집중.
  • React Query를 함께 사용하며 Client와 Server 데이터 분리

ContextApi 기반

  • 앱 전체를 QueryClientProvider로 감싸고 내부에서 QueryClient가 key 기반으로 캐싱
  • Context Store와 비슷한 역할
  • getQueryData, setQueryData, fetchQuery 등 데이터 관리 메서드 존재

주요 메서드

QueryClient

  • ContextApi 와 비슷한 역할
  • 전역 scope로 감싸줘야됨
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

  • queryKey에는 unique key를 포함한 배열 들어감
    • 다중 쿼리 키를 사용시 순서 중요
  • key가 변경되면 새로운 쿼리로 취급됨
  • 그 다음부터는 함수 내부 파라미터로 값 전달
  • 실제 호출하고자하는 비동기 함수 들어감. Promise 반환하는 함수
  • 응답은 Api 성공, 실패여부, 반환값 포함한 객체


  • 캐시된 데이터 없으면 서버에 요청. 캐시되어있으면 해당 데이터 사용.
    • fresh인 경우만. stale일 경우 서버에 요청해 가져옴
    • staleTime으로 시간 지정, isStale로 여부 확인
    • prop, params 값이 다르면 따로 요청
상태의미
isPending아직 첫 데이터가 없음 (초기 로딩)
isFetching네트워크 요청이 실행 중 (refetch 포함)
  • enabled 옵션으로 조건부 쿼리 생성(특정 조건에서 true로)
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from '@tanstack/react-query'

const queryClient = new QueryClient()

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

function Example() {
  const { isPending, error, data } = useQuery({
    queryKey: ['repoData'],
    queryFn: () =>
      fetch('https://api.github.com/repos/tannerlinsley/react-query').then(
        (res) => res.json(),
      ),
  })

  if (isPending) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{' '}
      <strong>✨ {data.stargazers_count}</strong>{' '}
      <strong>🍴 {data.forks_count}</strong>
    </div>
  )
}
  • select 사용법
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>
    </>
  )
}
  • placeholderData: prev => prev 로 임시로 표기할 데이터 지정 가능

  • structuralSharing: 변경되지않은 데이터 재사용

  • meta: 쿼리에 대한 추가정보 제공

  • isFetching(쿼리함수), isPending(서버요청), isLoading(쿼리 첫번째 가져오기)

  • refetch 시 데이터 새롭게 갱신

    • staleTime 기반으로 데이터 가져오기는 queryClient.fetchQuery() 사용.
    • 캐시된 데이터는 getQueryData() 메서드 사용. 없으면 undefined
    • ensureQueryData()는 없으면 자동으로 fetchQuery로 데이터 가져옴

useQueries

  • 여러 개의 useQuery 한번에 실행 시 Promise.all()처럼 묶어서 실행
const ids = [1, 2, 3]
const results = useQueries({
  queries: ids.map((id) => ({
    queryKey: ['post', id],
    queryFn: () => fetchPost(id),
    staleTime: Infinity,
  })),
})

// 두 query에 대한 반환값이 배열로 묶여 반환된다!!

// 만일 반환된 배열에 대해 통합된 값을 불러오고 싶다면, 아래와 같이 combine 설정을 통해 데이터를 한 번에 반환할 수 있다. 이외에도 배열을 다루는 메서드들을 이용해 반환값에 대한 전처리를 수행할 수 있다!

const ids = [1, 2, 3]
const combinedQueries = useQueries({
  queries: ids.map((id) => ({
    queryKey: ['post', id],
    queryFn: () => fetchPost(id),
  })),
  combine: (results) => {
    return {
      data: results.map((result) => result.data),
      pending: results.some((result) => result.isPending),
    }
  },
})

useMutation

  • PUT, UPDATE, DELETE와 같이 값 변경 시 사용하는 API
  • 반환값은 useQuery와 동일. 처음 사용 시 post 비동기 함수 넣고, 두번째 인자로 상황 별 분기설정 들어감
  • 실제 사용 시에는 mutation.mutate 메서드를 사용하고, 첫 번째 인자로 API 호출 시에 전달해주어야하는 데이터를 넣어주면 됨
  • 요청 실패 시 자동 재시도, 낙관적 업데이트(일단 UI 업데이트)등의 기능도 지원
function App() {
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return axios.post('/todos', newTodo)
    },
    onSuccess: () => queryClient.invalidateQueries(['todos'])
  })

  return (
    <div>
      {mutation.isLoading ? (
        'Adding todo...'
      ) : (
        <>
          {mutation.isError ? (
            <div>An error occurred: {mutation.error.message}</div>
          ) : null}

          {mutation.isSuccess ? <div>Todo added!</div> : null}

          <button
            onClick={() => {
              mutation.mutate({ id: new Date(), title: 'Do Laundry' })
            }}
          >
            Create Todo
          </button>
        </>
      )}
    </div>
  )
}

useInfiniteQuery

  • 무한 스크롤 or 더보기로 추가데이터 요청 시 사용
  • useInfiniteQuery 의 data는 pages 배열과 pageParams로 구성. data.pages[0], data.pages[1]처럼 각 페이지 데이터에 접근해서 flat하게 렌더링하면 된다.
  • useQuery 모든 옵션 사용 +
    • getNextPageParam: 다음 페이지를 가져올 때 사용할 파라미터를 반환
    • getPreviousPageParam: 이전 페이지용 파라미터(필요하다면)
    • initialPageParam: 첫 요청에 사용할 초기 파라미터
    • maxPages
  • 반환 속성
    • fetchNextPage
    • fetchPreviousPage
    • hasNextPage
    • hasPreviousPage
    • isFetchingNextPage
    • isFetchingPreviousPage
const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage
} = useInfiniteQuery({
  queryKey: ['movies'],
  queryFn: fetchMovies,
  getNextPageParam: lastPage => lastPage.nextCursor
})
import { Fragment, useState, useEffect, useRef } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'

// ...

export default function MovieList() {
  const [searchText, setSearchText] = useState('')
  const [queryText, setQueryText] = useState('')
  const observerEl = useRef<HTMLDivElement | null>(null)

  const {
    data,
    // isLoading,
    isFetching,
    // isFetched,
    hasNextPage,
    fetchNextPage
  } = useInfiniteQuery<Page>({
    // ...
  })

  useEffect(() => {
    const currentObserverEl = observerEl.current
    const io = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasNextPage) {
        fetchNextPage()
      }
    })
    if (currentObserverEl) {
      io.observe(currentObserverEl)
    }
    return () => {
      if (currentObserverEl) {
        io.disconnect()
      }
    }
  }, [hasNextPage, fetchNextPage])

  // ...

  return (
    <>
      {/* ... */}
      {/* {isLoading ? <div>로딩 중..</div> : null}
      {isFetched && hasNextPage && (
        <button
          disabled={isFetching}
          onClick={() => fetchNextPage()}>
          {isFetching ? '로딩 중..' : '더 보기!'}
        </button>
      )} */}
      {isFetching ? <div>로딩 중..</div> : null}
      <div
        ref={observerEl}
        style={{
          display: isFetching ? 'none' : 'block',
          height: '20px'
        }}
      />
    </>
  )
}
  • react-interection-observer 사용 시 더 간결
import { useState, useEffec, useCallback } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useInView } from 'react-intersection-observer'

// ...

export default function MovieList() {
  const [searchText, setSearchText] = useState('')
  const [queryText, setQueryText] = useState('')
  const { ref, inView } = useInView()

  // ...

  // useEffect(() => {
  //   const currentObserverEl = observerEl.current
  //   const io = new IntersectionObserver(entries => {
  //     if (entries[0].isIntersecting && hasNextPage) {
  //       fetchNextPage()
  //     }
  //   })
  //   if (currentObserverEl) {
  //     io.observe(currentObserverEl)
  //   }
  //   return () => {
  //     if (currentObserverEl) {
  //       io.disconnect()
  //     }
  //   }
  // }, [hasNextPage, fetchNextPage])

  useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage()
    }
  }, [inView, hasNextPage])

  // ...

  return (
    <>
      {/* ... */}
      {isFetching ? <div>로딩 중..</div> : null}
      <div
        ref={ref}
        style={{
          display: isFetching ? 'none' : 'block',
          height: '20px'
        }}
      />
    </>
  )
}

queryKey 설계 팁

React Query의 성능과 캐싱 전략은 queryKey 설계에 크게 영향 받음.

배열 형태 권장

['todos']
['todos', userId]
['product', productId, 'reviews']

리소스 이름 + 변수 조합

REST 방식과 유사한 패턴이 가장 직관적.

['user', userId]
['orders', userId, page]

객체 대신 배열 요소로 명확히 분리

// 비추천 ❌
['todos', { page, filter }]

// 추천 ⭕
['todos', page, filter]

의존성 정확히 반영하기

쿼리의 조건이 되는 값은 반드시 key에 포함해야 한다.

useQuery({
  queryKey: ['search', keyword],
  enabled: !!keyword
})

서버 자원(Resource) 단위로 key 구성

  • 하나의 자원 = 하나의 key
  • 서버 응답 형태가 어떻게 생겼는지는 중요하지 않음
  • "무엇을 식별하는가"에 집중하는 게 핵심

SWR과의 비교

Provider 사용 여부

  • React Query: 필수 (QueryClientProvider)
  • SWR: 기본 사용 시 Provider 불필요, 필요하면 SWRConfig

Fetcher 방식

  • React Query: 쿼리마다 queryFn 지정
  • SWR: 전역 fetcher 설정 후 useSWR(key) 사용

Devtools 지원

React Query Devtools로 캐싱, 상태 확인이 매우 쉬움

무한 스크롤 기능

React Query는 useInfiniteQuery를 공식 제공
(SWR은 커스텀 구현 필요)

Selectors

select 옵션으로 raw data → 가공 데이터로 매핑 가능

데이터 최적화

여러 컴포넌트에서 동일한 쿼리를 사용할 경우
React Query는 refetch를 batch로 처리해 렌더링 성능 향상

GC 기반 메모리 관리

cacheTime 기반으로 자동 GC 실행


🔗 참고
[React-Query] React-Query 개념잡기

TanStack Query(React Query) 핵심 정리

profile
이것저것이것 개발자

0개의 댓글