JS/Next

[Next.js] app router에서 React-query 구조적으로 사용해보기

shin96bc 2024. 6. 24. 22:02

평소에 react-query를 사용하면서 작은 규모에서는 크게 문제가 없었지만, 프로젝트 규모가 커지다 보면 유지보수하기가 용이하지 못한 구조가 되어버린 경험들이 다수 있었습니다.

특히 Next.js의 app router를 사용하게 되면서 그런 문제점들이 더욱 확실하게 느껴져서 블로그나 공식문서들을 찾아보며 좀 더 구조적으로 사용할 수 있도록 구성해보았습니다.

 

포인트

  • Hydrate API(React의 기본 hydration method)와 ReactQueryStreamedHydration(React-query와 함께 사용하는 hydration method) 두 가지 중에 API 중에 어떤 것을 채택할 것인가?
  • Server Component(SSR)와 Client Component(CSR) 양측에서 모두 깔끔하게 사용할 수 있는 코드를 유지하기 위한 구조는?
  • 좀 더 객체지향적으로 코드를 작성할 수는 없을까?

 

이제부터 제가 채택한 방식에 대해 하나씩 설명하겠습니다.

 

Service 별로 나누어 관리하기

우리가 서비스를 유지보수 하다보면 보통 어떤 특정 Service에만 문제가 생기는 경우가 대부분입니다.

그렇다면 특정 Service에 문제가 발생했을 때 가장 합리적인으로 해결되는건 어떤 상황일까요?

저는 문제가 생긴 Service부분만을 보고 해결이 가능한 상황이 가장 합리적이지 않을까 생각합니다.

불필요한 부분까지 확인을 해야만 해결이 가능하다면 빠른 대응도 어려울뿐더러 또 다른 사이드 이펙트가 생겨날 가능성이 있습니다.

 

그래서 저는 먼저 기본이 되는 Service를 만들고 각 Service들이 기본 Service를 확장하도록 구성했습니다.

 

기본 Service 구성

// 기본 Service객체(src/services/Service.ts)

interface HTTPInstance {
  get<R>(
    url: string,
    config?: RequestInit,
  ): Promise<R>;
  delete<R>(
    url: string,
    config?: RequestInit,
  ): Promise<R>;
  head<R>(
    url: string,
    config?: RequestInit,
  ): Promise<R>;
  options<R>(
    url: string,
    config?: RequestInit,
  ): Promise<R>;
  post<T, R>(
    url: string,
    data?: T,
    config?: RequestInit,
  ): Promise<R>;
  put<T, R>(
    url: string,
    data?: T,
    config?: RequestInit,
  ): Promise<R>;
  patch<T, R>(
    url: string,
    data?: T,
    config?: RequestInit,
  ): Promise<R>;
}

interface FileInstance {
  fileUpload(
    url: string,
    data: FormData,
    config?: RequestInit,
  ):Promise<boolean>;
}

class Service {
  public http: HTTPInstance;

  private baseURL: string;

  private headers: Record<string, string>;

  public file: FileInstance;

  public token?: string;

  constructor() {
    this.baseURL = `${process.env.API_SERVER_URL}`;
    this.headers = {
      // csrf: 'token',
      // Referer: this.baseURL,
    };

    this.http = {
      get: this.get.bind(this),
      delete: this.delete.bind(this),
      head: this.head.bind(this),
      options: this.options.bind(this),
      post: this.post.bind(this),
      put: this.put.bind(this),
      patch: this.patch.bind(this),
    };

    this.file = {
      fileUpload: this.fileUpload.bind(this),
    }
  }

  public setToken (token: string) {
    this.token = token;
  }

  private async request<R = unknown>(
    method: string,
    url: string,
    data?: unknown,
    config?: RequestInit,
  ): Promise<R> {
    try {
      const headers: HeadersInit & { Authorization?: string } = {
        ...this.headers,
        'Content-Type': 'application/json',
        ...config?.headers,
      };

      if (this.token) {
        headers.Authorization = `Bearer ${this.token}`;
      }

      const response = await fetch(this.baseURL + url, {
        method,
        headers: headers,
        // credentials: 'include',
        body: data ? JSON.stringify(data) : undefined,
        ...config,
      });

      if (!response.ok) {
        throw new Error('Network response was not ok');
      }

      const responseData: R = await response.json();
      return responseData;
    } catch (error) {
      console.error('Error:', error);
      throw error;
    }
  }

