
이번 디프만 프로젝트를 진행하면서 처음 사용해보는 Tanstack Query를 도입하기로 했습니다.
Tanstack Query를 처음 사용해보지만, 따로 공부 없이 팀원들의 코드를 참고하며 api 호출 로직을 완성했기에 프로젝트가 어느 정도 마무리 된 시점에서 Tanstack Query를 완벽하게 이해하기 위해 Tanstack Query에 대해 공부한 글을 정리하려고 합니다.
공식문서를 보면 알 수 있듯이 Tanstack Query를 사용하면 웹 애플리케이션에서 서버 상태를 가져오고(fetching), 캐싱하고(caching), 동기화하고(synchronizing), 업데이트(updating) 하는 작업을 쉽게 할 수 있게 도와줍니다.
이 뿐만 아니라 무한 스크롤, 페이지네이션에서의 성능 최적화와 네트워크 재연결, 요청 실패 등의 상황에서 자동 refetch 기능도 제공합니다.
4명의 FE 개발자가 2달 반이라는 짧은 개발 기간동안 MVP를 런칭해야 했기 때문에 기존의 useState, useEffect 를 작성하여 서버의 데이터를 받아오는 방식 보다는 TanStack Query를 사용하여 빠르고 깔끔하게 api 호출 로직을 작성할 수 있게 됩니다.
기존에 useQuery를 사용하지 않은 경우에는 data를 useState로 관리해주어야 하고, api로직을 호출하기 위해 useEffect를 사용해야 합니다.
// useState + useEffect 버전
import { useEffect, useState } from 'react';
// api 요청 코드
import { getRemainingCount } from './get-remaining-count';
export const useGetRemainingCount = () => {
const [data, setData] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const result = await getRemainingCount();
setData(result);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
return { data, isLoading, error };
};
위와 같이 간단한 api 코드임에도 불구하고 data, loading, error의 상태를 사용하기 위해 3개의 useState와 api 통신을 하기 위한 1개의 useEffect를 사용해야 하므로 작성해야 하는 코드도 많아지고 로직도 복잡해집니다.
// useQuery 버전
import { useQuery } from '@tanstack/react-query';
// api 요청 코드
import { getRemainingCount } from './get-remaining-count';
export const useGetRemainingCountQuery = () => {
return useQuery({
queryKey: ['getRemainingCount'],
queryFn: async () => {
const data = await getRemainingCount();
return data;
},
});
};
TanStack Query를 사용한다면 useState, useEffect 를 사용하지 않고 useQuery 안에서 데이터를 받아오고, 로딩 완료 여부를 자체적으로 해주면서 코드양도 줄어들고 가독성이 올라갑니다.
TanStack Query는 “queryKey” 를 기반으로 데이터를 자동 캐싱하고, 데이터 변경 시 “invalidateQueries” 한 줄로 간단하게 서버 상태와 동기화가 가능합니다.
// useState, useEffect를 활용한 데이터 refetch 코드
const [count, setCount] = useState<number | null>(null);
useEffect(() => {
getRemainingCount().then(setCount);
}, []);
<button onClick={() => {
getRemainingCount().then(setCount);
}}>갱신</button>
위와 같이 기존에 동일한 데이터를 여러 컴포넌트에서 쓰려면 공통 상태 관리 로직과 갱신 트리거를 직접 관리해줘야 합니다.
// useQuery + invalidateQueries
const queryClient = useQueryClient();
<button onClick={() => {
queryClient.invalidateQueries({ queryKey: ['getRemainingCount'] });
}}>갱신</button>
TanStack Query를 사용하면 캐시된 데이터를 공유하고, 변경이 필요한 시점에 invalidate만 하면 자동으로 최신 데이터를 가져와서 반영됩니다. 코드가 단순해지고, 불필요한 API 요청도 방지할 수 있습니다.
TanStack Query는 Devtools를 통해 모든 쿼리 상태를 시각적으로 확인하고, 쉽게 디버깅 할 수 있습니다.
<ReactQueryDevtools initialIsOpen={process.env.NODE_ENV === 'development'} />
App.tsx 안에 해당 코드만 넣어주면 화면 하단에 아래와 같은 아이콘이 나오게 됩니다.

해당 아이콘을 클릭하면

