Post

[Day64] TanStack Query(React Query)

[Day64] TanStack Query(React Query)

TanStack Query 란?

서버 상태(Server State) 관리를 도와주는 라이브러리
서버로부터 데이터를 가져오기(fetch), 캐싱(cache), 자동 갱신(refetch) 등을 효율적으로 처리할 수 있게 해준다

주요 기능

  • 데이터 가져오기 및 캐싱
  • 동일 요청의 중복 제거
  • 무한 스크롤, 페이지네이션 등의 성능 최적화
  • 네트워크 재연결, 요청 실패 등의 자동 갱신

데이터 캐싱

  • 데이터를 가져올 때는 항상 쿼리 키(queryKey)를 지정하게 됨
  • 이 queryKey는 캐시된 데이터와 비교해 새로운 데이터를 가져올지, 캐시된 데이터를 사용할지 결정하는 기준이 된다.
1
2
3
4
useQuery({
  queryKey: ['delay'],
  queryFn: () => fetch('...').then(res => res.json())
})
  • queryKey: 데이터를 구분하는 고유한 이름
  • 같은 queryKey로 요청하면 캐시된 데이터를 사용

동작 원리

  1. queryKey에 해당하는 캐시가 없으면 → 서버에서 데이터를 가져옴
  2. 서버에서 데이터를 가져오면 그 데이터는 캐시되고 그 이후 요청부터는 캐시된 데이터를 사용할 수 있음

  3. queryKey에 해당하는 캐시가 있으면 → 서버에 요청하지 않고 캐시 데이터 사용
  4. 따라서 같은 데이터를 가져오는 요청이 여러 번 발생해도, 캐시된 데이터를 사용하게 되어 중복 요청을 줄일 수 있다.

그렇다면 한번 캐시된 데이터가 있으면, 서버로는 더 이상 요청을 보낼 수 없는 걸까? -> 데이터의 신선도

데이터의 신선도

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>
);

React Query Devtools로 상태 확인 가능

🔸 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)
캐싱XO (queryKey 기준)
재시도XO (retry)
오래된 데이터수동 관리staleTime 으로 자동 관리
개발 편의성직접 핸들링 필요훨씬 간결하고 재사용성 높음

📁 전체 흐름 요약

  1. useWeather(city) 커스텀 훅으로 공통 로직 분리
  2. queryFn 내부에서 city에 따라 API 호출 결정
  3. staleTime 설정으로 데이터 새로고침 최소화
  4. QueryClientProvider로 앱 전역에서 사용 가능
  5. 개발 중엔 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

This post is licensed under CC BY 4.0 by the author.