개발자
류준열
queryKey 자동생성하는 useQuery
내가 입사하자마자 넘겨받은 코드는 추상화없이 모든 코드가 Page.tsx에 작성되어 있는 코드이다.
기능개발 중에 틈틈히 관심사분리, 추상화 등의 리팩토링을 하고 있는데 그 와중 커스텀 useQuery를 만든 이유는 다음과 같다.
- 모듈화를 안해서 중복코드가 발생
- 중복코드임에도 쿼리키가 달라서 캐시적용이 안되고 있음
queryKey 자동생성 기능
결론부터 말하면 다음과 같다. 예시
- queryKey 주입하면 주입한 queryKey 사용
- 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;
};