내가 tanstack-query를 바꾼 이유
custom hook 무조건 좋은건 아니다.
보통 리액트 쿼리를 사용할 때에는, 아래와 같은 방식을 사용하였었다.
import { useQuery } from "@tanstack/react-query";
import { getData } from "../Services/http/getData";
import { District } from "../Types/CityAndDistrict";
import { ApiResponse } from "../Types/ResponseType";
function useDistrictQuery(cityId: string) {
return useQuery({
queryKey: ["district", cityId],
queryFn: ({ signal }) =>
getData<ApiResponse<District[]>>(`districts/${cityId}`, signal),
});
}
export default useDistrictQuery;
부끄럽지만 1년전의 코드이다.
무분별한 export default에 추상화는 되어있지 않는 코드.
단순히 tanstack-query를 커스텀 훅으로 추출한 결과이다.
코드의 문제점은 너무나도 많다.
- queryKey 관리의 어려움
- 프로젝트의 크기가 커질수록 queryKey 관리의 비용이 기하급수적으로 증가한다.
- queryFn에서 타입 관리에 대한 어려움
- single soure of the truth.
- 어디에 위치하여야 하는가.
- single soure of the truth.
- api Endpoint 유지보수 및 관리에 대한 어려움
- 기능이 개발하는 동안, endpoint가 자잘하게 바뀌는 경우도 많다.
- endpoint의 추적이 쉽지 않다.
- useQuery custom hooks 자체에 대한 어려움.
- 이 custom hook을 못찾으면 결국 똑같은 코드를 만들어내고 있는 당신을 발견할 수 있다.
이 수많은 어려움을 하나씩 이겨내보자.
useQuery를 어떻게 사용할 것인가
근본적인 원인을 찾아보면 useQuery를 추상화가 아니라 추출한 것이 문제이다.
useQuery는 단순하게 options를 주입받는 방식이기 때문에, options를 만들고 사용하는 곳에서 그 options을 DI하는 방식이 더 나은 방향이라고 생각하였다.
tanstack-query에서는 이미 queryOptions라는 좋은 함수가 이를 지원한다.
단순히 useQuery만이 아니라, useSuspenseQuery 또한 queryOptions를 사용하기에, 본 글에서는 이 queryOptions를 사용하는 방향으로, useMutation도 mutationOptions를 사용할 것이다.
가장 먼저 캐싱의 기반이 되는 queryKey를 정리하는 것부터 시작하자.
queryKey 관리
대규모 프로젝트에서 캐싱을 사용한다면 캐싱 키 관리 또한 비용이다.
queryKey를 관리하는 방식은 알고리즘 문제풀이가 아니기에 정답이란 것이 존재하지 않지만, 정도는 존재한다.
재귀적으로 queryKey를 생성
TanStack Query는 기본적으로 queryKey를 “앞에서부터 순차적으로 비교하는 prefix matching”으로 처리한다.
따라서 invalidateQueries(["district"])는 ["district"], ["district", cityId], ["district", "list", params] 등, 해당 prefix로 시작하는 모든 쿼리가 무효화된다.
exact:true를 사용하면 이 동작을 비활성화하고, queryKey가 완전히 일치하는 쿼리만 대상으로 제한할 수 있다.
도메인당 queryKey에 한가지 스탠스를 정하고 재귀적으로 확장시키는 queryKey팩토리 패턴을 사용하자.
const DISTRICT = ["district"] as const;
const districtQueryKeys = {
index: DISTRICT,
getCityIndex: () => [...districtQueryKeys.index, "city"],
getCityById: (cityId: City["id"]) => [
...districtQueryKeys.getCityIndex(),
cityId,
],
getCityAll: () => [...districtQueryKeys.getCityIndex(), "all"],
getAddressIndex: () => [...districtQueryKeys.index, "address"],
getAddressByKeyword: (keyword: SearchParams["keyword"]) => [
...districtQueryKeys.getAddressIndex(),
"이건 갓 만든 예시 코드",
keyword,
],
} as const;
다음과 같이 재귀적으로 queryKey를 만들면, prefix matching이기에 tree를 만든다고 생각하고 key를 만들면 효율적으로 관리할 수 있다.
하지만 queryKey만 정리된다고 해서 모든 문제가 해결되지는 않는다.
캐싱은 안정되었지만, 정작 API endpoint가 여러 곳에서 중복되고 흩어져 있는 문제가 남아 있었다.
API Endpoint 모듈화
기존에는 custom hook마다 endpoint의 문자열이 하드코딩되어 있었다. 그래서 백엔드가 endpoint를 바꾸면 최소 5개~10개 이상의 파일을 수정해야하는 대참사가 발생하였다.
실제로 일할때 endpoint의 수정이 빈번하기에, custom hook에 하드코딩된 endpoint는 은닉시켜야한다.
정리하자면, 내부 url 구조를 외부로부터 숨기고, 변경이 발생해도 한 파일만 고치게 하고, 외부에서는 일관된 방식으로만 접근하게 한다.
const districtBaseEndpoint = "/districts" as const;
export const districtEndpoint = {
getByCityId: (cityId: City["id"]) => `${districtBaseEndpoint}/${cityId}`,
getCityAll: (keyword: SearchParams["keyword"]) =>
`${districtBaseEndpoint}/겟시티올`,
};
외부에서 사용한다면 property를 접근하는 방식으로 호출한다.
districtEndpoint.getByCityId(cityId);
여태까지 했던 것을 되짚어보자.
useQuery에서 options를 분리시키면 자연스레 남는 것이 options인데, 이 options에서 대표적으로 queryKey, queryFn를 또 분리시킨다. 그리고 이 queryFn에서 endpoint를 분리시킨다.
관심사의 분리에 중점을 두다 보니 자연스레 캡슐화 및 파편화가 일어난 것이다.
이제 queryOptions를 통해 지금까지의 것들을 options로 만들 것이다.
queryOptions
queryOptions에서는 분리된 파편들을 단순히 조립한다.
import { DefaultError, queryOptions } from "@tanstack/react-query";
export function districtQueryOptions() {
return queryOptions<Slice<District[]>, DefaultError, District[]>({
queryKey: TANSTACK_QUERY_KEY.district.getCityAll(),
queryFn: async ({ signal }) =>
http.base.get(districtEndpoint.getCityAll, { signal }),
select: (data) => data.content,
});
}
호출부에서는 다음과 같이 사용한다.
const { data } = useQuery(districtQueryOptions());
const { data } = useSuspenseQuery(districtQueryOptions());
보일러플레이트는 늘었지만, 데이터 규칙은 더 이상 훅 내부에서 즉흥적으로 정의되지 않는다.
도메인 레이어에서 데이터의 규칙을 결정짓는다.
마지막으로
mutationOptions는 말 그대로 같은 패턴을 useMutation에도 적용하는 정도고 구조는 동일하기 때문에 생략했다.
타입 관리도 이 글에서 작성할려다가 너무 글이 난잡해져서 제외하였고, 아마 타입 관리 및 파편화된 파일들의 관리는 다음 글에서 single source of the truth를 주제 삼아 작성할 예정.
'프론트엔드 관련 > 기초' 카테고리의 다른 글
| Zustand 역설계 (0) | 2025.12.14 |
|---|---|
| Execution Context를 통한 Closure의 이해 (1) | 2025.12.11 |
| React Router Path AutoComplete with type-safety (4) | 2025.04.10 |
| [React] http Typescript axios(feat. interceptor 에러 핸들링) (0) | 2025.03.30 |
| React로 이미지 업로드 (with Blob, FileReader) (1) | 2024.07.23 |