[React Query] useQueryMutation 패턴: Mutation처럼 쓰고 Query처럼 캐싱하기

장성우·2025년 11월 25일

들어가며

실무에서 검색 기능을 구현할 때 이런 고민을 해보신 적 있으신가요?

"이건 데이터 조회니까 useQuery를 써야 하나? 아니면 버튼 클릭 시에만 실행되어야 하니까 useMutation을 써야 하나?"

더 나아가 복잡한 필터 조건을 가진 검색 API는 GET이 아닌 POST로 요청해야 하는 경우도 많습니다. REST 원칙상 조회는 GET을 사용해야 하지만, 실무에서는 다음과 같은 이유로 POST를 사용합니다:

  • URL 길이 제한 (2048자)
  • 복잡한 객체 구조를 query string으로 변환하는 어려움
  • 배열, 중첩 객체 등 복잡한 필터 조건 전달의 불편함
  • 보안상 민감한 검색 조건을 URL에 노출하지 않기 위함

이런 상황에서 useQueryuseMutation 중 어느 것을 선택해도 아쉬운 점이 생깁니다. 이 글에서는 두 가지의 장점을 결합한 useQueryMutation 패턴을 소개합니다.

문제 상황

useQuery의 한계

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의 한계

반대로 useMutation을 사용하면 어떨까요?

const { mutate, data } = useMutation({
  mutationFn: (filters: ProductFilters) => fetchProducts(filters),
});

const handleSearch = () => {
  mutate(filters); // 수동 실행 ✅
};

수동 실행은 가능하지만, 다음과 같은 Query의 강력한 기능들을 사용할 수 없습니다:

  • 캐싱: 같은 조건으로 재검색 시 캐시된 데이터 사용 불가
  • keepPreviousData: 새 데이터 로딩 중 이전 데이터 유지 불가 (화면 깜빡임)
  • staleTime: 데이터 신선도 관리 불가
  • gcTime: 메모리 효율적인 캐시 정리 불가
  • refetchOnMount, refetchOnWindowFocus: 세밀한 refetch 제어 불가

해결 방법: useQueryMutation

useQueryMutation은 Query의 캐싱/최적화 기능과 Mutation의 수동 실행을 결합한 커스텀 훅입니다.

핵심 아이디어

  1. 내부 상태로 variables 관리: useState로 요청 파라미터를 내부에서 관리
  2. Lazy Execution: enabled: !!variables로 variables가 설정될 때만 쿼리 실행
  3. Dynamic Query Key: variables를 queryKey에 포함하여 검색 조건별 캐싱
  4. Mutation-like API: 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,
  };
}

동작 원리

  1. 초기 상태: variablesnull이므로 enabled: false → 쿼리 실행 안 됨
  2. mutate 호출: mutate(params) 호출 → variables 상태 업데이트
  3. 자동 실행: variables가 설정되어 enabled: true → 쿼리 자동 실행
  4. 캐싱: queryKey에 variables 포함 → 같은 조건 재검색 시 캐시 사용
  5. UX 최적화: keepPreviousData로 로딩 중에도 이전 데이터 유지

ApiResponse 패턴

실무에서는 백엔드 응답 구조가 정형화되어 있는 경우가 많습니다. 우리 프로젝트에서는 다음과 같은 응답 구조를 사용합니다:

export interface ApiResponse<T> {
  status: 'success' | 'fail';
  code: string;
  message: string;
  token: string;
  data: T;  // 실제 응답 데이터
}

useQueryMutation은 이러한 래핑된 응답 구조를 제네릭으로 처리하여, 타입 안정성을 유지하면서도 일관된 API를 제공합니다.

실제 사용 예시

프로젝트에서 실제로 사용하고 있는 패턴들을 소개합니다.

1. 기본 패턴: 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,
  });
};

2. 컴포넌트에서 사용

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

장점:

  • 필터를 변경해도 즉시 API 호출 안 됨 (useQuery의 문제 해결)
  • 검색 버튼 클릭 시에만 mutate() 호출
  • 같은 조건으로 재검색 시 캐시된 데이터 사용
  • 페이지 변경 시 keepPreviousData로 이전 데이터 유지 (깜빡임 없음)

3. 고급 패턴: 초기 로딩 자동화

컴포넌트가 마운트될 때 기본값으로 자동 검색이 필요한 경우:

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

4. 캐시 전략 커스터마이징

데이터 특성에 따라 캐시 전략을 다르게 설정할 수 있습니다:

실시간 데이터 (캐시 사용 안 함)

// 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번 재시도
    },
  });
};

5. 복잡한 폼 상태와 통합

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

주의사항

1. 초기 렌더링 시 자동 실행이 필요한 경우

useEffect로 초기 로딩 처리:

useEffect(() => {
  mutate(defaultParams);
}, []);

2. 실시간 데이터 처리

캐시가 오히려 문제가 되는 경우 staleTime: 0 설정:

queryOptions: {
  staleTime: 0,
  gcTime: 0,
}

3. 메모리 관리

불필요한 캐시 축적 방지를 위해 적절한 gcTime 설정:

queryOptions: {
  gcTime: 1000 * 60 * 5, // 5분 후 정리
}

4. 타입 안정성

제네릭 타입을 명시적으로 정의하여 타입 추론 오류 방지:

useQueryMutation<
  ResponseType,  // TData
  Error,         // TError
  RequestType    // TVariables
>({ ... })

결론

useQueryMutation은 실무의 복잡한 검색/필터 요구사항을 해결하는 실용적인 패턴입니다.

언제 사용하면 좋을까요?:

  • ✅ 검색/필터 기능
  • ✅ POST로 데이터 조회하는 API
  • ✅ 사용자 액션 기반 데이터 로딩
  • ✅ 페이지네이션 + 필터 조합
  • ❌ 단순 GET 조회 (useQuery 사용)
  • ❌ 실제 데이터 변경 (useMutation 사용)

React Query를 사용하면서 검색 기능 구현에 어려움을 겪고 계셨다면, useQueryMutation 패턴을 적용해보시기 바랍니다. 코드가 간결해지고, 성능이 향상되며, 사용자 경험도 개선될 것입니다.

0개의 댓글