현재 팀 내에서 단일책임원칙에 준수하는 API 함수를 모듈화 해놓은 것을, queryFn으로 호출하는 useQuery를 개별로 커스텀 훅 화하여 사용하고 있습니다.
제가 회사에 처음 입사했을 때, 서버 Response가 타이핑이 안되고 쿼리키 관리가 어려워, invalidate, refetch 등, 쿼리키를 요구하는 queryClient의 메서드들을 활용하기가 어려워 제안한 방법입니다.
근데 프로젝트 규모가 커질 수록, API 함수도 하나씩 늘어만 가는데, 동시에 useQuery 훅도 같이 생성하려하니, 소스 파일이 비정상적으로 늘어나 추후에 관리가 어렵겠다는 생각이 들어 하나 제안을 하게 되었습니다.
프로 님, 지금 프로젝트 내에서 사용하고 있는 API가 80개 이상이 넘어가는데 뭔가 더 늘어난다고 하면 관리가 힘들어질 것 같은데, useQuery훅을 타이핑 되는 지금보다 좀 낮은 레벨로 추상화해보는 건 어떨까요?
다행히도 팀원분도 같은 생각을 하고 계셨습니다.
AS-IS
예전 진행했던 사이드 프로젝트에서도 같은 포맷을 사용하기 있었기에 예시코드를 가져와봤습니다.
- /api/comments/commentListGetFetch.ts
import { apiFetch } from '../common';
import { ResponseModel } from '../model';
export interface CommentListGetFetchParams {
/**
* 게시물 식별값
*/
postId: string;
}
export interface CommentListResponse extends ResponseModel {
/**
* 댓글 식별값
*/
id: number;
/**
* 프로필 이미지
*/
imageUrl: string;
/**
* 게시물 식별값
*/
postId: number;
/**
* 댓글 작성자 식별값
*/
memberId: number;
/**
* 댓글 단 사람
*/
memberName: string;
/**
* 댓글
*/
content: string;
/**
* 댓글 작성 및 수정 시간
*/
modifiedAt: string;
/**
* 좋아요 수
*/
likeCount: number;
/**
* 게시글 제목
*/
postTitle: string;
/**
* 게시글 작성자 닉네임
*/
postMemberName: string;
/**
* 게시글 종류
*/
postType: 'RECIPE' | 'ROOM' | 'TALK' | 'USEDTRADE';
}
/**
* 댓글 조회
*/
export const commentListGetFetch = ({ postId }: CommentListGetFetchParams) =>
apiFetch.get<CommentListResponse[]>(`/comments/${postId}`);
- /services/comments/useCommentListQuery.ts
import { useQuery } from '@tanstack/react-query';
import { commentListGetFetch, CommentListGetFetchParams, CommentListResponse } from '@/api/comment/commentListGetFetch';
/**
* 댓글 리스트 쿼리키
*/
export const COMMENT_LIST_QUERY_KEY = '@comment-list' as const;
/**
* 댓글 조회
*/
export const useCommentListQuery = ({ postId }: CommentListGetFetchParams) =>
useQuery({
queryKey: [COMMENT_LIST_QUERY_KEY, postId],
queryFn: async () => {
const res = await commentListGetFetch({ postId });
return res.data;
},
staleTime: 5000,
});
위와 같은 형식으로 사용하고 있었기에, 만약, 호출해야하는 API 함수가 500개가 넘어간다면,, 총 파일의 갯수는 최소 1000개 이상이 될 것입니다.
다른 사람들은 어떻게 query 훅들을 관리할까,, 찾아보다가 @tanstack/react-query 의 maintainer 분이 올리신 게시글 하나를 보게되었습니다.
I have been asked a lot lately how to make your own low-level abstraction over useQuery and have it work in TypeScript. My answer is usually: You don’t need it, as those abstractions are often too wide. But there are use-cases for it, so here is my take. Let’s break it down
function useApi<
TQueryKey extends [string, Record<string, unknown>?],
TQueryFnData,
TError,
TData = TQueryFnData,
>(
queryKey: TQueryKey,
fetcher: (params: TQueryKey[1], token: string) => Promise<TQueryFnData>,
options?: Omit<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, "queryKey" | "queryFn">
) {
const { getToken } = useAuth();
return useQuery({
queryKey,
queryFn: async () => {
const token = await getToken();
return fetcher(queryKey[1], token);
},
...options
})
}
보통 필요하지않지만 만약 사용한다면 아래와 같이 사용할 것이다. 라고 maintainer는 말합니다.
코드를 보면 컴포넌트 레벨에서 useApi는 호출 시점에, queryKey와 api를 호출하는 fetcher 라는 콜백함수 인자를 받습니다.
fetcher는 넘어온 queryKey 배열의 1번째 인덱스의 데이터와 유저 정보가 담겨있는 token 을 받는 것 같습니다.
그리고 useApi는 최종적으로 useQuery의 queryFn에서 호출된 fetcher의 결과값을 리턴합니다.
아무래도 maintainer는 queryFn에서 사용될 fetcher가 token 값도 같이 받아 처리할 수 있게하는 추상화된 api call 함수를 생각한 것 같습니다.
TO-BE
호출한 api 별로 응답받은 서버의 데이터는 결국 프론트 코드 단에서 타입스크립트를 사용한다면, 응답 데이터의 구조를 정의하는 타입 인터페이스를 선언해야합니다. 따라서, 어차피 선언해야할 타입 인터페이스도 있으니, api 함수를 각각 모듈화하는 것은 유지하고 maintainer가 제시한 방법에서 좀 변형해서 로우 레벨로 추상화한 훅을 만들어보았습니다.
import { useQuery, UseQueryOptions, UseQueryResult, QueryKey } from '@tanstack/react-query';
import { AxiosResponse } from 'axios';
export const useApiQuery = <
TQueryKey extends [string, Record<string, unknown>?],
TQueryFnData,
TError = Error,
TData = TQueryFnData,
>({
queryKey,
fetcher,
options,
}: {
queryKey: TQueryKey;
fetcher: () => Promise<AxiosResponse<TQueryFnData>>;
options?: Omit<UseQueryOptions<TQueryFnData, TError, TData, QueryKey>, 'queryKey' | 'queryFn'>;
}): UseQueryResult<TData, TError> =>
useQuery<TQueryFnData, TError, TData>({
queryKey,
queryFn: async () => {
const res = await fetcher();
return res.data;
},
...options,
});
팀 내에서는 axios 의 api 호출 인스턴스를 따로 생성하여 request interceptor 메서드에 request header에 로그인 상태 유무로 토큰 헤더를 주입하고 있었기에, 모듈화한 api 함수만 호출하도록 구현했습니다.
또한, 최종적으로 return하는 데이터가 api response interface의 타입이 제대로 추론되어 반환될 수 있도록 신경썼습니다.
사용해보기
import { useApiQuery } from '@/services/useApiQuery';
import { MENU_LIST_QUERY_KEY } from '@/constants/queryKey';
const { data, refetch } = useApiQuery({
queryKey: [MENU_LIST_QUERY_KEY, { roleId }],
fetcher: () => menuListGetFetch({ roleId: roleId ?? '' }),
options: {
enabled: !!roleId,
},
});
이제 위와 같이 어느정도 공통으로 사용할 수 있는 훅을 완성한 것 같습니다.
그러나 막상 사용해보니 어떤 데이터를 가져오려고 훅을 호출하는 것인지 가독성이 떨어지는 문제가 있었습니다.
import { useApiQuery as useThreadListQuery } from '@/services/useApiQuery';
//...
특정 리스트 데이터를 가져오려고 하는 경우, 위와 같이 import alias를 사용하는 것이 가독성 측면에 도움이 되지 않을까 생각합니다.