언제부턴가 다들 자연스럽게 “비동기 상태 관리? TanStack Query 쓰면 되지" 라고 말하고 있지 않나요?
그렇다면, TanStack Query는 언제부터, 그리고 왜 이렇게 많이 쓰이게 된 걸까요?
이 글에서는 그 이유를 알아보고, 직접 useQuery를 구현하면서 TanStack Query의 핵심 원리도 파헤쳐 보려고 합니다.
📌 바로 useQuery 구현부를 보고 싶다면 여기로 이동!
과거에는 useState와 useEffect를 조합하여 데이터를 불러오는 방식이 일반적이었습니다.
1
2const [data, setData] = useState(null);
3const [loading, setLoading] = useState(true);
4const [error, setError] = useState(null);
5
6useEffect(() => {
7 setLoading(true);
8 fetch('/api/data')
9 .then((res) => res.json())
10 .then((result) => setData(result))
11 .catch((err) => setError(err))
12 .finally(() => setLoading(false));
13}, []);이 방식이 완벽했다면 TanStack Query는 등장하지 않았겠죠?
useEffect를 직접 관리해야 함이 문제를 해결하기 위해 Redux, MobX, Recoil, Zustand 같은 전역 상태 관리 라이브러리가 활용되었지만,
비동기 데이터 관리에 최적화되지 않았고 오히려 코드 복잡도가 증가하는 문제가 있었습니다.
Redux를 쓰는 FE: 비동기 요청 하나만 처리하는데도 액션, 리듀서, 미들웨어까지 작성해야 해!
MobX를 쓰는 FE: @observable, @action을 사용해서 비동기 데이터를 관리해야 해!
Recoil을 쓰는 FE: 비동기 데이터를 selector에서 async로 감싸야 해!
Zustand를 쓰는 FE: 비동기 데이터를 set을 사용해서 직접 업데이트해야 해!
이처럼 기존 전역 상태 관리 라이브러리를 사용하면, 프로젝트가 커질수록 API 요청이 많아지면서 코드의 복잡도가 상당히 증가했습니다.
2020년, Tanner Linsley가 React Query를 발표했고, "서버 상태를 관리하는 새로운 방식"을 제안한 React Query는 곧 많은 주목을 받았습니다.
그리고 시간이 지나면서 더 확장되었고, React뿐만 아니라 Vue, Solid 등 다양한 프레임워크에서도 사용할 수 있도록 TanStack Query로 발전했습니다.
✔ 자동으로 캐싱 & 동기화 → 동일한 요청이 발생하면 기존 데이터를 재사용
✔ 자동으로 데이터 갱신 → 서버 데이터가 변경되면 알아서 최신 상태 유지
✔ 중복 요청 방지 → 같은 API를 여러 곳에서 호출해도 한 번만 요청
✔ 훅 기반 API 제공 → useQuery, useMutation 같은 훅을 사용하여 간단하게 데이터 관리
즉, 전역 상태 관리 라이브러리 없이도 비동기 데이터를 효율적으로 다룰 수 있도록 해줍니다.
TanStack Query의 핵심 기능을 직접 구현하며 내부 원리를 이해해 보겠습니다.
완벽한 대체재를 만드는 것이 아니라, 핵심 개념을 이해하는 데 초점을 맞춰 구현할 예정입니다.
STEP 1 - Data fetching (+ 중복 요청 방지)
STEP 2 - Retry 옵션 추가
STEP 3 - Caching (데이터 캐싱 및 만료 시간 설정)
TanStack Query의 useQuery는 기본적으로 로딩 상태, 에러 상태, 데이터 상태를 관리합니다.
이 방식을 모방하여 isPending, error, data 상태를 통해 비동기 상태를 관리하는 훅을 만들어 봅시다.
1import { useEffect, useState } from 'react';
2
3export function useQuery<TData = unknown, TError = unknown>({
4 queryFn,
5}: {
6 queryFn: () => Promise<TData>;
7}) {
8 const [isPending, setIsPending] = useState(true);
9 const [error, setError] = useState<TError | null>(null);
10 const [data, setData] = useState<TData | null>(null);
11
12 useEffect(() => {
13 const fetchData = async () => {
14 try {
15 const result = await queryFn();
16 setData(result);
17 setIsPending(false);
18 } catch (err) {
19 setError(err as TError);
20 setIsPending(false);
21 }
22 };
23
24 fetchData();
25 }, [queryFn]);
26
27 return { isPending, error, data };
28}
29isPending, error, data 상태를 통해 비동기 상태 관리useEffect를 사용하여 비동기 요청을 수행하지만, 여기서 Race Condition 문제가 발생할 가능성이 있습니다!
Race Condition이 무엇이고, 어떻게 해결할 수 있는지 살펴보겠습니다.
비동기 작업이 동시에 실행될 때, 의도한 순서와 다르게 실행되면서 예측 불가능한 문제가 발생하는 상황을 말합니다.
A요청과 B요청을 했는데 B요청에 대한 응답이 서버에서 더 먼저 처리되서 A보다 먼저 도착하면 어떻게 될까요?
userId = 1일 때 fetchUserData(1) 요청 보냄userId = 2로 변경fetchUserData(2) 요청도 보냄fetchUserData(2)의 응답이 fetchUserData(1)보다 먼저 도착fetchUserData(1) 응답이 다시 setData()를 실행최신 요청의 응답만 반영해야 하는데, 오래된 요청이 나중에 도착하면서 덮어씌우는 문제 발생
즉, 최신 데이터만 반영해야 하지만, 네트워크 속도 차이로 인해 의도치 않은 데이터가 화면에 표시될 수 있음
isMounted Flag 사용 (Closure 활용)React docs에서 설명하는 Closure 활용 전략
1import { useEffect, useState } from 'react';
2
3export function useQuery<TData = unknown, TError = unknown>({
4 queryFn,
5}: {
6 queryFn: () => Promise<TData>;
7}) {
8 const [isPending, setIsPending] = useState(true);
9 const [error, setError] = useState<TError | null>(null);
10 const [data, setData] = useState<TData | null>(null);
11
12 useEffect(() => {
13 let isMounted = true; // 최신 요청만 반영하는 플래그
14
15 const fetchData = async () => {
16 try {
17 const result = await queryFn();
18 if (isMounted) {
19 setData(result);
20 setIsPending(false);
21 }
22 } catch (err) {
23 if (isMounted) {
24 setError(err as TError);
25 setIsPending(false);
26 }
27 }
28 };
29
30 fetchData();
31
32 return () => {
33 isMounted = false; // 컴포넌트가 언마운트되거나 새로운 요청이 발생하면 무효화
34 };
35 }, [queryFn]);
36
37 return { isPending, error, data };
38}하지만 이 방법만으로는 Race Condition을 완벽하게 방지하기에는 한계가 있습니다.
AbortController 사용AbortController이란? 웹 API에서 비동기 요청을 취소할 수 있도록 도와주는 내장 객체입니다. 특히 fetch 요청을 취소할 때 사용합니다.
보통 가장 마지막 요청만 유지되고, 이전 요청들은 취소해야 할 경우에 사용됩니다. (e.g. 검색창 자동완성 기능 최적화)
예를들면 아래와 같이 검색창에 입력할 때마다 API 요청이 발생하고, 이전 요청을 취소하는 형태가 되겠죠.
AbortController 관련해서 궁금했던 점 (궁금하면 살펴보세요)▾그러면 AbortController만을 이용해서 Race condition을 완벽하게 방지할 수 있는가? ⇒ X
이 방식은 기존 요청을 취소할 수 있지만, 늦게 도착한 응답을 자동으로 무시하지는 않기 때문입니다.
fetch() 요청이 abort()되면 브라우저가 요청을 중단하지만, 이미 서버에서 응답이 전송된 경우, 클라이언트가 이를 받을 수 있고 늦게 도착한 응답을 처리할 가능성이 존재하기 때문입니다.
두 가지 방법을 혼합하면 어떨까요?
isMounted)을 사용하면 오래된 응답을 무시 가능1import { useEffect, useState } from 'react';
2
3export function useQuery<TData = unknown, TError = unknown>({
4 queryFn,
5}: {
6 queryFn: (signal?: AbortSignal) => Promise<TData>;
7}) {
8 const [isPending, setIsPending] = useState(true);
9 const [error, setError] = useState<TError | null>(null);
10 const [data, setData] = useState<TData | null>(null);
11
12 useEffect(() => {
13 const abortController = new AbortController();
14 const signal = abortController.signal;
15 let isMounted = true;
16
17 const fetchData = async () => {
18 try {
19 const result = await queryFn(signal);
20 if (isMounted && !signal.aborted) {
21 setData(result);
22 setIsPending(false);
23 }
24 } catch (err) {
25 if (isMounted && !signal.aborted) {
26 setError(err as TError);
27 setIsPending(false);
28 }
29 }
30 };
31
32 fetchData();
33
34 return () => {
35 isMounted = false;
36 abortController.abort(); // 이전 요청을 취소하는 부분
37 };
38 }, [queryFn]);
39
40 return { isPending, error, data };
41}
42직접 useQuery를 구현하면서 Race Condition을 방지하는 여러 방법을 적용했지만, TanStack Query는 이를 훨씬 더 효율적으로 처리하는 내부 로직을 가지고 있을 것 같습니다. 어떻게 구현되어있는지 살펴볼까요?
TanStack Query는 중앙 집중식 Query 관리 시스템을 통해 여러 개의 useQuery 인스턴스가 동일한 데이터를 요청하더라도 중복 요청을 방지하고, 최신 응답만 반영하도록 설계되어 있습니다.
Race Condition을 방지하는 핵심 요소
QueryCache를 활용한 중앙 집중식 관리 (중복 요청 방지)fetchStatus를 이용한 요청 상태 관리 (최신 요청만 반영)Promise 재사용AbortController를 활용한 불필요한 요청 취소 (fetch API 한정)QueryCache를 활용한 중앙 집중식 관리 (중복 요청 방지)1class QueryCache {
2 constructor() {
3 this.queriesMap = new Map();
4 }
5...
6 get(queryHash) {
7 return this.queriesMap.get(queryHash);
8 }
9...
10 build(client, options) {
11 const queryHash = hashQueryKeyByOptions(options.queryKey, options);
12
13 let query = this.get(queryHash);
14 if (!query) {
15 query = new Query(client, options);
16 this.queriesMap.set(queryHash, query);
17 }
18
19 return query;
20 }
21}✔️ queryHash를 활용해 동일한 queryKey를 가진 Query가 존재하는지 확인하여 기존 요청이 진행 중이면 새로운 요청을 보내지 않으므로 불필요한 요청을 방지
✔️ 같은 queryKey를 가진 여러 useQuery가 존재해도, 하나의 Query만 생성하고 이를 여러 Observer가 구독
✔️ queryKey가 변경되면 새로운 queryHash가 생성되는데 응답 도착 시 현재의 queryHash와 다르면 해당 응답을 무시하고 최신 데이터만 반영
✔️중앙 집중식 관리로 중복 요청 방지 & 하나의 Query를 여러 컴포넌트에서 구독 가능
fetchStatus를 이용한 요청 상태 관리 (최신 요청만 반영)1// Set to fetching state if not already in it
2if (
3 this.state.fetchStatus === 'idle' ||
4 this.state.fetchMeta !== context.fetchOptions?.meta
5) {
6 this.#dispatch({ type: 'fetch', meta: context.fetchOptions?.meta })
7}✔️오래된 요청이 나중에 응답을 반환하더라도, fetchStatus 가 fetching인지 체크하여 무시
✔️ 즉, 이전 요청이 끝나기 전에 새로운 요청 발생 시 이전 응답은 반영되지 않음
✔️예를 들어 A → B 순서로 요청했는데 B가 먼저 응답하면, A의 응답은 적용되지 않도록 함
Promise 재사용1fetch(
2 options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
3 fetchOptions?: FetchOptions<TQueryFnData>,
4 ): Promise<TData> {
5 if (this.state.fetchStatus !== 'idle') {
6 if (this.state.data !== undefined && fetchOptions?.cancelRefetch) {
7 this.cancel({ silent: true })
8 } else if (this.#retryer) {
9 this.#retryer.continueRetry()
10 // 현재 요청이 이미 진행 중이라면, 새로운 요청을 보내지 않고 기존 `Promise`를 반환한다.
11 return this.#retryer.promise
12 }
13 }
14...
15}
16✔️ 같은 queryKey의 요청이 이미 진행 중이면, 기존 Promise를 반환하여 중복 요청을 방지
AbortController를 이용한 기존 요청 취소 (fetch API 한정)1const abortController = new AbortController();
2const signal = abortController.signal;
3
4this.promise = this.queryFn({ signal }).then((data) => {
5 this.setData(data);
6 return data;
7});
8
9// 컴포넌트 언마운트 시 이전 요청 취소
10return () => abortController.abort();✔️이전 요청이 끝나기 전에 새로운 요청이 발생하면, 기존 요청을 abort()하여 취소
✔️불필요한 네트워크 요청 방지 (fetch API에서만 가능)
지금까지 TanStack Query가 Race Condition을 방지하는 방법을 살펴봤습니다.
하지만 비동기 상태 관리는 여기서 끝이 아닙니다. 네트워크 요청이 실패했을 때 자동으로 재시도하는 "Retry", 불필요한 네트워크 요청을 줄이기 위한 "Cache & CacheTime" 개념도 매우 중요합니다.
이제 이러한 기능들을 useQuery에 이어서 구현해 보겠습니다.
1import { useEffect, useState } from 'react';
2
3export function useQuery<TData = unknown, TError = unknown>({
4 queryFn,
5 retry = 3, // retry 옵션 추가 (기본값 3회)
6}: {
7 queryFn: () => Promise<TData>;
8 retry?: boolean | number;
9}) {
10 const [isPending, setIsPending] = useState(true);
11 const [error, setError] = useState<TError | null>(null);
12 const [data, setData] = useState<TData | null>(null);
13
14 useEffect(() => {
15 let failureCount = 0;
16 let isMounted = true;
17
18 const executeQuery = async () => {
19 try {
20 const result = await queryFn();
21 if (isMounted) {
22 setData(result);
23 setIsPending(false);
24 }
25 } catch (err) {
26 const shouldRetry = retry === true ||
27 (typeof retry === 'number' && failureCount < retry);
28
29 if (shouldRetry) {
30 failureCount++;
31 executeQuery(); // 재귀 호출하여 재시도
32 } else {
33 if (isMounted) {
34 setError(err as TError);
35 setIsPending(false);
36 }
37 }
38 }
39 };
40
41 executeQuery();
42
43 return () => {
44 isMounted = false;
45 };
46 }, [queryFn, retry]);
47
48 return { isPending, error, data };
49}
50retry 옵션을 기반으로 재시도retry가 true이면 무한 재시도, 숫자이면 해당 횟수만큼 재시도failureCount로 관리하여, retry 제한 초과 시 setError 처리1import { useEffect, useState } from 'react';
2
3const cache = new Map(); // 캐시 저장소
4
5export function useQuery<TData = unknown, TError = unknown>({
6 queryFn,
7 retry = 3,
8 cacheKey,
9 cacheTime = 5000, // 캐시 유지 시간 (5초)
10}: {
11 queryFn: () => Promise<TData>;
12 retry?: boolean | number;
13 cacheKey: string;
14 cacheTime?: number;
15}) {
16 const [isPending, setIsPending] = useState(true);
17 const [error, setError] = useState<TError | null>(null);
18 const [data, setData] = useState<TData | null>(null);
19
20 useEffect(() => {
21 let failureCount = 0;
22 let isMounted = true;
23
24 // 캐시 확인 (cacheKey가 존재하고 유효할 경우)
25 if (cache.has(cacheKey)) {
26 const cachedData = cache.get(cacheKey);
27 if (Date.now() - cachedData.timestamp < cacheTime) {
28 setData(cachedData.data);
29 setIsPending(false);
30 return;
31 } else {
32 cache.delete(cacheKey); // 캐시 만료 시 삭제
33 }
34 }
35
36 const executeQuery = async () => {
37 try {
38 const result = await queryFn();
39 if (isMounted) {
40 setData(result);
41 setIsPending(false);
42 cache.set(cacheKey, { data: result, timestamp: Date.now() }); // 캐시에 데이터 저장
43 }
44 } catch (err) {
45 const shouldRetry = retry === true ||
46 (typeof retry === 'number' && failureCount < retry);
47
48 if (shouldRetry) {
49 failureCount++;
50 executeQuery();
51 } else {
52 if (isMounted) {
53 setError(err as TError);
54 setIsPending(false);
55 }
56 }
57 }
58 };
59
60 executeQuery();
61
62 return () => {
63 isMounted = false;
64 };
65 }, [queryFn, retry, cacheKey, cacheTime]);
66
67 return { isPending, error, data };
68}
69cache 객체에 저장하여, 동일한 요청이 들어오면 캐시된 데이터를 반환cacheTime을 설정하여 일정 시간이 지나면 캐시 삭제오늘 살펴봤듯이, TanStack Query가 등장하기 전에는 useEffect와 useState 만으로 API 요청을 직접 관리해야 했습니다.
하지만 데이터 갱신, 중복 요청, Race Condition 등 여러 문제들을 해결하는 것은 쉽지 않았고,
전역 상태 관리 라이브러리를 사용해도 비동기 데이터를 다루는 것은 여전히 복잡한 문제였습니다.
그런데 TanStack Query는 이 모든 문제를 어떻게 해결했을까요?
우리가 직접 useQuery를 구현하면서 확인한 것처럼, TanStack Query는 단순한 Fetching 라이브러리가 아니라, 비동기 상태 관리에서 발생할 수 있는 여러 문제들을 체계적으로 해결하는 강력한 도구였다는 점을 다시 한 번 느낍니다. 오늘 살펴본 Race condition에 대한 방지를 처리하기 위한 구현부만 봐도 그렇죠.
Promise를 재사용하며,fetchStatus와 queryHash를 비교하여 최신 응답만 반영하고,Retry, CacheTime을 적용하는 방식이제 저는 TanStack Query를 "편리하다” 라는 평가에 더해 내부 원리를 이해한 상태로 더욱 효과적으로 활용할 수 있을 것 같습니다.
이후 글들에서 useQuery에 staleTime등의 더 많은 옵션을 추가해 고도화 해보면서 내부 구현을 연이어 살펴보도록 하겠습니다.