React Query는 기존의 client state를 다루는 상태관리 라이브러리들과 달리, server state 를 Fetching / Caching / 비동기 업데이트하는 데 도움을 주는 라이브러리다.
기존에는 Redux(+saga)로 서버에서 받아온 데이터까지 관리하고 있었는데, 프로젝트를 진행하면서 아래와 같은 고민이 쌓였다.
- 기능추가가 될 수록 store가 커진다.
- API 관련해서 작성되는 코드수와 반복되는 작업이 너무 많다.
- 컴포넌트가 렌더링 하는 작업만 하면 좋을거 같은데 API나 다른 로직들도 포함된다.
- 현재 프론트에서 보여주는 모든 데이터는 API를 통해 가져온 데이터 인데 이 데이터를 다시 state로 만들어서 사용 시 이데이터의 값을 보장할 수 있는지?
- 각 개발자마자 api를 호출하는 방식이 다르다
특히 4번이 핵심이었다. 서버 데이터를 클라이언트 state로 복제해 두면 둘 사이가 어긋날 여지가 생기고, 결국 client state와 server state를 분리해서 다뤄야 한다는 문제로 이어졌다.
사실 rtk-Query도 후보였다. 다만 rtk-Query는 서버 데이터 캐시가 결국 Redux store 안에 쌓인다. 위에서 적은 첫 번째 고민(기능이 늘수록 store가 커진다)을 생각하면, 서버 상태를 store 바깥에서 따로 관리할 수 있는 React Query 쪽이 내 문제에 더 맞았다.
1. 코드 라인 수가 줄어 가독성이 좋아짐

2. 비동기 API 로직을 한 곳에서 볼 수 있어 관심사 분리에 용이
3. onSuccess onError isFetching 등 상태 플래그를 제공해 편리
4. 다양한 옵션으로 데이터 변환이 효율적 (select 활용)
5. staleTime으로 불필요한 재요청을 줄일 수 있음
React Query는 받아온 데이터를 staleTime 동안 fresh로 간주해, 그 사이에는 컴포넌트 재마운트나 창 포커스 복귀 같은 상황에도 네트워크 요청을 다시 보내지 않는다. 자주 바뀌지 않는 데이터라면 staleTime만 늘려도 백엔드 호출을 줄일 수 있다. (데이터를 메모리에 얼마나 보관할지를 정하는 gcTime(구 cacheTime)과는 별개의 옵션이다.)
6. 같은 요청은 자동으로 합쳐준다 (요청 중복 제거)
거의 같은 시점에 같은 queryKey로 여러 컴포넌트가 데이터를 요청하면, 첫 요청이 만든 Promise를 공유해 실제 네트워크 요청은 한 번만 나간다. 헤더와 본문에서 같은 유저 정보를 각각 useQuery로 불러도 호출은 1번이다.
7. 그래서 별도 전역 상태 없이 server state를 전역처럼 쓸 수 있다
queryKey가 쿼리를 유일하게 식별하므로, 같은 key를 쓰는 컴포넌트는 트리 어디에 있든 같은 데이터를 바라본다. 이를 custom hook으로 감싸두면 Redux·zustand 같은 전역 상태 라이브러리에 서버 데이터를 따로 담지 않아도 어디서든 같은 값을 꺼내 쓸 수 있다. tkdodo는 이를 "React Query를 (async) 상태 관리자로 쓴다"고 표현하는데, 핵심은 서버 데이터를 다른 상태 도구로 복사·동기화하지 말고 React Query를 single source of truth로 두는 것이다. (단, 이 방식은 server state에 한정된다.)
queries 폴더를 따로 두고, useQuery를 쓰는 각 쿼리를 도메인별 custom hook으로 분리해 관리했다. 쿼리 관련 로직은 hook 안에서 처리하고, 컴포넌트는 hook으로 가져온 데이터를 렌더링하는 데만 집중하게 했다.
기존에는 컴포넌트의 useEffect 안에서 직접 API를 호출하고, 응답을 state에 넣고,isLoading · 에러까지 일일이 관리해야 했다.
// Before — 컴포넌트가 데이터 패칭까지 떠안음
const [data, setData] = useState();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
getUserInfo()
.then((res) => setData(res.data))
.catch((e) => console.error(e))
.finally(() => setIsLoading(false));
}, []);
// After — 패칭/로딩/에러는 hook이 담당, 컴포넌트는 data만 사용
const { data, isLoading } = useMainQuery();
API 요청 관련 부분을 컴포넌트가 신경 쓰지 않아도 되고, 코드량도 확실히 줄어 편리했다.


