최근 네이버 재직자님의 스터디를 계기로 React Query에 관심이 생겨 개념과 활용법을 정리해보려 한다.
1. ReactQuery
💡 react-query 를 사용하면 서버의 상태를 간단하고 효율적이게 처리 할 수 있습니다.
- 자동 캐싱 & 재사용 • 동일한 쿼리를 여러 컴포넌트에서 사용할 때 데이터를 자동으로 캐싱하고 재사용합니다. • 덕분에 불필요한 네트워크 요청을 줄일 수 있습니다.
- 자동 리페치 (Refetch) • 윈도우 포커스 변경 시, 네트워크가 재연결되었을 때 등 자동으로 데이터 갱신 가능. • refetchOnWindowFocus, refetchOnReconnect 같은 옵션 제공.
- 로딩/에러/성공 상태 자동 관리 • isLoading, isError, isSuccess 같은 상태를 자동으로 제공합니다. • 수동으로 상태 관리를 하지 않아도 됨.
- 간편한 쿼리 무효화 (invalidate) 및 갱신 • useMutation과 queryClient.invalidateQueries() 조합으로 데이터 일관성을 쉽게 유지할 수 있음. • 예: POST/PUT/DELETE 이후 목록 자동 새로고침.
- 서버 상태를 전역으로 공유 • 서버 데이터를 React Context처럼 공유 가능 (별도 상태관리 라이브러리 불필요). • 전역 상태 관리 도구 (Recoil, Redux 등) 없이도 서버 데이터 관리에 충분.
- 개발자 도구 제공 • React Query Devtools로 쿼리 상태, 캐시 등 실시간으로 확인 가능.
- Suspense, Pagination, Infinite Query 등 고급 기능 • React.Suspense 연동 지원 • 페이지네이션 및 무한 스크롤 기능 지원 (useInfiniteQuery)
2. gctime과 stale time
✅ gcTime (Garbage Collection Time)
캐시된 쿼리 데이터가 모든 컴포넌트에서 언마운트된 후에도 얼마 동안 메모리에 유지될지를 밀리초 단위로 설정합니다.
✅ staleTime
데이터가 신선하다고 간주되는 시간. 이 시간내에는 refetch 를 하지 않는다.
시나리오. 만약 stale time이 2초, gcTime이 10초라면 ?
1. 처음 마운트
- useQuery()가 실행되며 네트워크에서 데이터를 fetch함.
- 캐시에 저장됨, 그리고 staleTime 타이머(2초)가 시작됨.
2. 2초 내 재사용
- 다른 컴포넌트에서 같은 쿼리를 사용하거나 리렌더 되어도 → refetch 안 함 (신선함 유지)
3. 2초 이후에도 마운트 중
- staleTime이 지나면 데이터는 stale 상태로 바뀜.
- 이후 조건이 맞으면(윈도우 focus, refetch interval 등) → refetch 발생할 수 있음
4. 컴포넌트 언마운트
- 해당 쿼리를 쓰는 모든 컴포넌트가 사라지면 → React Query는 "아무도 안 씀" 상태로 인식
- 이제부터 gcTime (10초) 타이머 시작됨
5. 10초 안에 다시 마운트되면?
- 쿼리 캐시가 살아있으므로 → 기존 데이터 재사용 (신선한지 여부에 따라 refetch 여부 결정)
6. 10초가 지나면
- 캐시 완전히 삭제됨 → 다시 마운트되면 처음부터 fetch
2-1. stale time 설정
$ pnpm install ms
$ pnpm install -D @types/ms
- QueryClientProvider.tsx

