웹 앱을 만들다 보면 API 요청 패턴이 반복됩니다. 매번 useQuery에 같은 옵션을 쓰고, fetch로 비슷한 처리를 하다보면 “이걸 깔끔하게 감추는 훅이 필요하다”는 생각이 듭니다. 이번 글에선 React Query 기본 세팅부터 **커스텀 훅(useApiQuery)**을 만들어 실제 화면에서 쓰는 과정까지, 코드를 중심으로 정리합니다.
데모 코드는 TypeScript + React 19, @tanstack/react-query v5 기준입니다.
1) QueryClient 설정
전역에서 쓸 QueryClient를 만들고 기본값을 지정합니다.
import { QueryClient } from "@tanstack/react-query";
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 300_000,
refetchOnWindowFocus: false,
retry: 1,
throwOnError: false,
},
},
});
staleTime과 gcTime의 차이는 자주 헷갈립니다. staleTime은 데이터의 신선도 창구(재요청 여부 결정), gcTime은 캐시 메모리 보관 기간입니다. 신선하지 않아도 캐시에 남아있을 수 있고, 이때는 **소프트 캐시 적중(과거 데이터 즉시 표시 + 백그라운드 갱신)**이 가능합니다.
전역 Provider는 엔트리 영역에서 감쌉니다.
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { createQueryClient } from "./react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = createQueryClient();
const root = document.getElementById("root");
if (root)
ReactDOM.createRoot(root).render(
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
else throw Error("root is not exists");
2) API 응답 타입과 공통 유틸
백엔드 응답 포맷을 제네릭으로 캡슐화합니다. results에 실제 데이터를 담고, status 블록엔 상태 코드와 메시지를 둔 패턴입니다.
interface ResponseBody<R> {
status: number;
data: {
status: { message: string; error: boolean; code: number };
results: R;
};
}
쿼리 파라미터에서 undefined는 제외해 직렬화합니다.
const normalizeParams = (obj: Record<string, unknown> = {}) => {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined)
);
};
3) useApiQuery 훅 설계
반복되는 useQuery 호출을 감싸 다음을 표준화합니다.
- queryKey 규칙: ['page', url, id]
- 요청 메서드/바디: fetch + JSON 직렬화
- 공통 옵션: enabled, staleTime, refetchOnWindowFocus, refetchOnMount
- 에러 규약: 서버의 data.status.code === 9999일 때 네트워크 에러로 간주
API 표면 타입은 다음과 같습니다.
interface PageOpts {
url: string;
id: string;
method?: "get" | "post" | "put" | "delete";
params?: Record<string, unknown>;
enabled?: boolean;
staleTime?: number;
refetchOnWindowFocus?: boolean;
refetchOnMount?: boolean | "always";
}
구현 코드는 아래와 같습니다.
import { useQuery, QueryKey } from "@tanstack/react-query";
import { useRef } from "react";
import memoize from "lodash/memoize";
const getPageKey = (url: string, id: string) => ["page", url, id] as const;
export default function useApiQuery<R>({
url,
id,
method = "get",
params,
enabled = true,
staleTime = 30_000,
refetchOnWindowFocus = false,
refetchOnMount = true,
}: PageOpts) {
// lodash.memoize로 queryKey 생성 비용을 최소화
const memoPageKey = useRef(
memoize(getPageKey, (u, i) => `${u}|${i}`)
);
const qKey: QueryKey = memoPageKey.current(url, id);
const query = useQuery<ResponseBody<R>>({
queryKey: qKey,
enabled,
queryFn: async () => {
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const data = await response.json();
// 서버의 합의된 에러 코드 처리
if (data.status?.code === 9999) throw new Error("network_error");
return data;
},
staleTime,
refetchOnWindowFocus,
refetchOnMount,
});
return { query, qKey };
}
왜 queryKey를 메모이즈하나?
React Query는 queryKey의 동등성으로 캐시를 구분합니다. 렌더마다 새로운 배열 인스턴스를 만들면, key가 자주 바뀌는 것으로 오해할 수 있습니다. memoize로 동일 입력에 동일 참조를 보장해 불필요한 캐시 미스와 재요청을 줄입니다.
팁: 엄격히는 안정된 배열 생성만으로도 충분한 경우가 많습니다. 하지만 동적 URL/ID 조합이 잦고, 키 생성이 여러 곳에서 반복될 때 메모이즈가 실수를 줄여줍니다.
4) 실제 사용 예시
아래처럼 화면 컴포넌트에서 간단히 호출합니다.
const { query } = useApiQuery<{ data: Record<string, unknown>}>({
url: `/api/page/${id}`,
id,
});
useEffect(() => {
if (query.isSuccess && query.data) {
const data = query.data;
console.log(data);
}
}, [query.isSuccess, query.isRefetching]);
의존성 배열에 왜 isRefetching을?
- 첫 성공 이후 백그라운드 리패치가 완료될 때도 콜백을 태우고 싶다면 isRefetching을 함께 관찰하는 패턴이 유용합니다. 최초 성공만 처리하려면 isSuccess만 의존시키세요.
5) 실전에선 이렇게 튜닝합니다
- GET 요청의 body 금지
위 예시는 단순화를 위해 모든 메서드에 body를 실었습니다. 실제로는 메서드별로 분기하세요. - 서버 에러 규약 일원화
9999 같은 매직 넘버는 상수화하세요. - 쿼리 무효화/프리패치 조합
상세 페이지 진입 전에 queryClient.prefetchQuery로 미리 데이터를 불러오면 체감 속도가 좋아집니다. - 에러/로딩 UI 표준 컴포넌트화
각 화면에서 조건 분기하지 말고, 로딩/에러/빈 상태를 그려주는 공통 컴포넌트를 두면 생산성이 올라갑니다. - SSR/Hydration 고려
Next.js 등에서 서버 프리패치된 캐시를 클라이언트에 하이드레이션하려면 dehydrate/hydrate 패턴을 함께 설계하세요.
6) FAQ
Q. staleTime을 0으로 두면?
컴포넌트 마운트마다 바로 리패치합니다. 신선함을 공격적으로 유지할 때 사용하지만, 네트워크 비용이 커질 수 있습니다.
Q. refetchOnWindowFocus를 켜지 않은 이유는?
협업 툴이 아니라면 사용자 포커스 전환마다 요청이 발생하는 건 체감상 과한 경우가 많습니다. 데이터 민감도에 따라 화면 단위로 켜세요.
Q. queryKey에 객체를 넣어도 되나요?
네. 단, 구조적으로 안정적인 직렬화 가능 값을 쓰세요. JSON.stringify 비용과 참조 변경 이슈를 고려해야 합니다.
7) 마무리
이 글의 useApiQuery는 반복 로직 캡슐화와 실전 디폴트에 초점을 맞췄습니다. 팀의 에러 규약, 응답 포맷, SEO/SSR 요구사항에 따라 훅을 더 분리하거나 확장하면 됩니다. 작은 훅 하나가 뷰 코드의 노이즈를 크게 줄이고, React Query의 장점을 일관되게 누릴 수 있게 해줍니다.