기본 콘텐츠로 건너뛰기

TanStack Query를 쓰는 이유: 서버 상태를 캐시하고 갱신하는 실전 기준

TanStack Query를 쓰는 이유: 서버 상태를 캐시하고 갱신하는 실전 기준

빠른 답

  • TanStack Query는 서버 응답을 전역 상태처럼 보관하는 도구가 아니라, 서버 데이터의 조회, 캐싱, 갱신 흐름을 관리하는 도구입니다.
  • 같은 데이터를 여러 컴포넌트가 읽을 때 중복 요청을 줄이고, 로딩과 에러, 재요청 상태를 일관된 방식으로 다룰 수 있습니다.
  • staleTime, gcTime, queryKey 설계가 맞지 않으면 최신성 문제나 불필요한 네트워크 요청이 생길 수 있습니다.
  • 입력값, 모달 열림 여부, 작성 중인 폼 값처럼 브라우저가 원본인 상태는 useState, Zustand, Redux 같은 클라이언트 상태와 분리해서 다루는 편이 좋습니다.

시간 흐름으로 이해하기

컴포넌트 마운트
useQuery 를 호출한 컴포넌트가 특정 쿼리를 구독합니다.
캐시 조회
TanStack Query가 queryKey 로 기존 캐시를 찾습니다.
데이터 반환
캐시가 있으면 화면은 먼저 기존 데이터를 사용할 수 있습니다.
신선도 판단
staleTime 이 지났거나 무효화된 데이터인지 확인합니다.
백그라운드 갱신
stale 상태라면 조건에 따라 다시 요청하고 캐시를 갱신합니다.

흐름으로 보기

흐름 다이어그램
TanStack Query를 쓰는 이유: 서버 상태를 캐시하고 갱신하는 실전 기준 흐름 다이어그램

이 흐름에서 컴포넌트는 서버 데이터를 직접 소유하지 않습니다. 서버 데이터의 원본은 서버에 있고, TanStack Query는 브라우저 안의 캐시와 갱신 정책을 관리합니다. 컴포넌트는 캐시를 구독하고, 필요한 시점에 다시 가져오거나 무효화하도록 요청합니다.

이 차이를 이해하면 TanStack Query를 “또 하나의 전역 상태 관리 도구”로 쓰는 실수를 줄일 수 있습니다. 전역 상태에 서버 응답을 복사해 두는 방식보다, 서버 상태는 쿼리 캐시에 맡기고 화면 상태는 클라이언트 상태로 분리하는 편이 데이터 흐름을 추적하기 쉽습니다.

서버 상태는 일반 상태와 다르다

React에서 상태라고 부르는 값이 모두 같은 성격을 갖지는 않습니다. 검색창에 입력 중인 값, 선택된 탭, 모달 열림 여부는 브라우저 안에서 사용자가 만든 클라이언트 상태입니다. 반면 사용자 정보, 게시글 목록, 주문 내역, 알림 개수는 서버가 원본을 가지고 있는 서버 상태입니다.

서버 상태에는 네트워크 요청, 실패 가능성, 최신성, 중복 조회, 재시도, 캐시 무효화 같은 문제가 따라옵니다. 같은 사용자 정보를 헤더, 설정 화면, 사이드바에서 동시에 필요로 할 수도 있고, 한 화면에서 수정한 결과가 다른 목록 화면에도 반영되어야 할 수도 있습니다.

처음에는 다음처럼 useEffect와 전역 상태로 직접 관리할 수 있습니다.

import { useEffect, useState } from 'react'
import { useUserStore } from './userStore'

export function useUserProfile(userId) {
  const { user, setUser } = useUserStore()
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(null)

  useEffect(() => {
    let ignore = false

    setLoading(true)
    setError(null)

    fetch(`/api/users/${userId}`)
      .then((res) => {
        if (!res.ok) {
          throw new Error('사용자 조회 실패')
        }

        return res.json()
      })
      .then((data) => {
        if (!ignore) {
          setUser(data)
        }
      })
      .catch((err) => {
        if (!ignore) {
          setError(err)
        }
      })
      .finally(() => {
        if (!ignore) {
          setLoading(false)
        }
      })

    return () => {
      ignore = true
    }
  }, [userId, setUser])

  return { user, loading, error }
}

