Ayden's journal

data fetching on react.js

Next.js page router에서 서버 상태는 보통 리액트 쿼리를 사용하여 관리하는데, 이걸 어떻게 사용하는지는 개발자마다 조금씩 다른 듯하다. 내 경우는 아래와 같이 다섯 단계에 걸쳐 컴포넌트에 데이터를 넣어주고 있다. 각각의 단계는 한 줄로 정의되는 최소한의 책임을 구현하며, 이를 통해 백엔드 인터페이스가 변경되거나 엔드포인트가 확장되어도 유연하게 대응할 수 있다.

 

 

Fetcher

Fetcher 클래스에서는 데이터 가져오는 가장 기본적인 로직을 정의한다. 아래에서는 AXIOS를 사용하였지만, GraphQL을 사용해서 구현할 수도 있다. 기본적으로 Fetcher 클래스는 Query 클래스와 Mutation 클래스에 상속하기 위한 것이지만, 필요하다면 Fetcher 그 자체를 가져다 써야 할 수도 있어서 추상 클래스로 구현하지는 않았다.

// @/api/base/fetcher.ts
class Fetcher {
  private instance: AxiosInstance;

  constructor() {
    this.instance = axios.create({
      baseURL: process.env.NEXT_PUBLIC_BASE_URL,
      timeout: 5 * 1000,
      withCredentials: true,
    });
  }

  public async doFetch<T>(config: AxiosRequestConfig): Promise<T> {
    const { data } = await this.instance<T>({
      ...config,
    });
  
    return data;
  }
}

 

 

Query

CRUD 중에 R을 담당하는 Query 클래스에 대해 먼저 살펴보자. 추상 클래스 Query에는 데이터를 가져오기 위한 queryFn과 infiniteQueryFn 메서드가 있고, 추상 멤버 변수로 queryKey를 넣어 잊지 않고 구현하도록 했다.

// @/api/base/query.ts
abstract class Query extends Fetcher {
  abstract queryKey: QueryKey;

  queryFn<T>(url: string) {
    return () =>
      this.doFetch<T>({
        method: "get",
        url,
      });
  }

  infiniteQueryFn<T>(url: string) {
    return ({ pageParam }: { pageParam: number }) =>
      this.doFetch<T>({
        method: "get",
        url: `${url}&skip=${pageParam}`,
      });
  }

  queryOptions = <TQueryFnData = unknown, TError = Error, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
    options: Parameters<typeof queryOptions<TQueryFnData, TError, TData, TQueryKey>>[0],
  ) => queryOptions(options);

  infiniteQueryOptions = <
    TQueryFnData,
    TError = Error,
    TData = InfiniteData<TQueryFnData>,
    TQueryKey extends QueryKey = QueryKey,
    TPageParam = unknown,
  >(
    options: Parameters<typeof infiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>>[0],
  ) => infiniteQueryOptions(options);
}

 

리액트 쿼리는 queryFactory 패턴을 구현함에 있어 타입 오류가 발생하는 것을 막고자 queryOptions 함수와 infiniteQueryOptions 함수를 제공하고 있다. 여기에 더해 나는 비슷하지만 useMutation에 대응하는 mutationOptions 함수를 따로 구현하여 사용하고 있다. 이런 options 함수는 전달받은 아규먼트를 그대로 리턴하면서도 쿼리와 뮤테이션에 필요한 옵션 객체의 타입에 문제가 없는지를 검사한다.

mutationOptions이야 리액트 쿼리 차원에서 제공되고 있지 않으니 어쩔 수 없이 만들어 써야 한다. 그런데 다른 두 options 함수는 이미 리액트 쿼리가 제공하는데도 불구하고 나는 이를 Fetcher 내부에 정의하고 있는 걸까? 이유는 크게 두 가지이다.

 

하나는 Fetcher를 extends 받는 클래스는 거의 반드시 options 함수를 사용하게 된다. 그런데 이걸 각각의 클래스 Repository 파일에서 import하는 것보다 Query 클래스 한 곳에서 처리하는 게 낫다고 판단했다. 또한, mutationOptions는 Mutation 클래스에서 제공되는데 다른 두 options는 그렇지 않을 경우 호출 방식에 있어 mutationOptions만 this로 호출하게될테니 중구난방으로 보이지 않을까 염려한 데도 있었다.

다른 이유로는 '타입 주입' 문제가 있다. 아래는 리액트 쿼리가 제공하는 queryOptions 함수의 타입 일부이다. 살펴보면 TError 부분이 Error로 되어있다. 백엔드가 어떤 형식의 에러를 던지게 될는지는 타입스크립트로 추론할 수 있는 게 아닌 만큼, 기본적으로 우리가 제공해주어야 하는 타입이다.

문제는 TQueryFnData는 queryOptions 함수가 충분히 추론할 수 있음에도 불구하고 TError 타입을 제공하기 위해 TQueryFnData까지 명시적으로 제공해야 한다는 점이었다. 게다가 이것은 queryOptions를 사용하는 모든 곳에서 발생할 것이기 때문에 가능하다면 어디 한 곳에서 TError의 타입을 일괄적으로 필터링해주어야 했다. 그 한 곳이 지금으로서는 Query 클래스 일 뿐이다.

function queryOptions<
  TQueryFnData = unknown,
  TError = Error,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey
>

 

이 추상 클래스 Query는 각각의 DomainQuery 클래스로 상속된다. options 메서드 덕분에 타입 걱정 없이 각 쿼리 옵션 객체를 작성할 수 있다.