위와 같이 쿼리 캐시 상태, fetch 여부, 오류 내역 등을 GUI로 확인할 수 있어 디버깅이 훨신 쉬워집니다.
Tanstack Query의 가장 기본적인 훅이며 데이터가 변경되었는지 자동으로 감지하고 갱신해줍니다.
- 주로 컴포넌트에서 서버의 data를 가져올때 (GET 요청) 사용합니다.
- 파라미터로 1개의 객체를 받으며
queryKey,queryFn이 필수 값입니다.
기존에 useQuery를 사용하지 않은 경우에는 data를 useState로 관리해주어야 하고, api로직을 호출하기 위해 useEffect를 사용해야 합니다.
// useState + useEffect 버전
import { useEffect, useState } from 'react';
// api 요청 코드
import { getRemainingCount } from './get-remaining-count';
export const useGetRemainingCount = () => {
const [data, setData] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const result = await getRemainingCount();
setData(result);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
return { data, isLoading, error };
};
위와 같이 간단한 api 코드임에도 불구하고 data, loading, error의 상태를 사용하기 위해 3개의 useState와 api 통신을 하기 위한 1개의 useEffect를 사용해야 하므로 코드가 굉장히 더럽고 복잡해집니다.
// useQuery 버전
import { useQuery } from '@tanstack/react-query';
// api 요청 코드
import { getRemainingCount } from './get-remaining-count';
export const useGetRemainingCountQuery = () => {
return useQuery({
queryKey: ['getRemainingCount'],
queryFn: async () => {
const data = await getRemainingCount();
return data;
},
});
};
useQuery를 사용한다면 useState, useEffect 를 사용하지 않고 useQuery 안에서 데이터를 받아오고, 로딩 완료 여부를 자체적으로 해주면서 코드양도 줄어들고 가독성이 올라갑니다.
1. queryKey (필수적으로 들어가야하는 옵션!!!) (unknown[])
queryKey는 쿼리를 식별하는 고유한 값으로, 배열로 지정해줍니다.
useQuery는 queryKey를 기반으로 캐싱하므로 만약 query가 특정 변수에 의존한다면, 배열에 이어줘야 합니다.
export const useGetRemainingCountQuery = ({ userId }: { userId: number }) => {
return useQuery({
queryKey: ['user', userId], // 꼭 userId를 배열에 추가해주어야 한다!!(id마다 별개의 요청)
queryFn: async () => await axios.get(`/api/users/${userId}`);
},
});
};
2. queryFn (필수적으로 들어가야하는 옵션!!!) ((context: QueryFunctionContext) => Promise<TData>)
queryFn은 데이터를 가져오는 비동기 함수로 꼭 데이터를 반환하거나 error를 던져야 합니다. (api 요청 로직)
import { axiosInstance } from '@/common/services/service-config';
import type { Response } from '@common/types/response';
export const getRemainingCount = async () => {
const { data } =
await axiosInstance.get<Response<{ remainCount: number }>>('/api/v1/feedback/remain');
return data;
};
queryFn: async () => await getRemainingCount(); // 중요!!
3. enabled (boolean)
enabled 는 조건부 요청에 필수로 들어가야 하는 로직입니다. (ex. 비로그인 상태일때 api 요청 막음)
false인 경우 마운트시 쿼리가 실행되지 않습니다.
쿼리에 캐시된 데이터가 없다면 status === “pending”, fetchStatus === “idle”
쿼리에 캐시된 데이터가 있다면 status === “sucdess”
return useQuery<Response<UseGetPortfolioFeedbackResponse>>({
queryKey: [endPoint, feedbackId],
queryFn,
enabled: !!feedbackId && !!isLogin, // feedbackId가 있고 login 되어있을 때만 실행
})
4. select ((data: TData) ⇒ unknown)
반환된 데이터를 가공해거나 선택해야 할때 유용하게 사용됩니다.
// data가 name만을 선택한 배열로 변환됨
const { data } = useQuery({
queryKey: ["super-heroes"],
queryFn: getAllSuperHero,
select: (data) => {
const superHeroNames = data.data.map((hero) => hero.name);
return superHeroNames;
},
});
5. staleTime / gcTime (number | Infinity)
staleTime은 데이터의 상태가 fresh ⇒ stale로 바뀌는데 걸리는 시간을 지정해줍니다. (불필요한 refetch 방지)
- 만약 staleTime이 5000 이면 데이터가 fresh상태에서 5초 후 stale 상태로 바뀌게 됩니다.
- 기본값이 0 이기 때문에 따로 지정해주지 않으면 기본적으로 fetch된 후에 바로 stale로 바뀌게 됩니다.
gcTime은 데이터가 inactive 상태일 때 캐싱된 상태로 메모리에 남아있는 시간입니다.
- 쿼리 인스턴스가 unmount가 되면, 데이터는 inactive상태로 변경되며 캐시는 gcTime만큼 유지되었다가 gcTime이 지나면 가비지 콜렉터로 수집됩니다. (메모리 해제)
- 기본값은 5분이고, SSR환경에서는 Infinity입니다.
gcTime: 5 * 60 * 1000, // 5분
staleTime: 1 * 60 * 1000, // 1분
6. retry (boolean | number | (failureCount: number, error: TError) => boolean)
retry는 쿼리가 실패하면 useQuery를 특정 횟수만큼 재요청하는 기능입니다.
기본 값으로 클라이언트 환경에서는 3, 서버 환경에서는 0입니다.
false인 경우 재요청을 하지 않고, 숫자를 넣는 경우 해당 숫자만큼 재요청합니다.
retry: 5, // 오류를 표시하기 전에 요청을 5번 반복합니다.
7. refetchOnMount / refetchOnWindowFocus (boolean | "always" | ((query: Query) => boolean | "always"))
refetchOnMount는 데이터가 stale 상태일때 mount마다 refetch하는 옵션입니다.
refetchOnWindowFocus는 데이터가 stale일때 윈도우 포커싱되면 refetch하는 옵션입니다.
기본 값은 true 이고 always를 사용하면 mount / 윈도우 포커싱 할 때마다 매번 refetch합니다.
false로 설정하면 최초 fetch 이후에는 refetch하지 않습니다.
refetchOnMount: true
8. refetchInterval / refetchIntervalInBackground (number | false | ((data: TData | undefined, query: Query) => number | false))
두 옵션 모두 Polling 기법을 구현하기 위한 옵션입니다.
refetchInterval 은 시간을 값으로 넣어주면 일정 시간마다 자동으로 refetch 시켜줍니다.
refetchIntervalInBackground 은 boolean타입으로 가지며 refetchInterval과 함께 사용하고, 탭 / 창이 백그라운드에 있는 동안 refetch 시켜줍니다.
refetchInterval: 5000,
refetchIntervalInBackground: true,
9. placeholderData (TData | (previousValue: TData | undefined; previousQuery: Query | undefined,) => TData)
placeholderData는 query가 Pending 상태일때 보여줄 임시 데이터를 말합니다.
특징으로는 캐시에 유지되지 않으며 새로운 데이터를 가져오기 이전의 데이터를 받을 수 있습니다.
import { useQuery } from '@tanstack/react-query';
const fetchUser = async () => {
const res = await fetch('/api/user');
return res.json();
};
export const UserInfo = () => {
const { data, isPending } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
placeholderData: {
name: '로딩 중...',
age: 0,
},
});
return (
<div>
<p>이름: {data.name}</p>
<p>나이: {data.age}</p>
{isPending && <p>진짜 데이터를 불러오는 중...</p>}
</div>
);
};
10. meta (Record<string, unknown>)
meta 속성은 쿼리에 대한 추가 정보를 제공합니다.
이 외에도 networkMode, notifyOnChangeProps, retryDelay, structuralSharing, throwOnError 등의 옵션이 존재합니다!!
status 와 fetchStatus의 차이점
| status | fetchStatus | |
|---|---|---|
| 무엇을 나타내는지? | 쿼리의 결과 상태 | 네트워크 요청 상태 |
| 상태 종류 | “pending”, “success”, “error” | “fetching”, “idle”, “paused” |
| 용도 | 데이터가 준비되었는지를 확인하는 용도 | 현재 쿼리를 요청중인지를 확인하는 용도 |
| 캐시 사용 시 | 캐시된 데이터만 있으면 success | 캐시 유무와 상관 없이 현재 쿼리 진행중이면 fetching |
isPending / isLoading / isFetching의 차이점
쉽게 접근하자면 isPending은 "아직 데이터가 없습니다" 를 의미합니다. 그에 반해 isLoading은 "아직 데이터가 없고, 데이터를 가져오는 중입니다"를 의미합니다.
useQuery({ queryKey, queryFn, enabled: false });
// isPending: true, isLoading: false
위의 정리한 내용으로 TanStack Query 공부할 때 많은 도움이 된 react-query-tutorial 깃허브에 PR 올린 부분이 머지됐어요 ㅎ.ㅎ
https://github.com/ssi02014/react-query-tutorial/pull/40
원래 useMutation, useQueries, useSuspenseQuery, useQueryClient 와 같은 다양한 훅들을 같이 작성하려 했으나 useQuery 내용이 생각보다 길어져 해당 훅들은 다음 포스트에서 정리하겠습니다!!
https://github.com/ssi02014/react-query-tutorial?tab=readme-ov-file#usemutation