이 코드는 작은 화면에서는 충분히 동작합니다. 다만 요청 취소, race condition, 캐싱, 재요청, 윈도우 포커스 복귀 시 갱신, 여러 컴포넌트의 중복 요청 제거까지 직접 챙기기 시작하면 로직이 빠르게 흩어집니다. React 공식 문서도 Effect 안에서 직접 데이터를 가져오는 방식이 서버 렌더링, 네트워크 waterfall, 캐시 부재 측면에서 비용을 만들 수 있다고 설명합니다.

TanStack Query는 이 반복되는 서버 상태 흐름을 컴포넌트 바깥의 쿼리 캐시로 옮깁니다. 컴포넌트는 “요청을 직접 소유”하기보다 “쿼리 결과를 구독”하는 쪽에 가까워집니다.

현재 기준 용어와 버전 차이

2026년 4월 기준 TanStack Query의 React 공식 문서는 v5를 기준으로 안내됩니다. 오래된 글에는 “React Query”라는 이름과 react-query 패키지명이 남아 있을 수 있습니다. 현재 새 코드에서는 @tanstack/react-query를 기준으로 보는 편이 맞습니다.

설치는 현재 패키지 이름을 사용합니다.

npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools

버전 차이에서 자주 헷갈리는 부분은 다음과 같습니다.

  • 패키지 이름: v4부터 react-query가 아니라 @tanstack/react-query를 사용합니다.
  • 호출 방식: v5에서는 useQuery({queryKey, queryFn, ...options})처럼 객체 하나를 넘기는 형식이 기준입니다.
  • 캐시 정리 옵션: v4의 cacheTime은 v5에서 gcTime으로 이름이 바뀌었습니다.
  • 에러 경계 옵션: 예전 useErrorBoundary는 v5에서 throwOnError 이름을 사용합니다.
  • 상태 이름: v5에서는 초기 대기 상태를 설명할 때 isPendingstatus: 'pending'을 기준으로 이해해야 합니다.

cacheTime에서 gcTime으로 바뀐 이유는 이름이 주는 오해 때문입니다. gcTime은 데이터가 fresh로 유지되는 시간이 아니라, 더 이상 구독 중인 컴포넌트가 없는 inactive 쿼리를 메모리에 얼마나 남겨둘지 정하는 시간입니다. 데이터의 신선도는 staleTime이 담당합니다.

QueryClient에서 기본 정책 잡기

QueryClient는 앱 전체의 기본 쿼리 정책을 정하는 출발점입니다. 모든 API에 같은 시간을 적용하기보다, 대부분의 데이터에 적용할 기본값을 두고 개별 쿼리에서 덮어쓰는 방식이 관리하기 쉽습니다.

import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,
      gcTime: 10 * 60 * 1000,
      retry: 1,
      refetchOnWindowFocus: true,
    },
  },
})

export function AppProviders({ children }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools />
    </QueryClientProvider>
  )
}

staleTime은 데이터가 fresh로 간주되는 시간입니다. 이 시간이 지나면 데이터는 stale 상태가 되고, 새 쿼리 인스턴스가 마운트되거나 창 포커스가 돌아오거나 네트워크가 재연결되는 시점에 백그라운드 refetch가 일어날 수 있습니다.

gcTime은 캐시 정리 시간입니다. 해당 쿼리를 구독하는 컴포넌트가 없어 inactive 상태가 된 뒤 얼마 동안 메모리에 남겨둘지를 정합니다. 공식 문서의 기본값은 inactive 쿼리를 5분 뒤 garbage collection 하는 것입니다.

주문 상태, 재고, 채팅 알림처럼 최신성이 중요한 데이터는 staleTime을 짧게 두거나 명시적인 무효화 흐름을 설계하는 편이 낫습니다. 반대로 자주 바뀌지 않는 설정값이나 참조 데이터는 staleTime을 길게 잡아 불필요한 요청을 줄일 수 있습니다.

