개발자
류준열

queryKey 자동생성하는 useQuery

내가 입사하자마자 넘겨받은 코드는 추상화없이 모든 코드가 Page.tsx에 작성되어 있는 코드이다.
기능개발 중에 틈틈히 관심사분리, 추상화 등의 리팩토링을 하고 있는데 그 와중 커스텀 useQuery를 만든 이유는 다음과 같다.

  1. 모듈화를 안해서 중복코드가 발생
  2. 중복코드임에도 쿼리키가 달라서 캐시적용이 안되고 있음

queryKey 자동생성 기능

결론부터 말하면 다음과 같다. 예시

  1. queryKey 주입하면 주입한 queryKey 사용
  2. queryKey 따로 주입하지 않으면 규칙에 따라 queryKey 자동생성

queryKey 관리가 중요한 이유

tanstack-query는 queryKey를 유니크 삼아서 동일한 queryKey를 가진 GET 요청이 실행되었을때 네트워크 요청을 보내지 않고 cache를 가져온다.(stateTime, gcTime은 이 글에서 다루지 않음)

queryKey를 적절히 사용하지 못하면 동일 데이터 조회에도 매번 network 요청을 보낼 수 있고, 때로는 갱신되어야 할 데이터가 갱신되지 않을 수도 있다.

이러한 문제를 해결하기 위해 전 회사에서는 동료들과 queryKey 작성 규칙을 만들었었다.

queryKey를 자동생성하여 개발자들이 queryKey를 신경쓰지 않게 하기

그런데 특정한 규칙에 따라 queryKey가 자동생성된다면, 개발을 하면서 queryKey 작성을 신경쓰지 않아도 된다.

우리 팀은 스웨거-openAPI에서 만들어진 메서드, 스키마 타입을 이용하여 프론트-백 통신을 하기 때문에 동일 api를 사용할 경우 항상 동일 메서드, 동일 파라미터이다.

우리가 사용하는 queryFn은 다음과 같다.

import { actionLogsApi, userApi, userGroupApi } from "@/client/api";

queryFn: async () => await userApi.userRetrieve("me"),
queryFn: async () => await userGroupApi.userGroupGroupsList(6, 1000),
queryFn: async ()=> await actionLogsApi.actionLogsList(1000, 30, 5);

위와 같은 queryFn을 문자열로 변경하고 정규표현식을 활용하여 queryName, queryParams 로 분리 하였다.

