탄스탁 쿼리(기존 리액트 쿼리였으나, 리액트 전용 라이브러리가 아닌 범용 라이브러리로 확장되면서 이름이 변함)
해당 라이브러리는 서버의 상태를 간단하게 관리할 수 있도록 도와주는 라이브러리 입니다.

가장 핵심적인 기능은 "서버 상태와 클라이언트 상태의 동기화" 입니다.
이를 위해 특정 시간(staleTime)이 지나거나, 특정 조건(revalidateQueries)이 발동되면 refetch가 일어납니다.
(여기서 refetch는 서버로부터 데이터를 가져오는 것을 의미해요.)
그 외에 API 요청과 관련하여 편리한 기능들도 제공해주곤 합니다.
- 옵션 객체를 통해 API 요청이 실패했을 경우 자동으로 retry할 수 있도록 할 수도 있고,
- 쿼리키에 따라 데이터를 캐싱해두어 새로운 요청(중복 요청)이 일어나지 않도록 방지할 수 있습니다.
- 또한 쿼리키를 적절히 조절하여 필요할 때 새롭게 요청을 불러오도록 할 수도 있고,
- invalidateQueries 등을 이용하여 post시 데이터를 자동으로 불러오게 할 수 있습니다. (서버 상태 - 클라이언트 상태 동기화)
마지막으로 useQuery와 같이 조회하는 API를 사용하면, 그 전에 비동기처리를 위해 useEffect를 활용하여 display와 관련된 데이터들을 state로 등록해두고 set 해둬야하는 로직들을 쭉 작성해야했던 것과는 달리 useQuery의 인스턴스가 반환하는 data를 사용하여 깔끔한 코드를 작성할 수도 있어, 유지 보수성도 향상됩니다.
오늘은 탄스탁 쿼리 사용을 위한 필수적인 이해와 더불어, 사용 방법에 대해 알아보는 시간을 갖겠습니다.
사용 방법
탄스탁 쿼리를 사용하기 위해서는, queryClient를 <QueryClientProvider> 를 통해 전달해야 합니다.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
refetchOnWindowFocus: false,
},
},
})
export default const App = () => {
return(
<QueryClientProvider client={queryClient}>
<div id="root">{children}</div>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
이 queryClient를 통해서 쿼리키들을 기억하고, 쿼리키들에 등록된 데이터들을 캐싱해둡니다.
queryClient는 하나의 쿼리키 저장소이며, 여러개의 queryClient를 만든다면 쿼리키가 같아도 다른 데이터를 저장해둘 수 있습니다.
다만 QueryClientProvider에는 단 하나의 queryClient를 전달할 수 있습니다.
따라서 여러개의 queryClient를 이용하고 싶다면 여러개의 QueryClientProvider 를 제작하고 래핑해두어야합니다.
그럼 가장 가까운 QueryClientProvider에서 queryClient를 참조해 사용합니다. (cf. 실행컨텍스트 덕분에 가능합니다)
이제 탄스탁 쿼리를 사용하기 위한 기본적인 설정은 끝났습니다.
쿼리키에 대한 이해
쿼리키는 배열로 정의됩니다. (예전에는 배열이 아니어도 되었으나, 지금은 통일되었습니다.)
쿼리키는 배열의 요소를 순서에 따라 모아 해싱한 값입니다. 따라서 아래와 같은 결론이 나옵니다.
// 서로 같은 쿼리
useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2 }] })
useQuery({ queryKey: ['hello', 'world', 123, { b: 2, c: undefined, a: 1 }] })
// 서로 다른 쿼리
useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2 }] })
useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2, c: 3 }] })
useQuery({ queryKey: ['hello', 'world'] })
즉, 배열의 요소가 다르거나 요소의 순서가 다르다면 다른 값으로 해싱되어, 고유한 식별값인 쿼리키가 달라집니다.
참고로 객체는 프로퍼티의 값이 다르면 다른 객체로 인식합니다. (프로퍼티간의 정의 순서는 전혀 상관 없습니다.)
쿼리키가 같은 경우에는 서로 데이터를 공유합니다. (즉, 한쪽에서는 fetch없이 캐싱되어 있는 데이터를 사용하게 됩니다.)
따라서 의도한게 아니라면, 쿼리키는 중복되지 않도록 설정해야합니다. (아니면 엉뚱한 데이터를 가져올겁니다.)
InvalidateQueries
쿼리키를 무효화하는 메서드입니다. 이 메서드는 prefix에 해당하는 모든 쿼리키를 무효화합니다.
import { useQueryClient } from "@tanstack/react-query";
const query = useQueryClient();
query.invalidateQueries({ queryKey: ["body", "hi"] });
//invalidate되는 쿼리키 예시
["body", "hi"]
["body", "hi", "3"]
["body", "hi", "me"]
//invalidate 되지 않는 쿼리키 예시
["body"]
["hi"]
["body", "his"]
["hi", "body"]
특정 쿼리키가 invalidate되면 그 즉시 해당 쿼리키를 stale 상태로 변경 및 refetch가 일어납니다.
하지만 inactive 상태인 쿼리키는 invalidate할 수 없습니다. invalidate해도 아무 동작 일어나지 않습니다.
staleTime, gcTime에 대한 이해
우선 이 2가지 개념은 useQuery(조회) 인스턴스를 위해 존재하는 개념입니다.
(조회해와서 현재 화면에 사용되는 데이터의 신선도가 중요하지, post-put-patch-delete는 상관이 없으니까요.)
statleTime과 gcTime을 각각 데이터가 탁해지는(신뢰할 수 없는) 시간과 메모리로부터 가비지 컬렉션이 일어나는 시간을 의미합니다.
default 값은 staleTime의 경우에는 0, gcTime은 1000*60*5 (5분) 입니다.
반드시 staleTime보다는 gcTime이 길어야 정상적으로 작동합니다.
statleTime이 gcTime보다 길다면, 정상적으로 가비지 컬렉션이 되지 않습니다. (믿기 힘들다면 devTool를 확인해보세요)
staleTime은 데이터를 fetch 해온 시점부터 측정
fetch를 해온 시점부터 쭉 staleTime을 측정합니다. 이 값은 자동으로 0으로 초기화되지 않습니다.
만약 카운트 결과 statleTime에 도달했을 때, 데이터의 상태는 stale로 변하게 됩니다.
이때는 특정 3가지 조건 중 하나라도 만족하면 신선한 데이터를 fetch 해옵니다.
1. refetchOnMount : 다시 마운트 되었을 때
2. refetchOnWindowFocus : 다시 해당 윈도우에 포커싱이 되었을 때(다른 창을 보고 있다가)
3. refetchOnReconnect : 인터넷이 다시 연결되었을 때
위의 3가지 조건은 기본적으로는 true로 설정되어 있습니다. (참고로 always로 설정하면 stale에 상관없이 데이터를 페칭해옵니다.)
좀 더 깊은 이해를 위해 예시를 들어보겠습니다.
특정 화면에서 불러온 데이터가 있는데, 해당 화면을 벗어나지 않고 계속 보고 있을 때
이 경우에는 위의 3가지 조건 중 만족하는 경우가 하나도 없습니다. 언마운트되었다가 마운트된 것도 아니고, 브라우저의 다른 탭을 보고 있다가 다시 포커싱을 한 것도 아니고, 인터넷이 끊겼다가 다시 연결된 것도 아니니까요.
그럼 만약, 화면에서 staleTime이 지난 후 데이터를 다시 불러오도록 만들 수는 없을까요?
이는 polling하는 방식을 통해 구현해야 합니다. 이를 위해 refetchInterval 옵션을 사용합니다.
useQuery({
queryKey: MAIN_BODY_PARTS_QUERY_KEY.BODY_PARTS_KEY(petProblem),
queryFn: () => getBodyParts(petProblem),
staleTime: 1000 * 3,
gcTime: 1000 * 4,
refetchInterval: 1000 * 3,
// refetchIntervalInBackground: false,
});
위 처럼 staleTime과 같은 간격으로 refetchInterval을 설정해주면 해당 시간이 지날 때마다 fetch 요청을 보냅니다.
또한 기본적으로 refetchIntervalInBackground는 false이기 때문에 만약 다른 윈도우를 보고 있든 잠시 브라우저를 내려두든 요청을 지속적으로 보내고 싶다면 refetchIntervalInBackground 를 true로 설정해주면 됩니다.
gcTime은 useQuery 인스턴스가 inactive(unused)될 때마다 0부터 측정
inactive(unused) 상태는 "현재 화면에서 사용되지 않을 때, 즉 언마운트 되었을 때" 를 의미합니다.
즉 특정한 데이터가 사용되는 화면이 다른 화면으로 전환되면, 해당 데이터가 더이상 쓰이지 않으므로 inactive가 됩니다.
이런 경우에는 inactive 될 때마다 카운트를 하고, 카운트한 결과 gcTime에 도달하면 더이상 사용되지 않을 것이라고 판단하여
메모리에 캐싱해둔 걸 가비지 컬렉션 합니다. 그럼 더이상 메모리에 존재하지 않게 되므로, 다시 해당 인스턴스가 마운트될 때는 무조건 서버로부터 fetch해오게 됩니다.
보통 직접 조절하는 경우는 많지 않습니다. 대부분의 경우 기본값인 5분을 사용합니다.
useQuery - 조회(get)
위의 쿼리키와 staleTime, gcTime을 잘 이해했다면, useQuery의 사용법은 간단합니다.
const {data, isError, isLoading} = useQuery({
queryKey: [CATEGORY_LIST_QUERY_KEY.CATEGORY_LIST],
queryFn: () => getCategory(),
});
//참고) getCategory 함수는 아래와 같이 정의되어 있어요.
type GetCategoryList = paths["/api/dev/posts/categories"]["get"]["responses"]["200"]["content"]["*/*"];
export const getCategory = async (): Promise<GetCategoryList> => {
const { data } = await get<GetCategoryList>(API_PATH.POST_CATEGORIES);
return data;
};
queryKey와 queryFn은 필수 옵션입니다. (위에서 설명했으므로, 쿼리키에 대한 설명은 생략할게요)
그 외에 많은 return값과 옵션 설정을 할 수 있으니, 필요하다면 직접 찾아서 사용하시는 걸 추천합니다.
(https://tanstack.com/query/latest/docs/framework/react/reference/useQuery)
queryFn 이 갖춰야 할 요건
queryFn에는 함수를 넣어주면 되는데, 해당 함수가 갖춰야하는 요건이 있습니다.
반드시 resolved 된 데이터를 반환하거나, Error를 throw 혹은 rejected된 Promise를 return 해줘야해요.
useQuery({
queryKey: ['todos', todoId],
queryFn: async () => {
const response = await fetch('/todos/' + todoId)//resolved된 데이터
if (!response.ok) {
throw new Error('Network response was not ok')
}
if(anotherSomethingGoesWrong){
return Promise.reject(new Error('Oh no!'))
}
return response.json()
},
})
이것만 지키면 queryFn은 정상적으로 잘 동작합니다.
queryFn 실행결과 반환값만 신경 쓰면 돼요. (async가 겉이 쌓여있던, 바깥에 쌓여있던 중요한게 아님)
https://tanstack.com/query/latest/docs/framework/react/guides/query-functions
추가로, queryFn에서 사용되는 변수가 있다면 해당 변수는 쿼리키에 넣는 것을 권장합니다. (공식 문서)
그 값이 변했을 때, 새롭게 fetch해오도록 하는게 일반적이니까요.
useQuery 인스턴스와 QueryObserver(심화)
useQuery 인스턴스라는 표현을 종종 사용하곤 합니다.
이는 useQuery가 실행되며 만들어내는 인스턴스로, QueryObserver의 인스턴스를 의미합니다.
(본격적으로 라이브러리가 확장되며, QueryObserver라는 core 개념이 분리되며 생긴 것으로 추측합니다.)
따라서 엄밀히 말하면 QueryObserver의 인스턴스 객체가 가지고 있는 프로퍼티들(data, isLoading, isError...)를 사용하는 겁니다. 이 QueryObserver에 대해 알아봅시다.
const observer = new QueryObserver(queryClient, { queryKey: ['posts'] })
const unsubscribe = observer.subscribe((result) => {
console.log(result)
unsubscribe()
})
단순하게 쿼리 옵저버를 사용하면, 위와 같이 사용하면 됩니다. (구독할 쿼리키를 설정하는게 핵심!)
subscribe는 쿼리의 상태 변화를 구독하고 있다가, 상태 변화(쿼리가 fetch되거나, 에러, 갱신 등등)가 일어나면 콜백함수를 실행합니다.
위의 코드는 딱 한번만 콜백이 실행된 뒤, 구독이 해제가 됩니다.
이유는 콜백 함수 내에서 재귀적으로 unsubscribe를 호출하기 때문입니다.
(subscribe 메서드의 실행 결과 unsubscribe를 할 수 있는 함수가 반환되기 때문에, 해당 함수를 이용하여 구독 해제)
QueryObserver 클래스는 필수적인 작업들을 수행합니다.
- queryFn 실행을 통해 첫 fetch 및 쿼리키 구독을 통해 쿼리키 변화에 따른 refetch를 비롯해서
- staleTime과 gcTime을 계산하는 로직
- 캐싱된 데이터를 반환할지, 서버로부터 fetch 해와서 데이터를 반환할지 결정하는 로직
등등 다양하고 필수적인 작업들이 전부 이루어진다고 보면 됩니다.
리액트에서는 QueryObserver를 저희가 사용할 일을 거의 없습니다. useQuery를 사용하면 내부적으로 QueryObserver의 인스턴스를 만들기 때문이죠. 따라서 저희는 리액트를 사용한다면 편리하게 useQuery를 사용하면 됩니다. (다만 작동 방식에 대한 궁금증이 있어 정리해두었습니다.)
useMutation - 생성, 수정, 삭제 (post, put - patch, delete)
useMutation의 경우에는 고려해야할 것들이 훨씬 적어집니다.
const { mutate, mutateAsync } = useMutation({
mutationKey: POST_QUERY_KEY.LIKE_POST_QUERY_KEY(postId),
mutationFn: (postId: { postId: string }) => {
return postLike(postId.postId);
},
});
오직 mutateFn 만 필수입니다. 그럼에도 불구하고 mutationKey를 사용하곤 하는 이유는, devtools등에서 어떤 요청이 가는지 쉽게 파악하기 위함이에요. 따라서 mutationFn의 요건에 대해서만 이야기 해볼게요.
mutationFn이 갖춰야 할 요건
반드시 mutationFn: (variables: TVariables) => Promise<TData> 형식을 갖춰야합니다.
즉 프로미스를 반환해야만 합니다. mutateAsync를 사용하기 위함입니다.
따라서 애초에 async로 래핑한 함수를 연결해두기도 합니다.
mutate와 mutateAsync의 차이점
await을 사용하고 싶으면 mutateAsync를 사용하면 됩니다.
즉, 좀 더 async 함수 내에서 유연하게 순서를 조절하거나 try-catch문을 사용하고 싶다면 mutateAsync를 사용해야만 합니다.
그게 아니라면, 간단한 mutate를 사용하곤 합니다. (프로미스를 반환하지도 않습니다.)
const mutation = useMutation({
mutationFn: yourFn,
onSuccess: () => console.log('성공!'),
onError: () => console.log('실패!'),
});
mutation.mutate(data);
const mutation = useMutation({ mutationFn: yourFn });
const handleSubmit = async () => {
try {
const result = await mutation.mutateAsync(data);
console.log('성공 결과:', result);
} catch (error) {
console.error('실패:', error);
}
};
useMutation의 옵션을 활용하여 낙관적 업데이트 구현해보기
useMutation 또한 많은 값을 반환받을 수 있고, 또한 설정할 수 있습니다.
그 중에서 onMutate, onError, onSuccess 등을 설정할 수 있어요. 이미 위에서 설정한 예시를 봤겠지만, 위는 각 요청에 대해 개별적으로 설정하는것이고, 지금은 useMutation 단위에서 설정해두는 겁니다. (더욱 넓은 범위, 우선 순위는 상대적으로 낮음)
onMutate는 mutate를 실행하기 전에 실행되는 콜백함수를 지정해두는 것이고,
onSuccess, onError, onSettled는 mutate가 실행된 뒤 상태에 따라 실행되는 콜백함수를 지정해두는거에요.
아래는 낙관적 업데이트를 위한 예시 코드입니다. onMutate를 통해 낙관적으로 미리 렌더링해두고, 실패시에는 onError에 등록해둔 콜백 함수를 통해 롤백합니다. (참고로 onError를 인식하는건 Error를 throw하거나 rejected된 Promise가 반환될 때입니다.)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const fetchLikeCount = async () => {
const res = await fetch('/api/likes');
return res.json(); // 예: { count: 10 }
};
const postLike = async () => {
const res = await fetch('/api/likes', { method: 'POST' });
if (!res.ok) throw new Error('서버 오류');
return res.json();
};
export default function LikeButton() {
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ['likes'],
queryFn: fetchLikeCount,
});
const mutation = useMutation({
mutationFn: postLike,
onMutate: async () => {
// 1. 이전 값 저장
await queryClient.cancelQueries(['likes']);
const previous = queryClient.getQueryData(['likes']);
// 2. 낙관적 업데이트: 좋아요 수 +1
queryClient.setQueryData(['likes'], (old: any) => ({
count: old.count + 1,
}));
// 3. 롤백을 위해 이전 상태 리턴
return { previous };
},
onError: (_err, _variables, context) => {
// 4. 실패 시 롤백
if (context?.previous) {
queryClient.setQueryData(['likes'], context.previous);
}
},
onSettled: () => {
// 5. 실제 데이터 재요청 (성공/실패 모두)
queryClient.invalidateQueries(['likes']);
},
});
return (
<div>
<p>좋아요 수: {data?.count ?? '...'}</p>
<button onClick={() => mutation.mutate()}>좋아요</button>
</div>
);
}
마무리
이외에도 Tanstack query에서 지원하는 기능은 정말 많습니다.
무한 스크롤을 위해서 지원하는 useInfiniteQuery, Suspense와 결합하여 data가 없을 때 쉽게 폴백을 띄우는 useSuspenseQuery, 별도로 설치해야하지만 쉽게 쿼리 상태를 보며 디버깅을 할 수 있게 돕는 ReactQueryDevtools 까지...
API 요청과 관련해서 이런 기능이 있지 않을까? 하고 찾아보면 있을 정도로 많은 기능을 지원하는 라이브러리니,
필요에 따라 잘 찾아 사용하면 좋을 것 같아요.
이번 아티클에서 다룬 것들만 확실히 이해한다면 리액트 쿼리 관련한 기본기는 어느정도 다져졌을 겁니다.
긴 글 읽어주셔서 감사합니다.
'Develop > Frontend' 카테고리의 다른 글
| history stack (2) | 2025.06.26 |
|---|---|
| 브라우저 캐싱과 헤더 필드 (0) | 2025.06.23 |
| 하이드레이션 에러(Hydration Error) (0) | 2025.05.07 |
| css는 모듈로 뽑을 수 있을까? (1) | 2025.04.29 |
| React Reconciliation 심화 이해를 바탕으로 리액트 잘 활용하기 (2) | 2025.04.18 |