queryKey로 데이터 소유권 나누기

queryKey는 캐시의 주소에 가깝습니다. 쿼리 함수가 page, status, userId 같은 값에 의존한다면 그 값도 키에 포함되어야 합니다. 그래야 필터가 바뀌었을 때 이전 데이터와 새 데이터를 TanStack Query가 구분할 수 있습니다.

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

async function fetchProjects({ queryKey }) {
  const [, params] = queryKey
  const search = new URLSearchParams({
    page: String(params.page),
    status: params.status,
  })

  const res = await fetch(`/api/projects?${search.toString()}`)

  if (!res.ok) {
    throw new Error('프로젝트 목록 조회 실패')
  }

  return res.json()
}

export function useProjects({ page, status }) {
  return useQuery({
    queryKey: ['projects', { page, status }],
    queryFn: fetchProjects,
    staleTime: 30 * 1000,
    select: (data) => ({
      items: data.items,
      totalCount: data.totalCount,
    }),
  })
}

page가 바뀌면 쿼리 키가 달라지고, TanStack Query는 다른 캐시 항목으로 인식합니다. 반대로 status를 키에서 빼면 “완료 프로젝트 목록을 요청했는데 진행 중 프로젝트 목록 캐시가 보이는” 식의 문제가 생길 수 있습니다.

select는 서버 응답 전체에서 화면이 필요한 형태만 읽게 해줍니다. 원본 응답을 전역 상태에 다시 복사하거나, 파생 데이터를 별도 useState로 저장하는 대신 쿼리 결과를 필요한 모양으로 선택할 수 있습니다. 이 방식은 리렌더링 범위를 이해하는 데도 도움이 됩니다.

mutation 후 캐시 갱신하기

조회는 useQuery, 생성과 수정과 삭제는 보통 useMutation으로 표현합니다. mutation이 성공하면 서버의 원본이 바뀐 것이므로, 관련 쿼리를 무효화해 다음 조회에서 새 데이터를 가져오게 할 수 있습니다.

import { useMutation, useQueryClient } from '@tanstack/react-query'

async function updateProjectStatus({ projectId, status }) {
  const res = await fetch(`/api/projects/${projectId}/status`, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ status }),
  })

  if (!res.ok) {
    throw new Error('프로젝트 상태 변경 실패')
  }

  return res.json()
}

export function useUpdateProjectStatus() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: updateProjectStatus,
    onSuccess: async (_data, variables) => {
      await Promise.all([
        queryClient.invalidateQueries({ queryKey: ['projects'] }),
        queryClient.invalidateQueries({
          queryKey: ['project', variables.projectId],
        }),
      ])
    },
  })
}

무효화는 단순히 “캐시를 삭제한다”와 같지 않습니다. 해당 쿼리를 stale로 표시하고, 활성 상태라면 refetch로 이어질 수 있습니다. 목록 화면이 열려 있다면 변경된 서버 상태를 다시 가져오고, 닫혀 있다면 다음에 열릴 때 갱신될 수 있습니다.

응답이 작고 변경 범위가 분명한 경우에는 setQueryData로 캐시를 직접 갱신할 수도 있습니다. 다만 여러 페이지, 필터, 목록에 영향을 주는 수정이라면 invalidate가 더 단순한 구조를 만들 때가 많습니다.

리렌더링 관점에서 보는 장점

서버 응답을 큰 전역 상태 객체에 넣어두면 관련 없는 필드 변화에도 여러 컴포넌트가 영향을 받을 수 있습니다. 또한 같은 서버 데이터를 컴포넌트 상태, 전역 상태, 파생 상태로 복사하기 시작하면 어떤 값이 최신인지 판단하기 어려워집니다.