서버로부터 데이터를 조회할 때 사용한다. 첫 번째 인자인 queryKey로 캐싱된 값을 재사용하거나, 없으면 queryFn으로 데이터를 가져온다.
회사에서 기존 main 프로젝트에서 쓰던
SWR은 key로API URL을 사용한다. 서버에 의존하는 server state다 보니 URL을 그대로 key로 쓰면 "어떤 값을 key로 쓸지 / 이미 쓰는 key인지" 고민이 사라져 관리가 편했고, 이후 어드민에서 React Query를 쓸 때도 같은 방식을 택했다.다만 트레이드오프는 있다. URL을 통째로 문자열 key로 쓰면
['todos', { page }]같은 계층형 배열 key가 주는 이점 — 즉['todos']prefix로 관련 쿼리를 한 번에 무효화(invalidateQueries)하는 것 — 을 활용하기 어렵다. 편의와 무효화 유연성 사이의 선택이었다.
queryKey, queryFn, optionsisError, isSuccess, isLoading, isFetching 등select를 쓰면 컴포넌트로 넘기기 전에 데이터를 가공할 수 있다.
기존에는 Axios interceptor에서 data 객체를 벗겨 return했는데, 성공/실패와 status 코드에 따라 분기 처리를 하다 보니 interceptor 내부가 복잡해졌다. select는 쿼리가 성공했을 때만 실행되는 옵션이라, 성공/실패를 가르는 로직 없이 변환만 깔끔하게 처리할 수 있어 편했다.
//hooks/queries/useMainAccTimesQuery.ts
export const useMainAccTimesQuery = (options?: UseQueryOptions) => {
return useQuery(["accTimes"], getAccumulationTimes, { select: ({ data }) => data });
};
프론트와 백엔드의 데이터 형태가 일치하지 않는 문제도 select로 가공해 해결했다. 덕분에 컴포넌트는 데이터를 보여주는 데만 집중할 수 있었다.
//utils/decode.ts
export const decodeUserData = (init: UserInfoResponse): Omit<UserInfoType, "isLogin"> => ({
loginID: init.login,
profileImage: init.profileImage,
isAdmin: init.isAdmin,
inoutState: init.inoutState,
});
//hooks/queries/useMainQuery.ts
const { data, ...queryInfo } = useQuery(["mainInfo"], getUserInfo, {
select: (data: AxiosResponse<UserInfoResponse>) => decodeUserData(data.data),
onSuccess: (data) => {
setUserInfo(data);
},
});
queryKey에 인자를 함께 넘긴다. React Query는 queryKey 배열 전체를 해시해서 캐시를 식별하므로, 인자가 바뀌면 이를 별개의 쿼리로 보고 새로 패칭한다. 예를 들어 ["timeLogs", year, month]에서 month가 바뀌면 다른 캐시 엔트리로 관리된다.
//hooks/queries/useMonthTimeLogsQuery.ts
type useMonthTimeLogsProps = {
year: number;
month: number;
options?: UseQueryOptions;
};
export const useMonthTimeLogsQuery = ({ year, month, options }: useMonthTimeLogsProps) => {
...
const { data, ...queryInfo } = useQuery(
["timeLogs", year, month],
() => getLogsmonth(year, month),
...
);
...
};
//hooks/queries/useMainQuery.ts
export const useMainQuery = (options?: UseQueryOptions) => {
const [userInfo, setUserInfo] = useState<UserInfoType>({
loginID: "user",
isAdmin: false,
profileImage: "",
inoutState: "OUT",
});
const { data, ...queryInfo } = useQuery(["mainInfo"], getUserInfo, {
select: (data: AxiosResponse<UserInfoResponse>) => decodeUserData(data.data),
onSuccess: (data) => {
setUserInfo(data);
},
});
return { userInfo, setUserInfo, queryInfo };
};
돌아보며: 위
useMainQuery는onSuccess에서setUserInfo로 데이터를 별도의useState(client state)에 복사하고 있다.
지금 보면 권장되지 않는 패턴이다.useQuery가 이미 server state를 들고 있는데 그 값을 또 client state로 복제하면 두 값이 어긋날 수 있고,setUserInfo가 추가 렌더 사이클을 만들어 중간 렌더에 빈 값이 들어가기도 한다.
앞서 정리한 "서버 데이터를 다른 상태 도구로 복사하지 말고 React Query를 single source of truth로 둔다"는 원칙과도 어긋나는 코드였다.
지금 다시 쓴다면onSuccess없이useQuery가 반환하는data를 그대로 사용하는 게 맞다.
버전 메모 (v4 기준): 이 글의 코드는 React Query v4 기준이다.
useQuery의onSuccess·onError·onSettled콜백은 v5에서 제거되었다(useMutation에는 그대로 남아 있다). v5에서 같은 side effect가 필요하면useEffect에서isSuccess/error상태를 보고 처리하거나, QueryClient의 전역 콜백을 사용한다.
무한 스크롤도 useInfiniteQuery의 hasNextPage, fetchNextPage 같은 반환값을 이용해 간단하게 구현할 수 있다.
기존에 무한 스크롤을 구현할 때 page도 state로 만들고 hasMore도 state로 만들어 관리했던 걸 생각하면 훨씬 간단하다. 실제로 어드민은 상품 목록·이벤트 목록처럼 무한 스크롤이나 페이지네이션을 쓰는 목록 페이지가 많아서 자주 쓰게 된다.
//hooks/queries/useInfinitiePointCouponList.ts
const { data, refetch, hasNextPage, fetchNextPage } = useInfiniteQuery(
[pointCouponApiUrl.getCouponCodes(campaignId)],
async ({ pageParam = 0 }) => {
return axios.get<PointCouponApi.GetPointCouponList>(pointCouponApiUrl.getCouponCodes(campaignId), {
params: {
offset: pageParam,
pageSize: DEFAULT_PAGE_SIZE,
},
});
},
{
enabled: !isSearch,
getNextPageParam: (lastPage) => {
return lastPage.data.result.length === 0 ? undefined : lastPage.config.params.offset + DEFAULT_PAGE_SIZE;
},
}
);
return {
data:data?.pages
.map((pageData) => {
return [...pageData.data.result];
})
.flat(),
refetch,
}
위의 커스텀 훅을 불러 data를 map 돌면서 사용할 수 있다.
데이터가 많아지면서 API는 페이지네이션으로 받고, 프론트엔드는 인피니티 스크롤로 표현되게 구현했다.
전체 리스트가 아닌 특정 페이지에 대한 요청만 보내고 싶다면, useInfiniteQuery 반환값 중 refetch를 이용해 아래와 같이 쓸 수 있다.
const { mutate: updateBulkOwnerUserId } = useMutation(
... mutationFunction,
{
onSuccess: (response, variables) =>
refetch({
refetchPage: (page: AxiosResponse<PointCouponApi.GetPointCouponList>) => {
const { result } = page.data;
const shouldRefetch = result.some((coupon) =>
variables.some(({ couponCode }) => couponCode === coupon.couponCode)
);
return shouldRefetch;
},
}),
onError: (error) => {
if (axios.isAxiosError(error)) {
console.error(error);
window.alert(`update bulk coupon code error \n ${error.response?.data.resultMessage}
\n${error.response?.data.resultCode}`);
}
},
}
);
useMutation의 onSuccess 콜백에서 useInfiniteQuery의 refetch를 호출해 특정 페이지의 데이터만 다시 불러오는 방법이다.
버전 메모 (v4 기준):
refetch의refetchPage옵션도 v5에서 제거되었고, 대신 무한 쿼리가 저장·리페치할 페이지 수를 제한하는maxPages옵션이 도입됐다.
또한 v5에서는useInfiniteQuery에initialPageParam을 명시해야 하며, 위 코드처럼queryFn의pageParam기본값(= 0)에 의존하는 방식은 더 이상 쓰지 않는다.
이 refetchPage 패턴은 목록 페이지가 많은 어드민에서 특히 자주 쓰게 됐는데, 나중에 팀에 전파할 때 동료들에게 가장 질문을 많이 받은 부분이기도 했다.
개인적으로 검증한 패턴을 회사 어드민 프로젝트로 가져왔다. 어드민은 여러 명이 나눠 개발하다 보니 API를 부르는 방식이 개발자마다 달랐다.
useEffect 안에서 axios를 직접 호출하고 로딩/에러를 컴포넌트 로컬 state로 관리했고,그러다 보니 같은 종류의 API를 화면마다 다르게 부르고, 로딩·에러 처리 규칙도 제각각이라 코드 스타일이 통일되지 않았다.
React Query를 도입하면서 아래 규칙으로 통일했다.
queries 폴더에 도메인별 custom hook(useXxxQuery)으로 호출을 모음select 가공을 hook 레이어에 모아, 컴포넌트는 렌더링만 담당하게 함전파는 거창한 절차 없이 두 가지로 했다. PR 리뷰 코멘트에서 새로 추가되는 API 호출을 custom hook 패턴으로 유도했고, 짧은 공유 세션으로 폴더 구조와 hook 작성 규칙을 한 번 정리해 공유했다.
특히 목록 페이지가 많은 어드민 특성상 useInfiniteQuery(무한 스크롤/페이지네이션) 관련 질문을 동료들에게 많이 받았다. 그때마다 getNextPageParam 설정이나 앞의 useInfiniteQuery 섹션에서 다룬 특정 페이지만 refetch하는 방법 같은 걸 같이 보면서 알려줬는데, 이 과정이 패턴을 자연스럽게 퍼뜨리는 계기가 됐다.
결과적으로 이후 신규 화면을 만들 때는 기존 hook 패턴을 참고해서 도메인마다 custom hook을 만드는 방식으로 자리잡았다. "API는 이렇게 부른다"는 기준이 생기니 매번 방식을 새로 정하지 않아도 됐고, 새로 합류하는 사람도 따라갈 레퍼런스가 생겼다.
도입부에서 던졌던 고민 — 특히 "API로 받아온 데이터를 다시 state로 복제하면그 값을 신뢰할 수 있는가" — 이 React Query를 도입하면서 가장 크게 풀렸다.
server state를 컴포넌트 state로 복제하지 않고, 필요한 곳에서 같은 queryKey로 직접 꺼내 쓰게 되니 "이 값이 최신인가"를 의심할 일이 줄었다. (물론 이 글의 useMainQuery처럼 onSuccess로 복제하던 코드도 있었고, 그건 원칙과 어긋난 선택이었다.)
개인 학습에 그치지 않고 어드민 팀의 API 호출 방식을 custom hook 패턴으로 통일·전파한 것도 의미가 있었다. 공식문서와 tkdodo 블로그를 컨벤션 근거로 삼은덕에 "내 취향"이 아니라 "정해진 기준"으로 설득할 수 있었다.
다만 이 프로젝트는 실시간 동기화가 필요해 캐싱(staleTime) 같은 React Query의 핵심 강점은 충분히 살리지 못했다. 캐싱 전략, v5의 변경점(onSuccess·refetchPage 제거 등)은 다음 과제로 남겨둔다.
https://parang.tech/react/2022-react-01/#keep-server-and-client-state-separate
https://parang.tech/react/2022-react-01/
https://tech.kakao.com/2022/06/13/react-query/
https://tech.kakaopay.com/post/react-query-1/
https://tech.osci.kr/2022/07/13/react-query/