[FE] Query Key 모듈화하여 관리하기

정성엽·2025년 6월 22일
0

LG CNS AM Inspire 1기

목록 보기
67/70

INTRO

프로젝트가 커지면서 React Query 쿼리키 관리가 점점 복잡해졌다.

여러 파일에 흩어진 쿼리키들을 찾아다니며 캐시를 무효화하는 작업이 어느순간부터 번거롭다고 느껴졌고, 이를 해결하기 위해 Query Key 모듈화 를 도입하게 되었다.

이번 포스팅에서는 실제 프로젝트에서 Query Key 모듈화를 어떻게 적용했는지, 그리고 어떤 개선 효과를 얻었는지 공유해보려고 한다!


1. 기존에는 어떻게 사용했는가?

우선 디렉토리 구조를 소개해보면 다음과 같다.

디렉토리 구조

우리팀은 React Query의 훅을 사용하여 API 요청을 처리하는 부분을 src/hooks/api 디렉토리에서 모두 관리하고 있다.

하나의 React Query 코드를 살펴보면 다음과 같다.

Sample Code

// useOrderListApi.ts
export const useGetOrderListApi = ({ size }: { size: number }) => {
  return useInfiniteQuery({
    queryKey: ["orderItem"],
    queryFn: ({ pageParam }) =>
      getOrderList({ lastOrderItemId: pageParam, size }),
    getNextPageParam: response => {
      const lastPage = response.data;

      if (lastPage.isLast) {
        return undefined;
      }

      const lastItem = lastPage.content[lastPage.content.length - 1];
      return lastItem.orderItemId;
    },
    initialPageParam: undefined as number | undefined,
  });
};

export const usePostChangeOrderItemStatus = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ orderItemId, qty, status }: PostChangeOrderItemRequest) =>
      postChangeOrderItemStatus({ orderItemId, qty, status }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["orderItem"] }),
  });
};

우리는 도메인 혹은 기능 별로 파일을 분리하여 쿼리 훅들을 관리하고 있었다.

위 코드를 간단히 설명하면, useGetOrderListApi 는 발주 요청들을 무한 스크롤로 가져오는 훅이고, usePostChangeOrderItemStatus 는 발주 요청의 상태를 변경하는 훅이다.

💡 캐시 전략

기본적으로 우리는 Get 요청을 통해 받아온 모든 서버 상태(response)들을 5분동안 Stale한 상태로 관리하는 캐시 전략을 가지고 있었다.

Sample Image

여기서 usePostChangeOrderItemStatus 을 통해 포토카드 RETRO 상품의 상태가 승인 혹은 취소 로 변경된다면, 기존에 가지고 있던 발주 리스트 캐시 데이터를 무효화하고 다시 받아와야 한다.

Sample Code

onSuccess: () => queryClient.invalidateQueries({ queryKey: ["orderItem"] }),

우리는 mutation의 onSuccess 기능을 사용해서 특정 쿼리 데이터를 무시하는 방법을 사용하고 있었다.

💡 문제 인식

그런데 만약 요구사항이 변경되어서, 완전히 다른 기능의 A라는 mutation이 성공할 때마다 발주 리스트 캐시를 무효화해야 하는 상황이 생겼다고 가정해보자.

Sample Code

// useExampleApi.ts
export const useExampleApi = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: postSample(),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["a", "orderItem"] });
    },
  });
};

이런 상황에서 무효화할 캐시 데이터인 "orderItem"을 찾으려면 useOrderListApi.ts 파일을 일일이 찾아서 들어가야 한다는 문제가 있었다.

이처럼 React Query를 사용해서 쿼리키를 여러개 정의하다보면 여러 파일에 쿼리키가 흩어지기 때문에 유지보수를 하기 어려운 문제가 발생할 수 있다.


2. Query Key 모듈화

쿼리키 모듈화의 핵심은 쿼리키들을 함수로 만들어서 한 곳에서 관리하자는 것이다.

Sample Code

// queryKey.ts
type QueryParams = Record<string, string | number | boolean | undefined>;

export const queryKeys = {
  ...
  orderItem: {
    all: ["orderItem"] as const,
    lists: () => ["orderItem", "list"] as const,
    list: (popupId: string, params?: QueryParams) =>
      params
        ? (["orderItem", "list", popupId, params] as const)
        : (["orderItem", "list", popupId] as const),
    detail: (orderItemId: string) =>
      ["orderItem", "detail", orderItemId] as const,
  },
  ...
}

export const QUERY_KEYS = {
  ...
  ORDER_ITEM: {
    INDEX: queryKeys.orderItem.lists,
    LIST: (popupId: string, params?: QueryParams) =>
      queryKeys.orderItem.list(popupId, params),
    DETAIL: (orderItemId: string) => queryKeys.orderItem.detail(orderItemId),
  },
  ...
};

디렉토리 구조

queryKeys로 쿼리키의 기본 포맷을 정의하고, QUERY_KEYS로 실제 사용할 인터페이스를 제공하는 구조다.

(지금 생각해보면 기본 포맷 정의된 부분을 바로 인터페이스로 제공해도 상관없을 것 같다)

💡 어떻게 사용했는지?

적용한 코드를 살펴보면 다음과 같다.

Sample Code

export const useGetOrderListApi = ({
  size,
  popupId,
}: {
  size: number;
  popupId: number;
}) => {
  return useInfiniteQuery({
    queryKey: QUERY_KEYS.ORDER_ITEM.LIST(String(popupId), { size }),
    queryFn: ({ pageParam }) =>
      getOrderList({ lastOrderItemId: pageParam, size, popupId }),
    getNextPageParam: response => {
      const lastPage = response.data;

      if (lastPage.isLast) {
        return undefined;
      }

      const lastItem = lastPage.content[lastPage.content.length - 1];
      return lastItem.orderItemId;
    },
    initialPageParam: undefined as number | undefined,
  });
};

export const usePatchChangeOrderItemStatus = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ orderItemId, qty, status }: PatchChangeOrderItemRequest) =>
      patchChangeOrderItemStatus({ orderItemId, qty, status }),
    onSuccess: () =>
      queryClient.invalidateQueries({
        queryKey: QUERY_KEYS.ORDER_ITEM.INDEX(),
      }),
  });
};

이처럼 실제 쿼리키를 사용하는 곳에서는 QUERY_KEYS.ORDER_ITEM.INDEX() 와 같이 함수를 호출하여 쿼리키를 호출하는 방식으로 리팩토링을 진행할 수 있었다.

다시 돌아와서 이전에 가정했던 코드처럼 orderItem 을 캐시 무효화해야했다면, 디렉토리 내에서 여러 파일을 하나씩 찾아보는게 아니라 하나의 파일로 관리된 queryKey.ts 를 살펴보면 된다.

물론, 처음 정의하는 부분에서 쿼리키를 정의해야한다는 오버헤드가 있을 수 있으나 실제로 사용해보면 얻는 유지보수성이 더 높다고 생각한다.


OUTRO

처음에는 쿼리키를 정의하는 오버헤드가 있을 수 있지만, 실제로 사용해보니 얻는 유지보수성이 훨씬 높다고 느꼈다.

특히 중간 규모의 프로젝트에서 유지보수가 더 좋은 것 같다.

결국 엄청난 크기의 프로젝트가 된다면, 한 파일에서 관리한다고하더라도 당연히 복잡해지는건 똑같기 때문이다.

React Query를 사용하는 프로젝트라면 한 번쯤 고려해볼 만한 패턴이라고 생각한다!


참고

[React Query] querykey, mutationKey를 모듈화하여 관리하기

profile
코린이

0개의 댓글