  private async fileRequest(
    method: string,
    url: string,
    data: FormData,
    config?: RequestInit,
  ): Promise<boolean> {
    try {
      const response = await fetch(url, {
        method,
        headers: {
          ...this.headers,
          // 'Content-Type': 'multipart/form-data',
          ...config?.headers,
        },
        // credentials: 'include',
        body: data,
        ...config,
      });

      console.log('response = ', response);

      if (!response.ok) {
        throw new Error('Network response was not ok');
      }

      // const responseData: R = await response.json();
      return true;
    } catch (error) {
      console.error('Error:', error);
      throw error;
    }
  }

  private get<R>(
    url: string,
    config?: RequestInit,
  ): Promise<R> {
    return this.request<R>('GET', url, undefined, config);
  }

  private delete<R>(
    url: string,
    config?: RequestInit,
  ): Promise<R> {
    return this.request<R>('DELETE', url, undefined, config);
  }

  private head<R>(
    url: string,
    config?: RequestInit,
  ): Promise<R> {
    return this.request<R>('HEAD', url, undefined, config);
  }

  private options<R>(
    url: string,
    config?: RequestInit,
  ): Promise<R> {
    return this.request<R>('OPTIONS', url, undefined, config);
  }

  private post<T, R>(
    url: string,
    data?: T,
    config?: RequestInit,
  ): Promise<R> {
    return this.request<R>('POST', url, data, config);
  }

  private put<T, R>(
    url: string,
    data?: T,
    config?: RequestInit,
  ): Promise<R> {
    return this.request<R>('PUT', url, data, config);
  }

  private patch<T, R>(
    url: string,
    data?: T,
    config?: RequestInit,
  ): Promise<R> {
    return this.request<R>('PATCH', url, data, config);
  }

  private fileUpload(
    url: string,
    data: FormData,
    config?: RequestInit,
  ) {
    return this.fileRequest('POST', url, data, config);
  }
}

export default Service;

 

기본 서비스를 상속받은 CategoryService 객체

// 기본 Service를 확장한 CategoryService 객체(src/services/classification/category/CategoryService.ts)

import Service from "@Src/services/Service";
import {
  CategoriesRes,
  CategoryMainReq,
  CategoryRes,
  CategorySort,
} from "@Src/services/classification/category/model";

class CategoryService extends Service {
  getCategories() {
    return this.http.get<CategoriesRes[]>('/mall/categories');
  }

  getCategory(id: string) {
    return this.http.get<CategoryRes>(`/mall/categories/${id}`);
  }

  addCategoryMain(data: CategoryMainReq) {
    return this.http.post<CategoryMainReq, CategorySort>('/mall/categories', data);
  }

  updateCategorySort(data: CategorySort) {
    return this.http.put<CategorySort, null>('', data);
  }
}

const categoryService = new CategoryService();

export default categoryService;

 

queryKey, queryOption 별도 파일로 관리하기

https://tkdodo.eu/blog/effective-react-query-keys

 

Effective React Query Keys

Learn how to structure React Query Keys effectively as your App grows

tkdodo.eu

해당 글을 보면 효율적으로 queryKey를 관리하는 방법이 나와있습니다. 여기서 queryKey를 팩토리로 관리하면 좋다는 내용을 보고 생각하게 되었습니다. queryKey를 이곳 저곳에 하드코딩 해버리면 추후에 오류가 발생하기 쉽고, 변경이 어렵기 때문에 별도의 파일로 분리하여 사용하는 것이 더 유연하게 대응할 수 있어서 좋은 구조라고 생각했습니다.

 

저는 여기서 추가적으로 SSR을 할 때 Hydrate API를 사용하기 때문에 여기에도 유연하게 대응할 수 있도록 하고 싶어서 queryOption도 같이 별도로 분리했습니다.

// queryKey, queryOption들(src/services/classification/category/queries.ts)