추가적으로 gcTime 또한 설정 가능
2-2. React Query Hook 만들기
💡 서버에서 데이터를 보낼 때는 useMuation을, 데이터를 가져올 때는 useQuery를 사용하시면 됩니다.
- useWorkerList.ts
import { useQuery } from "@tanstack/react-query";
import { getWorkerList } from "../selfApi";
import { useAtomValue } from "jotai";
import { headerParamsAtom } from "@shared/stores/atom";
// 근로자 리스트를 조회할 때 사용하는 파라미터 타입 정의
interface WorkerListParams {
selectedPage: number; // 현재 선택된 페이지
limit: number; // 페이지당 항목 수
contentParams: {
start_date?: string; // 검색 시작일 (선택)
end_date?: string; // 검색 종료일 (선택)
search_text?: string; // 검색어 (선택)
};
}
// 근로자 리스트를 가져오는 커스텀 훅
const useWorkerList = ({
selectedPage,
limit,
contentParams,
}: WorkerListParams) => {
// 전역 상태에서 헤더에 필요한 파라미터 가져오기
const headerParams = useAtomValue(headerParamsAtom);
// 데이터를 페이지 단위로 잘라주는 유틸 함수
function disassemble(index: number, data: [], size: number) {
const res = [];
for (let i = 0; i < data.length; i += size) {
res.push(data.slice(i, i + size)); // size 단위로 잘라서 배열에 push
}
return res[index] || []; // 요청한 페이지에 해당하는 데이터 반환
}
// useQuery를 사용하여 서버에서 데이터 패칭
return useQuery({
queryKey: [useWorkerList.getKey(), selectedPage, limit], // 쿼리 키는 리스트 식별용 + 페이지, 제한 수
queryFn: async () => {
// API에 넘길 모든 파라미터 병합
const params = Object.assign(
{},
contentParams,
headerParams
);
// API 호출
const res = await getWorkerList(params);
// 응답에서 데이터 추출 (rsMap이 없으면 빈 배열)
const tableData = res.data?.rsMap ?? [];
// 분할된 페이지 데이터와 전체 수를 반환
return {
data: disassemble(selectedPage - 1, tableData, limit),
totalCount: tableData.length,
};
},
});
};
// 쿼리 키를 외부에서 재사용할 수 있도록 static 키 제공
useWorkerList.getKey = () => ["workerList"];
export default useWorkerList;
2-3. useQuery Hook의 isFetching, isError를 활용한 고차 컴포넌트
- workerManagement.tsx
...
// useWorkerList 훅을 호출하여 데이터를 패칭하고 상태를 받아옴
const { data, refetch, isFetching, isError, error } = useWorkerList({
selectParams, // 필터링용 select 박스 값
selectedPage, // 현재 페이지
limit, // 한 페이지당 아이템 수
contentParams, // 검색 조건 및 날짜 범위
});
...
// 콘텐츠 Wrapper 컴포넌트, 검색 필터 및 범위를 지정
<Content
label="" // 화면 타이틀
type="range" // 검색 필터 타입 (기간 검색)
searchText={searchText} // 검색어 입력값
onSearchText={setSearchText} // 검색어 입력 변경 핸들러
selected={searchDate} // 선택된 날짜 범위
onSelect={setSearchDate} // 날짜 변경 핸들러
onSearchEvent={onSearchEvent} // 검색 버튼 클릭 시 실행할 함수
>
{/* 테이블 틀(헤더 포함)을 감싸는 컴포넌트 */}
<Table label="" width={width}>
{/* 고차 컴포넌트: 로딩, 에러, 정상 데이터를 분기 처리함 */}
<AsyncTableBody
isFetching={isFetching} // 데이터 로딩 중 여부
isError={isError} // 에러 발생 여부
error={error} // 에러 객체
refetch={refetch} // 다시 요청할 수 있는 함수
// 로딩 상태일 때 보여줄 컴포넌트 (콜백 형태로 전달)
loadingFallback={() => (
<Skeleton
width={width} // 셀 너비
size={limit} // 몇 줄 그릴지 (페이지당 항목 수)
menu={menuList.length} // 셀 개수
/>
)}
// 에러 상태일 때 보여줄 컴포넌트 (에러와 refetch 전달 가능)
errorFallback={(err, refetch) => (
<DataFetchError
label={""} // 어떤 화면에서 에러났는지 라벨
error={err} // 에러 내용
refetch={refetch} // 다시 시도할 수 있는 버튼 등에서 사용
/>
)}
>
{/* 실제 데이터를 렌더링하는 테이블 본문 */}
<TableBody
label="" // 타이틀
data={data?.data} // 테이블에 표시할 데이터
width={width} // 셀 너비
filteredIndex={filteredIndex} // NO 표시 시 시작 인덱스 계산값
/>
</AsyncTableBody>
</Table>
{/* 페이지네이션: 페이지 변경 및 limit 변경 가능 */}
<CommonPagination
totalCount={data?.totalCount} // 전체 데이터 개수
selectedPage={selectedPage} // 현재 선택된 페이지
limit={limit} // 페이지당 보여줄 항목 수
onPageChange={(page) => { // 페이지 번호 변경 시
setSelectedPage(page);
refetch(); // 새 페이지 데이터 패칭
}}
onLimitChange={(newLimit) => { // 한 페이지 항목 수 변경 시
setLimit(newLimit);
setSelectedPage(1); // 페이지 초기화
refetch(); // 데이터 다시 요청
}}
/>
</Content>
2-4. Query Key를 통한 데이터 구조
const queryClient = useQueryClient();
// 예: 쿼리 무효화
queryClient.invalidateQueries(["workerList", selectedPage, limit]);
// 예: 캐시된 데이터 직접 가져오기
const cachedData = queryClient.getQueryData(["workerList", selectedPage, limit]);
// 예: 캐시된 데이터 수동으로 설정
queryClient.setQueryData(["workerList", selectedPage, limit], newData);