TanStack Query는 queryKey 단위로 서버 상태를 나누고, 컴포넌트가 필요한 쿼리 결과를 구독하게 합니다. 같은 키를 사용하는 여러 컴포넌트는 요청과 결과 상태를 공유할 수 있고, select를 사용하면 화면에 필요한 형태만 읽을 수 있습니다.

물론 TanStack Query를 쓴다고 리렌더링 문제가 자동으로 모두 사라지는 것은 아닙니다. 큰 응답을 여러 화면에서 그대로 넘기거나, select에서 매번 불필요한 새 객체를 많이 만들거나, 쿼리 키가 자주 바뀌는 구조라면 여전히 비용이 생길 수 있습니다. 먼저 데이터 소유권과 키 설계를 분명히 하고, 이후 실제 병목을 React DevTools나 TanStack Query Devtools로 확인하는 순서가 다루기 쉽습니다.

흔한 안티패턴

첫 번째 안티패턴은 useQuery로 받은 서버 데이터를 다시 Zustand나 Redux에 저장하는 방식입니다. 이렇게 되면 쿼리 캐시와 전역 상태가 같은 서버 응답을 각각 들고 있게 됩니다. refetch로 쿼리 캐시가 갱신되어도 전역 상태가 그대로 남아 있으면 화면마다 다른 값을 볼 수 있습니다.

두 번째는 서버 응답에서 만든 파생 값을 다시 useState에 넣는 방식입니다. 예를 들어 프로젝트 목록에서 완료된 항목만 보여주기 위해 completedProjects를 별도 상태로 저장하면 원본 목록이 갱신될 때 파생 상태도 함께 갱신해야 합니다. 이런 값은 렌더링 시 계산하거나 select로 읽는 편이 흐름을 따라가기 쉽습니다.

세 번째는 queryKey에 의존 값을 빠뜨리는 것입니다. 쿼리 함수가 userId, page, status, sort를 사용한다면 그 값은 키에도 들어가야 합니다. 그렇지 않으면 서로 다른 요청이 같은 캐시를 공유할 수 있습니다.

네 번째는 enabled: false와 수동 refetch를 남용하는 방식입니다. 특정 조건이 충족된 뒤에만 요청해야 하는 상황에서는 유용하지만, 대부분의 데이터 흐름을 명령형 호출로 바꾸면 TanStack Query의 선언적인 캐싱 모델을 제대로 활용하기 어렵습니다.

마지막으로 클라이언트 상태까지 TanStack Query에 넣으려는 경우도 있습니다. 검색어 입력 중인 값, 모달 열림 여부, 작성 중인 폼 값은 서버가 원본이 아닙니다. 이런 값은 로컬 상태나 클라이언트 상태 관리 도구로 두고, 확정된 검색 조건이나 저장 요청처럼 서버와 연결되는 지점에서 쿼리 키나 mutation으로 넘기는 편이 구조가 분명합니다.

언제 쓰고 언제 분리할까

TanStack Query가 잘 맞는 값은 서버에서 가져오고, 시간이 지나면 낡아질 수 있으며, 여러 컴포넌트가 함께 읽는 데이터입니다. 사용자 프로필, 게시글 목록, 상품 상세, 검색 결과, 알림 목록 같은 값이 여기에 속합니다.

반대로 사용자가 입력 중인 검색어, 열려 있는 드롭다운, 작성 중인 폼의 임시 값, 현재 선택된 탭은 브라우저 안에서 생긴 상태입니다. 이 값들은 TanStack Query 캐시에 넣기보다 useState, useReducer, Zustand, Redux 같은 클라이언트 상태로 다루는 편이 이해하기 쉽습니다.

구분 기준은 “이 값의 원본이 어디에 있는가”입니다. 서버가 원본이면 TanStack Query의 캐시와 갱신 흐름에 맡기고, 브라우저의 상호작용이 원본이면 클라이언트 상태로 둡니다. 이 경계가 분명하면 어떤 쿼리를 무효화해야 하는지, 어떤 컴포넌트가 다시 렌더링되는지 추적하기 쉬워집니다.

더 읽을 공식 문서

원문 참고

https://www.maeil-mail.kr/question/86

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...