카테고리 없음

typescript generic 공부: react query 공통 type 모듈 지정

Honey-dev 2023. 5. 4. 11:00

이번 프로젝트에서 Next.js와 TypeScript 그리고 react query를 처음 사용해보고 있는데, 꽤나 정신없다. 한 번에 새로운 세 가지를 적용해보려니 발생하는 문제가 정확히 어떤 곳에서 어떤 이유로 발생하는지를 구분하는 것도 꽤나 일이다.

 

이번 프로젝트에서는 특히나 React Bulletproof(https://github.com/alan2207/bulletproof-react)의 컨벤션을 따라 작업하고 있는데, 발생한 이슈(에러) 해결 과정에서 타입스크립트 제네릭에 대해 조금 더 확실히 이해하게 되어서 기록을 남겨둔다. 

 

이번 프로젝트는 개발자 지망생과 주니어 개발자를 위한 프로젝트 공유 및 코드 리뷰 커뮤니티 서비스이다. 그중 작성된 코드 목록을 가져오는 페이지를 무한 스크롤로 구현하기로 하였고, 기존에 구현해둔 Intersection Observer API를 이용한 무한스크롤 대신 react query를 이용한 코드로 수정하게 되었다. (프로젝트 목록 페이지를 구현한 팀원이 react query의 InfiniteQuery를 이용해서 코드를 구현하였고, 동일한 search 컴포넌트를 사용하게 되어서 그대로 가져와서 조금만 수정해서 사용하기로 결정했다.)

 

기존 코드에서 api 분리를 해 두지 않은 점에 대해 bulletproof 구조를 따라 분리하며 리팩토링까지 함께 진행하였다.

 

1. 코드 작성

참고한 팀원의 기존 코드 (api 요청과 상관없는 부분 제거)

// src/features/projects/components/lists/project-list

import axios from "axios";
import { useInfiniteQuery } from "@tanstack/react-query";

export const ProjectList = () => {
  ...
  const fetchProjects = async ({ pageParam = 0 }) => {
    const res = await axios.get(
      "BASE_URL/project",
      {
        params: {
          page: pageParam,
          sort: sort,
          size: size,
          keyword: usingKeyword,
          tagIdList: stringTagIdList,
          closed: closed,
        },
      }
    );
    return res;
  };

  const { status, data, fetchNextPage } = useInfiniteQuery(
    ["projects"],
    fetchProjects,
    {
      getNextPageParam: (lastPage) => {
        return lastPage.nextPage === -1 ? undefined : lastPage.nextPage;
      },
    }
  );

  return <>...</>;
};

 

참고하여 작성한 내 코드(api 분리, 상관없는 부분 제거)

// src/features/code-reviews/api/get-code-list

import { useInfiniteQuery } from "@tanstack/react-query";

import { axios } from "@/lib/axios";
import { ExtractFnReturnType, QueryConfig } from "@/lib/react-query";

type CodeListResult = {
  nextPage: number; // 다음 페이지
  list: {
    codeId: number; // 코드 ID
    version: number; // 코드 버전
    title: string; // 코드 제목
    date: Date; // 수정날짜
    likeCnt: number; // 좋아요 수
    reviewCnt: number; // 리뷰 수
    tags: string[]; // 태그 이름 목록
    userName: string; // 코드 작성자 닉네임
    liked: boolean; // 좋아요 여부
  }[];
};

type CodeListDTO = {
  sort: "modifiedDate" | "likeCnt" | "reviewCnt";
  page: number;
  size: number;
  keyword: string;
  tagIdList: string;
};

export const getCodeList = (params: CodeListDTO): Promise<CodeListResult> => {
  return axios.get("/code", { params });
};

type QueryFnType = typeof getCodeList;

type UseCodeListOptions = {
  params: CodeListDTO;
  config?: QueryConfig<QueryFnType>;
};

export const useCodeList = ({ params, config }: UseCodeListOptions) => {
  return useInfiniteQuery<ExtractFnReturnType<QueryFnType>>({
    ...config,
    queryKey: ["codeList", params],
    queryFn: () => getCodeList(params),
  });
};
// src/features/code-reviews/pages/code-list

import { useCodeList } from "../api";

export const CodeList = () => {
 ...
  const { status, data, fetchNextPage } = useCodeList({
    params: {
      page: pageParam || 0,
      sort: sort,
      size: size,
      keyword: usingKeyword,
      tagIdList: stringTagIdList,
      closed: closed,
    },
    config: {
      getNextPageParam: (lastPage) =>
        lastPage.nextPage === -1 ? undefined : lastPage.nextPage,
    },
  });  
  ...
};

 


2. 문제 발생

그런데 아래와 같은 타입 에러가 발생했다. 어쩌구저쩌구 굉장히 긴데 요약하면 config로 전달한 부분에서 옵션 타입들을 충족하지 못한다는 거다. 그런데 저기 나와 있는 메서드를 하나하나 세팅해주는 건 말이 안 되고, 저 메서드 타입들이 어디서 나왔는지 보니 InfiniteQuery의 옵션들이었다.

// 타입 에러가 발생한 함수
export const useCodeList = ({ params, config }: UseCodeListOptions) => {
  return useInfiniteQuery<ExtractFnReturnType<QueryFnType>>({
    ...config,
    queryKey: ["codeList", params],
    queryFn: () => getCodeList(params),
  });
};
No overload matches this call.
Overload 1 of 3, '(options: UseInfiniteQueryOptions<CodeListResult, unknown, CodeListResult, CodeListResult, QueryKey>): UseInfiniteQueryResult<...>', gave the following error.
Argument of type '{ queryKey: (string | CodeListDTO)[]; queryFn: () => Promise<CodeListResult>; context?: Context<QueryClient | undefined> | undefined; ... 32 more ...; meta?: QueryMeta | undefined; }' is not assignable to parameter of type 'UseInfiniteQueryOptions<CodeListResult, unknown, CodeListResult, CodeListResult, QueryKey>'.
Types of property 'refetchInterval' are incompatible.
Type 'number | false | ((data: CodeListResult | undefined, query: Query<CodeListResult, unknown, CodeListResult, QueryKey>) => number | false) | undefined' is not assignable to type 'number | false | ((data: InfiniteData<CodeListResult> | undefined, query: Query<CodeListResult, unknown, InfiniteData<CodeListResult>, QueryKey>) => number | false) | undefined'.
Type '(data: CodeListResult | undefined, query: Query<CodeListResult, unknown, CodeListResult, QueryKey>) => number | false' is not assignable to type 'number | false | ((data: InfiniteData<CodeListResult> | undefined, query: Query<CodeListResult, unknown, InfiniteData<CodeListResult>, QueryKey>) => number | false) | undefined'.
Type '(data: CodeListResult | undefined, query: Query<CodeListResult, unknown, CodeListResult, QueryKey>) => number | false' is not assignable to type '(data: InfiniteData<CodeListResult> | undefined, query: Query<CodeListResult, unknown, InfiniteData<CodeListResult>, QueryKey>) => number | false'.
Types of parameters 'data' and 'data' are incompatible.
Type 'InfiniteData<CodeListResult> | undefined' is not assignable to type 'CodeListResult | undefined'.
Type 'InfiniteData<CodeListResult>' is missing the following properties from type 'CodeListResult': nextPage, list
Overload 2 of 3, '(queryKey: QueryKey, options?: Omit<UseInfiniteQueryOptions<CodeListResult, unknown, CodeListResult, CodeListResult, QueryKey>, "queryKey"> | undefined): UseInfiniteQueryResult<...>', gave the following error.
Argument of type '{ queryKey: (string | CodeListDTO)[]; queryFn: () => Promise<CodeListResult>; context?: Context<QueryClient | undefined> | undefined; ... 32 more ...; meta?: QueryMeta | undefined; }' is not assignable to parameter of type 'QueryKey'.
Object literal may only specify known properties, and 'queryKey' does not exist in type 'readonly unknown[]'.

 

에러가 발생한 부분인 config의 타입으로 전달한 QueryConfig는 공통 외부 라이브러리 세팅을 위해 만든 lib 디렉토리에서 react query를 사용하기 위해 공통 기본 타입으로 지정해서 사용하고 있었는데, 여기서 내가 가져온 QueryConfig는 react query에서 제공하는 UseQueryOptions에 제네릭으로 넘겨줄 함수의 타입을 지정하여 사용하는 구조이다. 

// src/lib/react-query

import {
  UseQueryOptions,
} from "@tanstack/react-query";

...
export type ExtractFnReturnType<FnType extends (...args: any) => any> = Awaited<
  ReturnType<FnType>
>;

export type QueryConfig<QueryFnType extends (...args: any) => any> = Omit<
  UseQueryOptions<ExtractFnReturnType<QueryFnType>>,
  "queryKey" | "queryFn"
>;
...

 

 

그래서 react query에서 UseInfiniteQueryOptions를 임포트해서 InfiniteQueryConfig type을 만들어주고 그걸 사용해서 에러를 해결했다.

// src/lib/react-query

import { UseInfiniteQueryOptions } from "@tanstack/react-query";

export type ExtractFnReturnType<FnType extends (...args: any) => any> = Awaited<
  ReturnType<FnType>
>;

export type InfiniteQueryConfig<QueryFnType extends (...args: any) => any> =
  Omit<
    UseInfiniteQueryOptions<ExtractFnReturnType<QueryFnType>>,
    "queryKey" | "queryFn"
  >;
// 에러 해결: src/features/code-reviews/api/get-code-list

import { useInfiniteQuery } from "@tanstack/react-query";

import { axios } from "@/lib/axios";
import { ExtractFnReturnType, InfiniteQueryConfig } from "@/lib/react-query";

type CodeListResult = {
  nextPage: number; // 다음 페이지
  list: {
    codeId: number; // 코드 ID
    version: number; // 코드 버전
    title: string; // 코드 제목
    date: Date; // 수정날짜
    likeCnt: number; // 좋아요 수
    reviewCnt: number; // 리뷰 수
    tags: string[]; // 태그 이름 목록
    userName: string; // 코드 작성자 닉네임
    liked: boolean; // 좋아요 여부
  }[];
};

type CodeListDTO = {
  sort: "modifiedDate" | "likeCnt" | "reviewCnt";
  page: number;
  size: number;
  keyword: string;
  tagIdList: string;
};

export const getCodeList = (params: CodeListDTO): Promise<CodeListResult> => {
  return axios.get("/code", { params });
};

type QueryFnType = typeof getCodeList;

type UseCodeListOptions = {
  params: CodeListDTO;
  config?: InfiniteQueryConfig<QueryFnType>;
};

export const useCodeList = ({ params, config }: UseCodeListOptions) => {
  return useInfiniteQuery<ExtractFnReturnType<QueryFnType>>({
    ...config,
    queryKey: ["codeList", params],
    queryFn: () => getCodeList(params),
  });
};

 

 

제네릭이 확실히 편하다. javascript를 사용할 때는 크게 많이 사용하지 않았던 typeof를 저런 식으로(타입을 추출하여 적용하는 방식으로) 사용할 수 있다는 게 신기하고 재밌고 좋다.