실무에서 검색 기능을 구현할 때 이런 고민을 해보신 적 있으신가요?
"이건 데이터 조회니까 useQuery를 써야 하나? 아니면 버튼 클릭 시에만 실행되어야 하니까 useMutation을 써야 하나?"
더 나아가 복잡한 필터 조건을 가진 검색 API는 GET이 아닌 POST로 요청해야 하는 경우도 많습니다. REST 원칙상 조회는 GET을 사용해야 하지만, 실무에서는 다음과 같은 이유로 POST를 사용합니다:
이런 상황에서 useQuery와 useMutation 중 어느 것을 선택해도 아쉬운 점이 생깁니다. 이 글에서는 두 가지의 장점을 결합한 useQueryMutation 패턴을 소개합니다.
React Query의 useQuery는 데이터 조회에 최적화된 훅입니다. 하지만 검색 기능 구현 시 다음과 같은 문제가 있습니다:
1. 초기 렌더링 시 자동 실행
// 문제: 컴포넌트가 마운트되자마자 API 호출됨
const { data } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
});
사용자가 검색 버튼을 클릭하지 않았는데도 API가 호출됩니다.
2. enabled 옵션 사용 시 코드 복잡도 증가
const [shouldFetch, setShouldFetch] = useState(false);
const { data } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
enabled: shouldFetch, // 추가 상태 관리 필요
});
const handleSearch = () => {
setShouldFetch(true); // 상태 변경으로 쿼리 실행
};
검색을 제어하기 위해 별도의 상태를 관리해야 하고, 코드가 장황해집니다.
3. queryKey 의존성 변경 시 의도치 않은 재실행
const { data } = useQuery({
queryKey: ['products', filters], // filters가 변경되면 자동 재실행
queryFn: () => fetchProducts(filters),
enabled: shouldFetch,
});
// 문제: 필터를 변경하는 순간 API가 호출됨 (검색 버튼을 누르지 않았는데도)
const handleFilterChange = (newFilters) => {
setFilters(newFilters); // queryKey 변경 → 자동 refetch
};
반대로 useMutation을 사용하면 어떨까요?
const { mutate, data } = useMutation({
mutationFn: (filters: ProductFilters) => fetchProducts(filters),
});
const handleSearch = () => {
mutate(filters); // 수동 실행 ✅
};
수동 실행은 가능하지만, 다음과 같은 Query의 강력한 기능들을 사용할 수 없습니다:
useQueryMutation은 Query의 캐싱/최적화 기능과 Mutation의 수동 실행을 결합한 커스텀 훅입니다.
useState로 요청 파라미터를 내부에서 관리enabled: !!variables로 variables가 설정될 때만 쿼리 실행mutate() 함수로 수동 실행import {
keepPreviousData,
useQuery,
UseQueryOptions,
} from '@tanstack/react-query';
import { useCallback, useState } from 'react';
interface UseQueryMutationOptions<TData, TError = Error, TVariables = void> {
queryKey: unknown[];
queryFn: (variables: TVariables) => Promise<ApiResponse<TData>>;
queryOptions?: Omit<
UseQueryOptions<ApiResponse<TData>, TError, ApiResponse<TData>, readonly unknown[]>,
'queryKey' | 'queryFn'
>;
}
export function useQueryMutation<
TData = unknown,
TError = Error,
TVariables extends Record<string, any> | null | undefined = Record<string, any>,
>({ queryKey, queryFn, queryOptions }: UseQueryMutationOptions<TData, TError, TVariables>) {
// 1. 요청 파라미터를 내부 상태로 관리
const [variables, setVariables] = useState<TVariables | null>(null);
// 2. useQuery 래핑
const query = useQuery<ApiResponse<TData>, TError, ApiResponse<TData>, readonly unknown[]>({
// variables를 queryKey에 포함하여 검색 조건별 캐싱
queryKey: generateQueryKeysFromUrl(queryKey + createQueryString(variables)),
queryFn: () => queryFn(variables!),
// 핵심: variables가 설정될 때만 실행
enabled: !!variables,
// 기본 최적화 설정
retry: 1,
refetchOnMount: false,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
gcTime: 1000 * 60 * 10, // 10분 후 메모리에서 정리
placeholderData: keepPreviousData, // 로딩 중 이전 데이터 유지
// 사용자 정의 옵션으로 오버라이드 가능
...queryOptions,
});
// 3. Mutation처럼 사용할 수 있는 mutate 함수
const mutate = useCallback((newVariables: TVariables) => {
setVariables(newVariables); // 상태 변경 → useQuery 자동 실행
}, []);
// 4. Query의 모든 상태 + mutate 함수 반환
return {
...query,
mutate,
};
}
variables가 null이므로 enabled: false → 쿼리 실행 안 됨mutate(params) 호출 → variables 상태 업데이트variables가 설정되어 enabled: true → 쿼리 자동 실행queryKey에 variables 포함 → 같은 조건 재검색 시 캐시 사용keepPreviousData로 로딩 중에도 이전 데이터 유지실무에서는 백엔드 응답 구조가 정형화되어 있는 경우가 많습니다. 우리 프로젝트에서는 다음과 같은 응답 구조를 사용합니다:
export interface ApiResponse<T> {
status: 'success' | 'fail';
code: string;
message: string;
token: string;
data: T; // 실제 응답 데이터
}
useQueryMutation은 이러한 래핑된 응답 구조를 제네릭으로 처리하여, 타입 안정성을 유지하면서도 일관된 API를 제공합니다.
프로젝트에서 실제로 사용하고 있는 패턴들을 소개합니다.
먼저 타입을 정의합니다:
// types.ts
export interface ProductListRequest {
pageIndex: number;
pageCount: number;
categoryL?: string;
categoryM?: string;
categoryS?: string;
keyword?: string;
}
export interface ProductItem {
productId: string;
productName: string;
category: string;
price: number;
}
export interface ProductListResponse {
productItemList: ProductItem[];
totalCount: number;
}
API 함수를 작성합니다:
// useProductList.ts
import { ApiResponse, axiosInstance } from '@/shared/api';
import { API_ENDPOINTS } from '@/shared/constants';
import { useQueryMutation } from '@/shared/hooks';
import { ProductListRequest, ProductListResponse } from './types';
const fetchProductList = async ({ pageCount = 30, ...body }: ProductListRequest) => {
const { data } = await axiosInstance.post<ApiResponse<ProductListResponse>>(
API_ENDPOINTS.PRODUCT.LIST, // '/v3/product/list'
{ ...body, pageCount },
);
return data; // ApiResponse<ProductListResponse> 반환
};
export const useProductList = () => {
return useQueryMutation<ProductListResponse, Error, ProductListRequest>({
queryKey: [API_ENDPOINTS.PRODUCT.LIST],
queryFn: fetchProductList,
});
};
import { useProductList } from '@/features/product/api';
const ProductSearchModal = () => {
const {
mutate: searchProducts,
data,
isPending,
isError
} = useProductList();
const [filters, setFilters] = useState({
categoryL: '',
keyword: '',
});
const handleSearch = () => {
searchProducts({
pageIndex: 1,
pageCount: 30,
categoryL: filters.categoryL,
keyword: filters.keyword,
});
};
const handlePageChange = (page: number) => {
searchProducts({
pageIndex: page,
pageCount: 30,
categoryL: filters.categoryL,
keyword: filters.keyword,
});
};
return (
<div>
<input
value={filters.keyword}
onChange={(e) => setFilters({ ...filters, keyword: e.target.value })}
/>
<button onClick={handleSearch}>검색</button>
<Spin spinning={isPending}>
{data?.data?.productItemList.map((product) => (
<ProductCard key={product.productId} product={product} />
))}
</Spin>
<Pagination
total={data?.data?.totalCount || 0}
onChange={handlePageChange}
/>
</div>
);
};
장점:
mutate() 호출keepPreviousData로 이전 데이터 유지 (깜빡임 없음)컴포넌트가 마운트될 때 기본값으로 자동 검색이 필요한 경우:
// useAlarmList.ts
export const useAlarmList = (params?: { pageCount?: number; state?: string }) => {
const queryMutate = useQueryMutation<AlarmListResponse, Error, AlarmListRequest>({
queryKey: [API_ENDPOINTS.HISTORY.ALARM.LIST],
queryFn: fetchAlarmList,
});
// 초기 로딩: 기본값으로 자동 실행
useEffect(() => {
const defaultFormData = getDefaultFormValues(); // { state: 'all', pageIndex: 1 }
const requestData = {
...defaultFormData,
pageIndex: 1,
pageCount: params?.pageCount ?? 20,
state: params?.state ?? null,
};
queryMutate.mutate(requestData);
}, []); // 마운트 시 한 번만
return queryMutate;
};
사용:
const AlarmListPage = () => {
const { data, isPending, mutate } = useAlarmList({ pageCount: 30 });
// 마운트 시 자동으로 pageCount: 30으로 알림 조회
// 이후에는 mutate()로 수동 제어 가능
return (
<div>
<AlarmList items={data?.data?.alarmList || []} />
<button onClick={() => mutate({ state: 'unread', pageIndex: 1, pageCount: 30 })}>
읽지 않은 알림만 보기
</button>
</div>
);
};
데이터 특성에 따라 캐시 전략을 다르게 설정할 수 있습니다:
실시간 데이터 (캐시 사용 안 함)
// useAssetList.ts
export const useAssetListQuery = () => {
return useQueryMutation<AssetListResponse, Error, AssetListRequest>({
queryKey: [API_ENDPOINTS.ASSET.LIST],
queryFn: fetchAssetList,
queryOptions: {
staleTime: 0, // 항상 stale 상태 (즉시 refetch)
gcTime: 0, // 즉시 메모리에서 제거
},
});
};
정적 데이터 (캐시 적극 활용)
// usePriceOverviewList.ts
export const usePriceOverviewList = () => {
return useQueryMutation<PriceOverviewListResponse, Error, PriceOverviewListRequest>({
queryKey: [API_ENDPOINTS.REPORT.PRICE_OVERVIEW.LIST],
queryFn: fetchPriceOverviewList,
queryOptions: {
staleTime: 5 * 60 * 1000, // 5분간 캐시 유지
gcTime: 10 * 60 * 1000, // 10분 후 메모리에서 제거
retry: 2, // 실패 시 2번 재시도
},
});
};
React Hook Form과 함께 사용하는 패턴:
const AssetsView = () => {
const { control, watch } = useFormContext<FilterFormData>();
const { mutate: fetchAssets, data, isPending } = useAssetListQuery();
const [keyword, filters, currentPage, pageCount] = watch([
'keyword',
'filters',
'currentPage',
'pageCount',
]);
// 폼 상태를 API 요청 파라미터로 변환
const queryParams = useMemo(() => {
if (!filters || currentPage === undefined) {
return null;
}
return {
keyword: keyword || '',
startDate: filters.startDate,
endDate: filters.endDate,
siteList: filters.siteList,
categoryList: filters.categoryList,
pageIndex: currentPage,
pageCount: pageCount || 20,
};
}, [keyword, filters, currentPage, pageCount]);
// 폼 상태 변경 시 자동 검색
useEffect(() => {
if (!queryParams) return;
fetchAssets(queryParams);
}, [queryParams, fetchAssets]);
return (
<div>
<FilterForm />
<Spin spinning={isPending}>
<AssetList items={data?.data?.assetInfoList || []} />
</Spin>
</div>
);
};
useEffect로 초기 로딩 처리:
useEffect(() => {
mutate(defaultParams);
}, []);
캐시가 오히려 문제가 되는 경우 staleTime: 0 설정:
queryOptions: {
staleTime: 0,
gcTime: 0,
}
불필요한 캐시 축적 방지를 위해 적절한 gcTime 설정:
queryOptions: {
gcTime: 1000 * 60 * 5, // 5분 후 정리
}
제네릭 타입을 명시적으로 정의하여 타입 추론 오류 방지:
useQueryMutation<
ResponseType, // TData
Error, // TError
RequestType // TVariables
>({ ... })
useQueryMutation은 실무의 복잡한 검색/필터 요구사항을 해결하는 실용적인 패턴입니다.
언제 사용하면 좋을까요?:
React Query를 사용하면서 검색 기능 구현에 어려움을 겪고 계셨다면, useQueryMutation 패턴을 적용해보시기 바랍니다. 코드가 간결해지고, 성능이 향상되며, 사용자 경험도 개선될 것입니다.