const extractQueryDetails = (queryFn: Function) => {
  const fnString = queryFn.toString();
  const fnNameMatch = fnString.match(/await\s+(\w+\.\w+\.\w+)/);
  const fnParamsMatch = fnString.match(/\(([^)]+)\)/);

  const queryName = fnNameMatch ? fnNameMatch[1] : "";
  const queryParams = fnParamsMatch
    ? fnParamsMatch[1].split(",").map((param) => param.trim().replace(/['"]/g, ""))
    : [];

  return { queryName, queryParams };
};

const generateQueryKey = <TQueryFnData>(queryFn: QueryFunction<TQueryFnData>) => {
  const { queryName, queryParams } = extractQueryDetails(queryFn);
  return [queryName, ...queryParams];
};

console.log('쿼리키: 'generateQueryKey(async () => await userGroupApi.userRetrieve("me") ))
// 쿼리키: ['d.BG.userRetrieve', 'me']

console.log('쿼리키: 'generateQueryKey(async () => await actionLogsApi.actionLogsList(1000, 30, "5")))
// 쿼리키: ['d.gy.actionLogsList', '1e3', '30', '5']

그리고 자동 생성되는 queryKey를 이용하지 않고 특수한 queryKey를 넣고 싶을 수도 있기 때문에 queryKey를 옵셔널로 받을 수 있게 하였다.

interface UseQueryParameterType<TQueryFnData, TError, TData>
  extends Omit<UseQueryOptions<TQueryFnData, TError, TData>, "queryKey" | "queryFn"> {
  queryKey?: string[];
  queryFn: QueryFunction<TQueryFnData>;
  onSuccess?: (data?: TData) => void;
}

export const useQuery = <TQueryFnData, TError extends Error, TData>({
  queryKey,
  queryFn,
  ...
}: UseQueryParameterType<TQueryFnData, TError, TData>) => {
  const generatedQueryKey = queryKey || generateQueryKey(queryFn);

  const customQuery = createQuery({
    queryKey: generatedQueryKey,
    queryFn,
    ...
  });

  ...

  return customQuery;
};

자동생성되는 queryKey

위 작업을 통해 다음과 같이 queryKey 자동생성기능이 완성되었다.

const { data } = useQuery({
    queryKey: ['user','me']
    queryFn: async () => await userApi.userRetrieve("me"),
    ...
})
// 쿼리키: ['user', 'me']

const { data } = useQuery({
    queryFn: async () => await userApi.userRetrieve("me"),
    ...
})
// 쿼리키: ['d.BG.userRetrieve', 'me']

const { data } = useQuery({
    queryFn: async () => await actionLogsApi.actionLogsList(1000, 30, "5"),
    ...
})
// 쿼리키: ['d.gy.actionLogsList', '1e3', '30', '5']

v5에서 사라진 onSuccess 추가

tanstack-query v5에서 onSuccess가 사라진 이유

tanstack v5에서 useQuery에 onSuccess가 사라졌다.(v5 공식문서)

캐시가 작동하는 경우에는 fetch를 안하기 때문에 onSuccess 가 작동하지 않는데, 개발자들은 캐시든 fetch든 데이터를 조회하면 onSuccess가 작동 할 것이라고 기대하기 때문이다. 이는 개발자가 디버깅하기 힘든 버그라고 생각해서 v5에서 onSuccess가 사라졌다.

그럼에도 불구하고 onSuccess가 있는게 편리하다.

하지만 onSuccess가 있는게 개발할 때 편리하다. 그래서 onSuccess가 캐시, fetch 무관하게 data를 호출할때마다 실행되게 하고 싶었다.

어느 블로그에서는 useEffect를 만들어서 data 조회시 특정 콜백이 실행되게 만들라고 하고 있다.

위 방법을 써서 custom useQuery안에 onSuccess 콜백을 주입하면 useEffect를 통해 data가 호출될 때 마다 캐시, fetch 무관하게 onSuccess가 실행되게 하였다.


const { data: user } = useQuery({
    queryFn: async () => await userApi.userRetrieve("me"),
    select: ({ data }) => data,
    onSuccess: (data) => {
      data && setProfile(data);
    },
  });


interface UseQueryParameterType<TQueryFnData, TError, TData>
  extends Omit<UseQueryOptions<TQueryFnData, TError, TData>, "queryKey" | "queryFn"> {
  queryKey?: string[];
  queryFn: QueryFunction<TQueryFnData>;
  onSuccess?: (data?: TData) => void;
}

export const useQuery = <TQueryFnData, TError extends Error, TData>({...,onSuccess,...}: : UseQueryParameterType<TQueryFnData, TError, TData>) => {
  const customQuery = createQuery({
    queryKey: generatedQueryKey,
    queryFn,
    ...options,
  });

  useEffect(() => {
    if (customQuery.data && onSuccess) {
      onSuccess(customQuery.data);
    }
  }, [customQuery.data, onSuccess]);

  return customQuery;
};

전체 코드

"use client";

import { useQuery as createQuery, UseQueryOptions, QueryFunction } from "@tanstack/react-query";
import { useEffect } from "react";

interface UseQueryParameterType<TQueryFnData, TError, TData>
  extends Omit<UseQueryOptions<TQueryFnData, TError, TData>, "queryKey" | "queryFn"> {
  queryKey?: string[];
  queryFn: QueryFunction<TQueryFnData>;
  onSuccess?: (data?: TData) => void;
}

const extractQueryDetails = (queryFn: Function) => {
  const fnString = queryFn.toString();
  const fnNameMatch = fnString.match(/await\s+(\w+\.\w+\.\w+)/);
  const fnParamsMatch = fnString.match(/\(([^)]+)\)/);

  const queryName = fnNameMatch ? fnNameMatch[1] : "";
  const queryParams = fnParamsMatch
    ? fnParamsMatch[1].split(",").map((param) => param.trim().replace(/['"]/g, ""))
    : [];

  return { queryName, queryParams };
};

const generateQueryKey = <TQueryFnData>(queryFn: QueryFunction<TQueryFnData>) => {
  const { queryName, queryParams } = extractQueryDetails(queryFn);
  return [queryName, ...queryParams];
};

export const useQuery = <TQueryFnData, TError extends Error, TData>({
  queryKey,
  queryFn,
  onSuccess,
  ...options
}: UseQueryParameterType<TQueryFnData, TError, TData>) => {
  const generatedQueryKey = queryKey || generateQueryKey(queryFn);

  const customQuery = createQuery({
    queryKey: generatedQueryKey,
    queryFn,
    ...options,
  });

  useEffect(() => {
    if (customQuery.data && onSuccess) {
      onSuccess(customQuery.data);
    }
  }, [customQuery.data, onSuccess]);

  return customQuery;
};