import CategoryService from "@Src/services/classification/category/CategoryService";
import {CategoryMainReq, CategorySort} from "@Src/services/classification/category/model";

const queryKeys = {
  findAll: ['categories'] as const,
  findOne: (id: string) => [...queryKeys.findAll, id] as const,
  uploadUrl: ['uploadUrl'] as const,
};

const queryOptions = {
  findAll: () => ({
    queryKey: queryKeys.findAll,
    queryFn: () => CategoryService.getCategories(),
  }),
  findOne: (id: string) => ({
    queryKey: queryKeys.findOne(id),
    queryFn: () => CategoryService.getCategory(id),
  }),
  addMain: () => ({
    mutationFn: (data: CategoryMainReq) => CategoryService.addCategoryMain(data),
  }),
  updateSort: () => ({
    mutationFn: (data: CategorySort) => CategoryService.updateCategorySort(data),
  }),
};

export default queryOptions;

 

API 통신에 사용되는 Model 객체 관리하기

API 통신을 할 때 request와 response에서 사용되는 data의 타입들을 미리 지정해두게 될 텐데 이 타입들이 지정된 파일도 같은 서비스 폴더에 위치시켜서 좀 더 유지보수하기 편하게 했습니다.

// API 통신에 필요한 model 객체들 (src/services/classification/category/model.ts)

// 카테고리 id
type Ci = string;
// 카테고리 영어 이름
type Cen = string;
// 카테고리 한글 이름
type Cko = string;
// 카테고리 버전
type Cv = string;
// 카테고리 정렬 가중치
type CMove = number;
// 카테고리 썸네일 이미지
type Cp = string;
// 카테고리 사용여부
type Availability = 'Enabled' | 'Disabled';
// 연령확인 사용여부
type AgeVerification = 'Enabled' | 'Disabled';

// 카테고리 중분류
export interface SubCategoriesRes {
  ci: Ci;
  cen: Cen;
  cko: Cko;
  cMove: CMove;
}

export interface CategoriesRes {
  ci: Ci;
  cen: Cen;
  cko: Cko;
  cv: Cv;
  cMove: CMove;
  child?: SubCategoriesRes[];
}

export interface CategoryMainReq {
  cen: Cen;
  cko: Cko;
  cv: Cv;
  cMove: CMove;
}

export interface CategoryReq {
  cen: Cen;
  cko: Cko;
  availability: Availability;
  ageVerification: AgeVerification;
  cp: Cp;
  cv: Cv;
}

export interface CategoryRes {
  cen: Cen;
  cko: Cko;
  availability: Availability;
  ageVerification: AgeVerification;
  cp: Cp;
  cv: Cv;
}

export interface CategorySort {
  ci: Ci;
  cMove: CMove;
}

 

Client Component에서 사용할 Hook 별도 파일로 분리(CSR)

https://kentcdodds.com/blog/colocation

 

Colocation

Stay up to date Stay up to date All rights reserved © Kent C. Dodds 2024

kentcdodds.com

우리가 react-query를 사용하다보면 이곳 저곳에 useQuery, useMutation을 남발하게 되는데 이런 구조는 좋지 못합니다. 이렇게 사용해버리면 가독성을 해치고 문제가 되는 부분을 찾기가 어려워서 유지보수가 어려울 수 있습니다.

그래서 저는 별도의 hook으로 분리했습니다.

// Client Component를 위한 별도의 Hook(src/services/classification/category/useCategoryService.ts)

import {useMutation, useQuery} from "@tanstack/react-query";
import queryOptions from "@Src/services/classification/category/queries";
import {CategoryMainReq, CategorySort} from "@Src/services/classification/category/model";

export function useFindByCategories() {
  return useQuery(queryOptions.findAll());
}

export function useFindByCategory({ id }: { id: string }) {
  return useQuery(queryOptions.findOne(id));
}

export function useAddCategoryMain() {
  return useMutation(queryOptions.addMain());
}

export function useUpdateCategorySort() {
  return useMutation(queryOptions.updateSort());
}

 

서버에서 Prefetching 하고 데이터 dehydration 하기

