TanStack Query 란?
서버 상태(Server State) 관리를 도와주는 라이브러리
서버로부터 데이터를 가져오기(fetch), 캐싱(cache), 자동 갱신(refetch) 등을 효율적으로 처리할 수 있게 해준다
주요 기능
- 데이터 가져오기 및 캐싱
- 동일 요청의 중복 제거
- 무한 스크롤, 페이지네이션 등의 성능 최적화
- 네트워크 재연결, 요청 실패 등의 자동 갱신
데이터 캐싱
- 데이터를 가져올 때는 항상 쿼리 키(queryKey)를 지정하게 됨
- 이 queryKey는 캐시된 데이터와 비교해 새로운 데이터를 가져올지, 캐시된 데이터를 사용할지 결정하는 기준이 된다.
1
2
3
4
| useQuery({
queryKey: ['delay'],
queryFn: () => fetch('...').then(res => res.json())
})
|
queryKey
: 데이터를 구분하는 고유한 이름- 같은
queryKey
로 요청하면 캐시된 데이터를 사용
동작 원리
- queryKey에 해당하는 캐시가 없으면 → 서버에서 데이터를 가져옴
서버에서 데이터를 가져오면 그 데이터는 캐시되고 그 이후 요청부터는 캐시된 데이터를 사용할 수 있음
- queryKey에 해당하는 캐시가 있으면 → 서버에 요청하지 않고 캐시 데이터 사용
- 따라서 같은 데이터를 가져오는 요청이 여러 번 발생해도, 캐시된 데이터를 사용하게 되어 중복 요청을 줄일 수 있다.
그렇다면 한번 캐시된 데이터가 있으면, 서버로는 더 이상 요청을 보낼 수 없는 걸까? -> 데이터의 신선도
데이터의 신선도
TanStack Query는 캐시한 데이터를 신선(Fresh)하거나 상한(Stale) 상태로 구분해 관리한다.
- 캐시된 데이터가 신선 -> 캐시된 데이터를 사용
- 데이터가 상함 -> 서버에 다시 요청해 신선한(새로운) 데이터를 가져옴
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| import { useQuery } from '@tanstack/react-query'
export default function DelayedData() {
const { data, isStale } = useQuery({
queryKey: ['delay'],
queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
staleTime: 1000 * 10 // 10초 후 상함. 즉, 10초 동안 신선함.
})
return (
<>
<div>데이터가 {isStale ? '상했어요..' : '신선해요!'}</div>
<div>{JSON.stringify(data)}</div>
</>
)
}
|
staleTime
: 캐시된 데이터가 신선한 상태로 유지되는 시간(ms)isStale
: 현재 데이터가 신선한지 여부
useQuery
- 가장 기본적인 쿼리 훅으로, 컴포넌트에서 데이터를 가져올 때 사용
- 로딩 상태, 에러 처리, 자동 재요청, 캐싱 전략까지 모두 관리해준다.
기본 구조
1
2
3
4
5
| const query = useQuery<DataType>({
queryKey: ['key'],
queryFn: async () => await fetch(...).then(res => res.json()),
...기타 옵션들
})
|
주요 옵션
queryKey
: 캐시를 구분하기 위한 고유 키 (배열 권장)queryFn
: 데이터를 가져오는 비동기 함수staleTime
: 데이터가 신선하게 유지되는 시간(ms)cacheTime
: 캐시 데이터를 메모리에 유지할 시간
TanStack Query를 활용한 날씨 API 리팩터링
🔸 QueryClient 세팅 - main.jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { router } from "./router/index";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
// 전역에서 Query를 관리할 QueryClient 생성
const queryClient = new QueryClient();
// 앱 전체에서 React Query 사용 가능하도록 설정
createRoot(document.getElementById("root")).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
{/* 개발 중에는 ReactQueryDevtools로 상태 확인 가능 */}
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</StrictMode>
);
|
🔸 useWeatherApi.jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 커스텀 훅으로 공통화
export const useWeather = (city) => {
console.log("날씨 데이터:", city);
return useQuery({
queryKey: ["weather", city], // 캐싱 및 재요청 구분 기준
queryFn: async () => {
try {
const data = city ? await getCountryData(city) : await getCurrentData();
return data;
} catch (err) {
console.log("", err);
}
},
staleTime: 1000 * 60 * 5,
retry: 1,
});
};
|
❓ staleTime
왜 쓰는가?
staleTime
동안은 캐시된 데이터를 신선한(fresh) 데이터로 간주- 같은 쿼리 요청 시, API 재호출 없이 캐시된 데이터만 사용
- 특히 공공 API처럼 요청 제한이 있는 경우 유용
👉 날씨, 오늘의 영화 등 자주 바뀌지 않는 정보에 적합
콘솔 출력 예시
1
2
| const res = useWeather(city);
console.log("res", res.data);
|
res
는 TanStack Query가 반환하는 객체res.data
: 날씨 데이터res.isLoading
: 로딩 여부res.isError
: 에러 여부
console.log(res)
console.log(res.data)
- data, isLoading, isError 등 필요한 정보가 객체로 깔끔하게 정리되어 있음
🔸 기존 방식 (useEffect + fetch)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| useEffect(() => {
const fetchWeatherData = async () => {
try {
let data;
if (city) {
data = await getCountryData(city);
} else {
data = await getCurrentData();
}
setWeatherData(data);
} catch (err) {
console.err(err);
}
};
fetchWeatherData();
}, [city]);
|
- 직접 API 호출 → 상태 관리 필요 (
useState
, useEffect
) - 캐싱, 재시도, 로딩/에러 상태 따로 처리해야 함
- 요청 시마다 항상 API 호출됨 → 비효율적
🔸 개선 방식 (TanStack Query 사용)
1
| const { data: weatherData, isLoading, isError } = useWeather(city);
|
- weatherData: 실제 날씨 데이터
- isLoading: 로딩 중 여부
- isError: 오류 발생 여부
💡 상태 분기만 해주면 간편하게 UI 렌더링 가능
🔸 변경 포인트
항목 | 기존 방식 | TanStack Query |
---|
상태 관리 | 수동 (useState + useEffect) | 자동 (isLoading , isError , data ) |
캐싱 | X | O (queryKey 기준) |
재시도 | X | O (retry ) |
오래된 데이터 | 수동 관리 | staleTime 으로 자동 관리 |
개발 편의성 | 직접 핸들링 필요 | 훨씬 간결하고 재사용성 높음 |
📁 전체 흐름 요약
useWeather(city)
커스텀 훅으로 공통 로직 분리queryFn
내부에서 city
에 따라 API 호출 결정staleTime
설정으로 데이터 새로고침 최소화QueryClientProvider
로 앱 전역에서 사용 가능- 개발 중엔
ReactQueryDevtools
로 상태 디버깅 가능
🔸 실습 UI
캠핑장 API - TanStack Query 적용 흐름
🔸 캠핑장 데이터 API 호출 - getCamping.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| import axios from "axios";
const CAMPING_API = import.meta.env.VITE_CAMPING_API_KEY;
const CAMPING_BASE_URL =
"https://api.odcloud.kr/api/15037499/v1/uddi:adf7c061-042d-4965-9b7c-87585251862b";
export const getCampingData = async (page = 1, perPage = 10) => {
try {
const res = await axios.get(
`${CAMPING_BASE_URL}?page=${page}&perPage=${perPage}&serviceKey=${CAMPING_API}`
);
return res.data;
} catch (error) {
console.log(err);
}
};
|
🔸 캠핑장 데이터 가져오는 훅 - useCamping.js
1
2
3
4
5
6
7
8
9
10
11
| import { useQuery } from "@tanstack/react-query";
import { getCampingData } from "./getCampingApi";
export const useCamping = (page, perPage) => {
return useQuery({
queryKey: ["camping", page], // 페이지 별로 캐싱
queryFn: async () => await getCampingData(page, perPage),
staleTime: 1000 * 60 * 5, // 5분 동안은 새로운 요청 없이 캐시 사용
cacheTime: 1000 * 60 * 10, // 사용하지 않아도 10분동안 메모리에 유지
});
};
|
staleTime
: 데이터가 신선하다고 간주되는 시간. 이 시간 동안은 재요청 없이 캐시 사용cacheTime
: 컴포넌트가 언마운트된 후에도 데이터를 캐시에 유지하는 시간
🔸 컴포넌트 - CampingPage.jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| import React, { useState } from "react";
import { useCamping } from "./useCamping";
import css from "./CampingPage.module.css";
import DetailModal from "./DetailModal";
const CampingPage = () => {
// TanStack Query를 통해 캠핑 데이터 가져오기
const { data, isError, isLoading } = useCamping(1, 10);
// 캠핑 데이터 구조 분해
const campingData = data?.data;
const totalCount = data?.totalCount;
const page = data?.page;
const perPage = data?.perPage;
// 상태 처리
isError && <p>에러 발생</p>;
isLoading && <p>Loading..</p>;
return (
<main>
<p>
총 {totalCount}개 중 {perPage}개 표시 / 현재 {page}page
</p>
<ul className={css.list}>
{campingData?.map((list, i) => (
<li
key={list["야영장명"] + i}
onClick={() => handleCampingClick(list)}
>
<p>야영장명 : {list["야영장명"]}</p>
<p>주소 : {list["주소"]}</p>
<p>
연락처 : {list["연락처 앞자리"]}-{list["연락처 중간자리"]}-
{list["연락처 끝자리"]}
</p>
</li>
))}
</ul>
</main>
);
};
export default CampingPage;
|
🔸 실습 UI
github 코드 참고
Reference
END