// @/api/book/book.query.ts
class BookQuery extends Query {
  queryKey = ["book"];

  getBookByIsbn(isbn13: string) {
    return this.queryOptions({
      queryKey: [...this.queryKey, isbn13],
      queryFn: this.queryFn<Book>(`/book/detail?isbn=${isbn13}`),
      enabled: !!isbn13,
    });
  }

  getBookList(keyword: string) {
    return this.infiniteQueryOptions({
      queryKey: [...this.queryKey, keyword],
      queryFn: this.infiniteQueryFn<GetBookListQuery>(`/book/search?keyword=${keyword}&take=10`),
      initialPageParam: 1,
      getNextPageParam: (lastPage: GetBookListQuery, allPages: any, lastPageParam: number) =>
        lastPage.hasNext ? lastPageParam + 1 : undefined,
    });
  }
}

 

onef 프로젝트의 경우 Report 도메인에 독후감 ∙ 독후감 좋아요 ∙ 독후감 검색 등 여러가지가 포함되어있다. 이처럼 하나의 도메인이 너무 많은 작업을 해야 한다면, 아래와 같이 한 단계 분리하여 사용하기도 한다.

export class ReportQuery {
  queryKey = ["report"];

  base: new ReportBaseQuery(queryKey);
  
  search: new ReportSearchQuery(queryKey);
  
  like: new ReportLikeQuery(queryKey)
}

class ReportBaseQuery extends Query {
  constructor(queryKey) {
    this.queryKey = [...queryKey, "base"]
  }
  
  ...
}

 

useAdaptor는 각각의 DomainQuery 메서드를 사용해 쿼리 옵션을 획득하고, 이를 통해 데이터를 불러오게 된다. useAdaptor는 또한 백엔드 인터페이스의 변경에도 이에 의존하는 컴포넌트를 모두 바꿀 필요가 없도록 중간에서 인터페이스를 매개해주는 역할도 맡는다.

// 컴포넌트
const { data, isPending, book } = useQuery(new BookQuery().getBookByIsbn(isbn))

// useAdaptor
const useBookAdaptor = (queryOptions: QueryOptions<Book>) => {
  const { data, isPending } = useQuery(queryOptions);
  
  return {
    data,
    isPending,
    book: {
      isbn13: data?.book.isbn13 ?? "",
      title: data?.book.title ?? "",
      // 가나다 (지은이) 라마바 (옮긴이) 이런 형태로 옮
      author: formatAuthor(data?.book.author),
      description: data?.book.description ?? "",
      cover: data?.book.cover ?? "",
      categoryId: data?.book.categoryId ?? 0,
      categoryName: data?.book.categoryName ?? "",
      pubDate: data?.book.pubDate ?? "",
      publisher: data?.book.publisher ?? "",
      priceStandard: data?.book.priceStandard ?? 0,
      customerReviewRank: data?.book.customerReviewRank ?? 0,
      itemPage: data?.book.subInfo.itemPage ?? 0,
    },
  };
};

 

 

Mutation

Query 클래스와 비교하면 Mutation 클래스는 조금은 단촐해보이는 면이 있다.

abstract class Mutation extends Fetcher {
  mutationFn<T>(url: string, method: "post" | "put" | "patch" | "delete", data?: any) {
    return this.doFetch<T>({
      method,
      url,
      data,
    });
  }

  mutationOptions = <TData = unknown, TError = Error, TVariables = void, TContext = unknown>(
    options: UseMutationOptions<TData, TError, TVariables, TContext>,
  ): UseMutationOptions<TData, TError, TVariables, TContext> => options;
}

 

추상 클래스 Mutation를 상속받는 각각의 DomainMutation 클래스들을 어떻게 구현할 것인지는 아직까지도 고민 중인 부분이다. 특정한 책임들을 DomainMutation 클래스와 useMutator 중 어느 쪽에 맡겨야 좋을지 확신이 없기 때문이다. 당장은 아래와 같이 구현하고 있다. 훅의 규칙에는 위배되지만 동작에는 문제가 없는 만큼 응집력 있는 코드를 작성할 수 있다.

class AuthMutation extends Mutation {
  private queryClient = useQueryClient();
  private router = useRouter()

  changePassword() {
    return this.mutationOptions({
      mutationFn: (password) => this.mutationFn<ChangePasswordMutation>("/auth/password", "post", password),
      onSuccess: () => {
        toast.success("비밀번호가 변경되었습니다.");
        this.queryClient.invalidateQueries({ queryKey: ["user"] });
        this.router.push("/signin")
      },
      onError: (error) => {
        toast.error("비밀번호 변경에 실패했습니다.");
      },
    });
  }
}

 

useMutator는 useAdaptor와는 다르게 그저 도메인 별 mutate 함수를 한 곳에서 관리하기 위한 용도이다.

export const useAuthMutator = () => {
  const authMutation = new AuthMutaion()

  const { changePasswordMutate } = useMutation(authMutation.changePassword());

  const { changeBioMutate } = useMutation(authMutation.changeBio());

  const { changeProfileImageMutate } = useMutation(authMutation.changeProfileImage());

  return {
    changePasswordMutate,
    changeBioMutate,
    changeProfileImageMutate
  }
}

 

 

결론

한두 단계가 추가될 수는 있겠지만 전체적인 흐름은 마음에 든다. 위의 예시는 REST API + 리액트 쿼리의 조합이지만, GraphQL + 리액트 쿼리라던가 GraphQL + ApolloClient 심지어는 REST API + Next.js fetch 등 다양한 조합으로 얼마든지 변형할 수 있다.

 

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기