이제 hydrate와 dehydrate 세팅을 해줘야 합니다.

// src/services/react-query.ts

import {cache} from "react";
import {dehydrate, HydrationBoundary, QueryClient, QueryKey, QueryState} from "@tanstack/react-query";
import isEqual from "@Src/services/isEqual";

/* getQueryClient는 공식문서에서 권장하는대로 서버에서 데이터를 fetching 할 때 마다 필요한 queryClient를 cache해서 사용할 수 있도록 구성 */
export const getQueryClient = cache(() => new QueryClient());

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

interface QueryProps<ResponseType = unknown> {
  queryKey: QueryKey;
  queryFn: () => Promise<ResponseType>;
}

interface DehydratedQueryExtended<TData = unknown, TError = unknown> {
  state: QueryState<TData, TError>;
}

/* getDehydratedQuery는 서버에서 데이터를 prefetching하고 dehydrate한 결과를 return 하도록 구성(SSR) */
export async function getDehydratedQuery<Q extends QueryProps>({ queryKey, queryFn }: Q) {
  const queryClient = getQueryClient();
  await queryClient.prefetchQuery({ queryKey, queryFn });
  
  const { queries } = dehydrate(queryClient);
  console.log('dehydrate(queryClient) = ', dehydrate(queryClient));
  const [ dehydratedQuery ] = queries.filter((query) => isEqual(query.queryKey, queryKey))
  
  return dehydratedQuery as DehydratedQueryExtended<UnwrapPromise<ReturnType<Q['queryFn']>>>;
}

// filtering 하지 않고 모든 dehydrated query를 반환하는 함수
export async function getDehydratedQueries<Q extends QueryProps[]>(queries: Q) {
  const queryClient = getQueryClient();
  await Promise.all(
    queries.map(({ queryKey, queryFn }) =>
      queryClient.prefetchQuery({ queryKey, queryFn }),
    ),
  );

  return dehydrate(queryClient).queries as DehydratedQueryExtended<
    UnwrapPromise<ReturnType<Q[number]['queryFn']>>
  >[];
}

export const Hydrate = HydrationBoundary;

export default {};
// src/services/isEqual.ts

const isEqual = (value: unknown, other: unknown): boolean => {
  if (value === other) {
    return true;
  }

  if (typeof value !== typeof other) {
    return false;
  }

  if (Array.isArray(value) && Array.isArray(other)) {
    if (value.length !== other.length) {
      return false;
    }

    for (let i = 0; i < value.length; i++) {
      if (!isEqual(value[i], other[i])) {
        return false;
      }
    }

    return true;
  }

  if (typeof value === 'object' && typeof other === 'object' && value && other) {
    const valueObj = value as Record<string, unknown>;
    const otherObj = value as Record<string, unknown>;
    const valueKeys = Object.keys(valueObj);
    const otherKeys = Object.keys(otherObj);

    if (valueKeys.length !== otherKeys.length) {
      return false;
    }

    return valueKeys.every((key ) =>
      Object.prototype.hasOwnProperty.call(otherObj, key) && isEqual(valueObj[key], otherObj[key]),
    );
  }

  return value === other;
};

export default isEqual;

 

Provider 만들기

// src/services/ReactQueryProvider.tsx

'use client'

import {PropsWithChildren, useState} from "react";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {ReactQueryDevtools} from "@tanstack/react-query-devtools";

export default function ReactQueryProvider({ children }: PropsWithChildren) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      { children }
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

 

결론

이런식으로 api service 부분을 좀 더 구조적으로 가져갔을 때 유지보수하기 용이해지고 service별로 작업할 수 있어서 좋았던 것 같습니다.

하지만 저는 좀 더 좋은 구조를 찾을 수 있도록 더 고민할 것입니다.

 

'JS > Next' 카테고리의 다른 글

[Next.js] 빌드하기  (0) 2024.06.24
[Next.js] 서버기능 구현하기  (0) 2024.06.24
[Next.js] Dynamic Route  (0) 2024.06.24
[Next.js] 이미지 다루기 & 최적화 하기  (0) 2024.06.24
[Next.js] Server Component & Client Component  (0) 2024.06.24