TanStack Query, 왜 필요할까? 내부 원리를 파헤치고 직접 만들어보기
언제부턴가 다들 자연스럽게 “비동기 상태 관리? TanStack Query 쓰면 되지" 라고 말하고 있지 않나요?
그렇다면, TanStack Query는 언제부터, 그리고 왜 이렇게 많이 쓰이게 된 걸까요?
이 글에서는 그 이유를 알아보고, 직접
useQuery
를 구현하면서 TanStack Query의 핵심 원리도 파헤쳐 보려고 합니다.📌 바로
useQuery
구현부를 보고 싶다면 여기로 이동!⏳ TanStack Query가 없었던 시절에는 어떻게 비동기 데이터를 관리했나요?
예전에는 데이터를 불러오고 관리하는 방법이 단순했습니다. 대부분
useState
와 useEffect
를 조합해서 API 요청을 보냈습니다.const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { setLoading(true); fetch('/api/data') .then((res) => res.json()) .then((result) => setData(result)) .catch((err) => setError(err)) .finally(() => setLoading(false)); }, []);
이 방식이 문제가 없었다면, TanStack Query는 등장하지 않았겠죠?
⚠️ 기존 방식의 문제점
- 중복 요청 문제 → 여러 컴포넌트에서 같은 데이터를 요청하면, 같은 API가 중복 호출됨
- 수동으로 데이터 갱신해야 함 → 데이터가 변경되었을 때
useEffect
를 직접 관리해야 함
- 캐싱 기능 없음 → 새로고침하면 기존 데이터를 다시 요청해야 함
개발자들은 이런 문제를 해결하기 위해 전역 상태 관리 라이브러리(Redux, MobX 등)를 사용하려 했습니다.
하지만 전역 상태 관리 라이브러리는 비동기 데이터 관리에 최적화되어 있지 않았고, 오히려 코드가 복잡해지는 문제가 있었습니다.
😱 전역 상태 관리 라이브러리를 활용했을 때의 문제점
Redux를 쓰는 FE: 비동기 요청 하나만 처리하는데도 액션, 리듀서, 미들웨어까지 작성해야 해!
MobX를 쓰는 FE:
@observable
, @action
을 사용해서 비동기 데이터를 관리해야 해!Recoil을 쓰는 FE: 비동기 데이터를
selector
에서 async
로 감싸야 해!Zustand를 쓰는 FE: 비동기 데이터를
set
을 사용해서 직접 업데이트해야 해!이처럼 기존 전역 상태 관리 라이브러리를 사용하면, 프로젝트가 커질수록 API 요청이 많아지면서 코드의 복잡도가 상당히 증가했습니다.
⇒ 이 문제를 해결하기 위해 등장한 것이 바로 TanStack Query입니다.
🚀 TanStack Query의 등장 (2020년 초반)
2020년, Tanner Linsley가 React Query를 발표했고, "서버 상태를 관리하는 새로운 방식"을 제안한 React Query는 곧 많은 주목을 받았습니다.
그리고 시간이 지나면서 더 확장되었고, React뿐만 아니라 Vue, Solid 등 다양한 프레임워크에서도 사용할 수 있도록 TanStack Query로 발전했습니다.
📌 TanStack Query의 발전 과정 (타임라인)
- 2020년 초반 → React Query v1 출시 (React에서만 사용 가능)
- 2021년 후반 → React Query v3에서 더 안정적인 API 제공
- 2022년 초반 → React Query가 TanStack Query로 리브랜딩, Vue, Solid, Svelte 등 다양한 프레임워크 지원 시작
- 2023년 이후 → 지속적인 업데이트로 다양한 프레임워크에서 최적화됨
❓ TanStack Query를 통해 어떤 점들이 개선되었나요?
✔ 자동으로 캐싱 & 동기화 → 동일한 요청이 발생하면 기존 데이터를 재사용
✔ 자동으로 데이터 갱신 → 서버 데이터가 변경되면 알아서 최신 상태 유지
✔ 중복 요청 방지 → 같은 API를 여러 곳에서 호출해도 한 번만 요청
✔ 훅 기반 API 제공 →
useQuery
, useMutation
같은 훅을 사용하여 간단하게 데이터 관리⇒ 즉, 개발자들은 더 이상 비효율적인 전역 상태 관리 코드 없이도, 비동기 데이터를 쉽게 다룰 수 있게 되었습니다.
⛳ useQuery를 직접 구현해보겠습니다!
TanStack Query가 제공하는 핵심 기능 중 일부를 직접 구현해보면서 비동기 상태 관리 라이브러리가 어떤 원리로 동작하는지 이해해보려고 합니다.
완벽한 대체재를 만드는 것이 아니라, 핵심 개념을 이해하는 데 초점을 맞춰 구현할 예정입니다.
🚧 만들어야 할 기능
🧷 STEP 1 - Data fetching (+ 중복 요청 방지)
🧷 STEP 2 - Retry 옵션 추가
🧷 STEP 3 - Caching (데이터 캐싱 및 만료 시간 설정)
📌 이제
useQuery
를 직접 구현해봅시다!🧷 STEP 1 - useQuery로 기본적인 데이터 Fetching 구현
TanStack Query의
useQuery
는 기본적으로 로딩 상태, 에러 상태, 데이터 상태를 관리합니다.이 방식을 모방하여
isPending
, error
, data
상태를 통해 비동기 상태를 관리하는 훅을 만들어 봅시다.import { useEffect, useState } from 'react'; export function useQuery<TData = unknown, TError = unknown>({ queryFn, }: { queryFn: () => Promise<TData>; }) { const [isPending, setIsPending] = useState(true); const [error, setError] = useState<TError | null>(null); const [data, setData] = useState<TData | null>(null); useEffect(() => { const fetchData = async () => { try { const result = await queryFn(); setData(result); setIsPending(false); } catch (err) { setError(err as TError); setIsPending(false); } }; fetchData(); }, [queryFn]); return { isPending, error, data }; }
isPending
,error
,data
상태를 통해 비동기 상태 관리
useEffect
를 사용하여 비동기 요청을 수행
⚠️ 하지만, 여기서 Race Condition 문제가 발생할 가능성이 있습니다!
👉 Race Condition이 무엇이고, 어떻게 해결할 수 있는지 살펴보겠습니다.
🚨 Race Condition(경쟁 상태)이란?
비동기 작업이 동시에 실행될 때, 의도한 순서와 다르게 실행되면서 예측 불가능한 문제가 발생하는 상황을 말합니다.
sequenceDiagram participant User as 사용자 participant UI as 프론트엔드 (React) participant API as 백엔드 (API 서버) User->>UI: userId = 1 선택 UI->>API: fetchUserData(1) 요청 전송 Note right of API: 요청 1 시작 (userId = 1) User->>UI: userId = 2 선택 (빠르게 변경) UI->>API: fetchUserData(2) 요청 전송 Note right of API: 요청 2 시작 (userId = 2) API-->>UI: fetchUserData(2) 응답 도착 (최신 요청) UI->>UI: 상태 업데이트 (✅ userId = 2 데이터 표시) API-->>UI: fetchUserData(1) 응답 도착 (이전 요청) UI->>UI: ❌ 상태 업데이트 (오래된 userId = 1 데이터가 덮어씌워짐!) User->>UI: 화면 확인 Note left of User: ❌ userId = 2를 선택했는데 userId = 1 데이터가 표시됨!
A요청과 B요청을 했는데 B요청에 대한 응답이 서버에서 더 먼저 처리되서 A보다 먼저 도착하면 어떻게 될까요?
userId = 1
일 때fetchUserData(1)
요청 보냄
- 사용자가 UI에서
userId = 2
로 변경
fetchUserData(2)
요청도 보냄
- 그런데
fetchUserData(2)
의 응답이fetchUserData(1)
보다 먼저 도착
- 이후 늦게 도착한
fetchUserData(1)
응답이 다시setData()
를 실행
- 화면에는 userId = 2가 아닌 userId = 1의 데이터가 표시됨 😱
👉 최신 요청의 응답만 반영해야 하는데, 오래된 요청이 나중에 도착하면서 덮어씌우는 문제 발생
🛑 Race Condition이 발생하는 이유
- 이전 요청이 끝나기 전에 새로운 요청이 발생 → 두 개의 요청이 동시에 처리됨
- 서버 응답 순서는 보장되지 않음 → 나중에 보낸 요청이 먼저 응답할 수도 있음
- 오래된 요청이 나중에 도착하면, 최신 데이터가 덮어씌워질 위험
📌 즉, 최신 데이터만 반영해야 하지만, 네트워크 속도 차이로 인해 의도치 않은 데이터가 화면에 표시될 수 있음
📌 Race Condition을 방지해서 비동기 데이터가 안전하게 업데이트 되도록 하려면?
1. isMounted
Flag 사용 (Closure 활용)
import { useEffect, useState } from 'react'; export function useQuery<TData = unknown, TError = unknown>({ queryFn, }: { queryFn: () => Promise<TData>; }) { const [isPending, setIsPending] = useState(true); const [error, setError] = useState<TError | null>(null); const [data, setData] = useState<TData | null>(null); useEffect(() => { let isMounted = true; // 최신 요청만 반영하는 플래그 const fetchData = async () => { try { const result = await queryFn(); if (isMounted) { setData(result); setIsPending(false); } } catch (err) { if (isMounted) { setError(err as TError); setIsPending(false); } } }; fetchData(); return () => { isMounted = false; // 컴포넌트가 언마운트되거나 새로운 요청이 발생하면 무효화 }; }, [queryFn]); return { isPending, error, data }; }
하지만 이 방법만으로는 Race Condition을 완벽하게 방지하기에는 한계가 있습니다.
- 응답이 도착했을 때 상태 업데이트 여부를 결정하는 방식이어서 중복 요청 자체를 방지하지 못합니다.
- 오래된 응답은 무시하지만 이미 실행된 요청을 중단하지 않습니다.
- 네트워크 리소스 낭비 문제를 해결할 수 없습니다.
2. AbortController
사용
AbortController이란? 웹 API에서 비동기 요청을 취소할 수 있도록 도와주는 내장 객체입니다. 특히 fetch 요청을 취소할 때 사용합니다.
보통 가장 마지막 요청만 유지되고, 이전 요청들은 취소해야 할 경우에 사용됩니다.
(e.g. 검색창 자동완성 기능 최적화)
예를들면 아래와 같이 검색창에 입력할 때마다 API 요청이 발생하고, 이전 요청을 취소하는 형태가 되겠죠.

AbortController
관련해서 궁금했던 점 (궁금하면 살펴보세요)
- 요청을 취소하는 비용은 비싸지 않나요?
⇒ 요청을 취소한다고 추가적인 계산이 필요하지 않고, 단순히 signal 옵션을 설정하여 fetch 옵션을 중단하는 것 뿐이어서 괜찮습니다.
- 요청이 이미 네트워크로 전송된 경우가 발생하지는 않나요? 그런 경우에는 취소가 안되지 않나요?
- 맞습니다. abort()를 호출해도 요청이 이미 네트워크를 타고 가고 있다면 완전히 차단되지 않습니다.
- 서버에서도 요청 취소를 감지하는 로직을 구현할 수 있다고 합니다.
- e.g.
- Nest.js
req.on('aborted')
- Fastify
request.raw.on(’close’, callback)
- GraphQL API의 경우 Apollo Server에서
context.signal.aborted
를 활용
- 요청 취소를 활용할 수 있는 경우가 언제일까요?
- 사용자가 빠르게 입력하는 검색 기능
- 자동완성 기능
- Infinite Scroll 최적화
- Virtual Scroll 구현과 함께 + α
- 서버 리소스를 아끼고 싶을 때? - 쿼리가 무거운 경우
- 대량의 데이터 요청 시
그러면 AbortController만을 이용해서 Race condition을 완벽하게 방지할 수 있는가? ⇒ X
이 방식은 기존 요청을 취소할 수 있지만, 늦게 도착한 응답을 자동으로 무시하지는 않기 때문입니다.
fetch()
요청이 abort()
되면 브라우저가 요청을 중단하지만, 이미 서버에서 응답이 전송된 경우, 클라이언트가 이를 받을 수 있고 늦게 도착한 응답을 처리할 가능성이 존재하기 때문입니다.두 가지 방법을 혼합하면 어떨까요?
3. Closure 패턴 + AbortController 조합
- Closure 패턴(
isMounted
)을 사용하면 오래된 응답을 무시 가능
- AbortController를 사용하면 불필요한 네트워크 요청을 취소 가능
⇒ 즉, 중복 요청 방지 + 최신 응답 유지 + 네트워크 낭비 방지까지 해결할 수 있게됩니다.
구현 코드가 궁금하다면 열어보세요
import { useEffect, useState } from 'react'; export function useQuery<TData = unknown, TError = unknown>({ queryFn, }: { queryFn: (signal?: AbortSignal) => Promise<TData>; }) { const [isPending, setIsPending] = useState(true); const [error, setError] = useState<TError | null>(null); const [data, setData] = useState<TData | null>(null); useEffect(() => { const abortController = new AbortController(); const signal = abortController.signal; let isMounted = true; const fetchData = async () => { try { const result = await queryFn(signal); if (isMounted && !signal.aborted) { setData(result); setIsPending(false); } } catch (err) { if (isMounted && !signal.aborted) { setError(err as TError); setIsPending(false); } } }; fetchData(); return () => { isMounted = false; abortController.abort(); // 이전 요청을 취소하는 부분 }; }, [queryFn]); return { isPending, error, data }; }
직접
useQuery
를 구현하면서 Race Condition을 방지하는 여러 방법을 적용했지만, TanStack Query는 이를 훨씬 더 효율적으로 처리하는 내부 로직을 가지고 있을 것 같습니다. 어떻게 구현되어있는지 살펴볼까요?🤔 TanStack Query는 어떻게 Race Condition을 방지할까?
TanStack Query는 중앙 집중식 Query 관리 시스템을 통해 여러 개의
useQuery
인스턴스가 동일한 데이터를 요청하더라도 중복 요청을 방지하고, 최신 응답만 반영하도록 설계되어 있습니다.📌 Race Condition을 방지하는 핵심 요소
-
QueryCache
를 활용한 중앙 집중식 관리 (중복 요청 방지)
fetchStatus
를 이용한 요청 상태 관리 (최신 요청만 반영)
- 진행 중인 요청이 있으면 기존
Promise
재사용
AbortController
를 활용한 불필요한 요청 취소 (fetch API 한정)
QueryCache
를 활용한 중앙 집중식 관리 (중복 요청 방지)
class QueryCache { constructor() { this.queriesMap = new Map(); } ... get(queryHash) { return this.queriesMap.get(queryHash); } ... build(client, options) { const queryHash = hashQueryKeyByOptions(options.queryKey, options); let query = this.get(queryHash); if (!query) { query = new Query(client, options); this.queriesMap.set(queryHash, query); } return query; } }
✔️
queryHash
를 활용해 동일한 queryKey
를 가진 Query가 존재하는지 확인하여 기존 요청이 진행 중이면 새로운 요청을 보내지 않으므로 불필요한 요청을 방지✔️ 같은
queryKey
를 가진 여러 useQuery
가 존재해도, 하나의 Query만 생성하고 이를 여러 Observer가 구독✔️
queryKey
가 변경되면 새로운 queryHash
가 생성되는데 응답 도착 시 현재의 queryHash
와 다르면 해당 응답을 무시하고 최신 데이터만 반영✔️중앙 집중식 관리로 중복 요청 방지 & 하나의
Query
를 여러 컴포넌트에서 구독 가능fetchStatus
를 이용한 요청 상태 관리 (최신 요청만 반영)
// Set to fetching state if not already in it if ( this.state.fetchStatus === 'idle' || this.state.fetchMeta !== context.fetchOptions?.meta ) { this.#dispatch({ type: 'fetch', meta: context.fetchOptions?.meta }) }
✔️오래된 요청이 나중에 응답을 반환하더라도,
fetchStatus
가 fetching인지 체크하여 무시✔️ 즉, 이전 요청이 끝나기 전에 새로운 요청 발생 시 이전 응답은 반영되지 않음
✔️예를 들어 A → B 순서로 요청했는데 B가 먼저 응답하면, A의 응답은 적용되지 않도록 함
- 진행 중인 요청이 있으면 기존
Promise
재사용
fetch( options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>, fetchOptions?: FetchOptions<TQueryFnData>, ): Promise<TData> { if (this.state.fetchStatus !== 'idle') { if (this.state.data !== undefined && fetchOptions?.cancelRefetch) { this.cancel({ silent: true }) } else if (this.#retryer) { this.#retryer.continueRetry() // 현재 요청이 이미 진행 중이라면, 새로운 요청을 보내지 않고 기존 `Promise`를 반환한다. return this.#retryer.promise } } ... }
✔️ 같은
queryKey
의 요청이 이미 진행 중이면, 기존 Promise를 반환하여 중복 요청을 방지AbortController
를 이용한 기존 요청 취소 (fetch API
한정)
const abortController = new AbortController(); const signal = abortController.signal; this.promise = this.queryFn({ signal }).then((data) => { this.setData(data); return data; }); // 컴포넌트 언마운트 시 이전 요청 취소 return () => abortController.abort();
✔️이전 요청이 끝나기 전에 새로운 요청이 발생하면, 기존 요청을
abort()
하여 취소✔️불필요한 네트워크 요청 방지 (fetch API에서만 가능)
지금까지 TanStack Query가 Race Condition을 방지하는 방법을 살펴봤습니다.
하지만 비동기 상태 관리는 여기서 끝이 아닙니다. 네트워크 요청이 실패했을 때 자동으로 재시도하는 "Retry", 불필요한 네트워크 요청을 줄이기 위한 "Cache & CacheTime" 개념도 매우 중요합니다.
👉 이제 이러한 기능들을 useQuery에 이어서 구현해 보겠습니다.
🧷 STEP 2 - retry 옵션 구현하기
import { useEffect, useState } from 'react'; export function useQuery<TData = unknown, TError = unknown>({ queryFn, retry = 3, // retry 옵션 추가 (기본값 3회) }: { queryFn: () => Promise<TData>; retry?: boolean | number; }) { const [isPending, setIsPending] = useState(true); const [error, setError] = useState<TError | null>(null); const [data, setData] = useState<TData | null>(null); useEffect(() => { let failureCount = 0; let isMounted = true; const executeQuery = async () => { try { const result = await queryFn(); if (isMounted) { setData(result); setIsPending(false); } } catch (err) { const shouldRetry = retry === true || (typeof retry === 'number' && failureCount < retry); if (shouldRetry) { failureCount++; executeQuery(); // 재귀 호출하여 재시도 } else { if (isMounted) { setError(err as TError); setIsPending(false); } } } }; executeQuery(); return () => { isMounted = false; }; }, [queryFn, retry]); return { isPending, error, data }; }
- 요청 실패 시
retry
옵션을 기반으로 재시도
retry
가true
이면 무한 재시도, 숫자이면 해당 횟수만큼 재시도
- 재시도 횟수를
failureCount
로 관리하여,retry
제한 초과 시setError
처리
🧷 STEP 3 - cache와 cacheTime 구현하기
import { useEffect, useState } from 'react'; const cache = new Map(); // 캐시 저장소 export function useQuery<TData = unknown, TError = unknown>({ queryFn, retry = 3, cacheKey, cacheTime = 5000, // 캐시 유지 시간 (5초) }: { queryFn: () => Promise<TData>; retry?: boolean | number; cacheKey: string; cacheTime?: number; }) { const [isPending, setIsPending] = useState(true); const [error, setError] = useState<TError | null>(null); const [data, setData] = useState<TData | null>(null); useEffect(() => { let failureCount = 0; let isMounted = true; // 캐시 확인 (cacheKey가 존재하고 유효할 경우) if (cache.has(cacheKey)) { const cachedData = cache.get(cacheKey); if (Date.now() - cachedData.timestamp < cacheTime) { setData(cachedData.data); setIsPending(false); return; } else { cache.delete(cacheKey); // 캐시 만료 시 삭제 } } const executeQuery = async () => { try { const result = await queryFn(); if (isMounted) { setData(result); setIsPending(false); cache.set(cacheKey, { data: result, timestamp: Date.now() }); // 캐시에 데이터 저장 } } catch (err) { const shouldRetry = retry === true || (typeof retry === 'number' && failureCount < retry); if (shouldRetry) { failureCount++; executeQuery(); } else { if (isMounted) { setError(err as TError); setIsPending(false); } } } }; executeQuery(); return () => { isMounted = false; }; }, [queryFn, retry, cacheKey, cacheTime]); return { isPending, error, data }; }
- 이전 요청 데이터를
cache
객체에 저장하여, 동일한 요청이 들어오면 캐시된 데이터를 반환
cacheTime
을 설정하여 일정 시간이 지나면 캐시 삭제
결론
오늘 살펴봤듯이, TanStack Query가 등장하기 전에는
useEffect
와 useState
만으로 API 요청을 직접 관리해야 했습니다.하지만 데이터 갱신, 중복 요청, Race Condition 등 여러 문제들을 해결하는 것은 쉽지 않았고,
전역 상태 관리 라이브러리를 사용해도 비동기 데이터를 다루는 것은 여전히 복잡한 문제였습니다.
그런데 TanStack Query는 이 모든 문제를 어떻게 해결했을까요?
우리가 직접
useQuery
를 구현하면서 확인한 것처럼, TanStack Query는 단순한 Fetching 라이브러리가 아니라, 비동기 상태 관리에서 발생할 수 있는 여러 문제들을 체계적으로 해결하는 강력한 도구였다는 점을 다시 한 번 느낍니다. 오늘 살펴본 Race condition에 대한 방지를 처리하기 위한 구현부만 봐도 그렇죠.- QueryCache를 활용하여 중복 요청을 방지하고,
- 진행 중인 요청이 있으면 기존
Promise
를 재사용하며,
fetchStatus
와queryHash
를 비교하여 최신 응답만 반영하고,
- 네트워크 요청을 최적화하는
Retry
,CacheTime
을 적용하는 방식
이제 저는 TanStack Query를 "좋다, 편리하다” 라는 감상에 더해 내부 원리를 이해한 상태로 더욱 효과적으로 활용할 수 있을 것 같습니다.
이후 글들에서 useQuery에 staleTime등의 더 많은 옵션을 추가해 고도화 해보면서 내부 구현을 연이어 살펴보도록 하겠습니다.