Programming/React

(React-Query) useMutation 로직 커스텀 훅으로 리팩토링하기

soyeori 2024. 11. 28. 18:29

프로젝트 [리스티웨이브]를 하면서 점점 기능이 늘어남에 따라 API도 늘어나고 있다. 리액트쿼리를 이용해서 서버 상태를 동기화해주고 있는데 useMutation을 사용해 API를 호출할 때 다음과 같이 사용하고 있다. 

  // 삭제 뮤테이션
  const deleteMutation = useMutation({
    mutationFn: deleteNotice,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.getAdminAllNotice] });
    },
  });

  // 알림 보내기 뮤테이션
  const sendAlarmMutation = useMutation({
    mutationFn: sendNoticeAlarm,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.getAdminAllNotice] });
    },
  });

  // 비공개, 공개 옵션 수정 뮤테이션
  const updatePublicMutation = useMutation({
    mutationFn: updateNoticePublic,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.getAdminAllNotice] });
    },
  });

 

코드를 보다 보니 중복되는 부분이 많고, 동일한 API를 다른 컴포넌트에서 사용할 때 똑같은 로직을 반복해서 적어줘야 하는 불편함이 있었다. 공통 로직을 분리하여 재사용성을 높이기 위해 리액트쿼리 useMutation 훅을 커스텀훅으로 만들어 리팩토링해 보기로 했다.

 

Mutation Side Effects

뮤테이션의 사이드이펙트, 즉 옵션으로는 여러 가지 옵션(optional)들이 있고, 주요 속성으로 다음과 같은 함수가 주로 사용된다.

  1. onMutate :
    • 뮤테이션이 실행되기 전에 실행되며, 뮤테이션에서 받는 것과 동일한 변수가 전달된다. 뮤테이션 전에 실행되기 때문에 낙관적 업데이트를 할 때 이용되는 함수이다.
  2. onError :
    • 뮤테이션이 실패하면 실행되고, 에러를 전달받을 수 있다.
  3. onSuccess :
    • 뮤테이션이 성공하면 실행된다. 또한, 뮤테이션의 결괏값으로 data(TData)를 전달받을 수 있다.
  4. onSettled :
    • 뮤테이션 결과에 상관없이 뮤테이션 성공 또는 실패 시 실행된다. 그러므로 뮤테이션 결과와는 상관없이 쿼리를 무효화하는 로직이 필요할 때 해당 함수에 작성한다.

 

이들 옵션의 타입은 리액트쿼리에서 UseMutationOptions 타입을 제공하고 있는데, UseMutationOptions을 살펴보면, MutationObserverOptions 타입을 확장한(extends) 타입임을 알 수 있다.

// UseMutationOptions 타입
interface UseMutationOptions<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown> extends Omit<MutationObserverOptions<TData, TError, TVariables, TContext>, '_defaulted' | 'variables'> {
}

 

MutationObserverOptions을 또 타고 올라가 보면 최종 MutationOptions 타입에서 시작된 것을 알 수 있었다.

또한, MutationOptions 타입은 TData, TError, TVariables, TContext의 제네릭 타입을 매개변수로 받고, MutationOptions 타입의 속성은 mutationKey, 위에서 나열한 주요 속성 외에 retry, gcTime 등 뮤테이션 옵션들이 들어있는 것을 확인할 수 있다.

// MutationOptions 타입
interface MutationOptions<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown> {
    mutationFn?: ...
    mutationKey?: ...
    onMutate?: ...
    onSuccess?: ...
    onError?: ...
    onSettled?: ...
    ...
}

 

✔️ 첫 번째 방법,

처음에는 mutationFn을 지정하고, 나머지 옵션(onError 등)을 추가할 수 있는 옵션들을 받을 수 있도록 적용해 보았다. 이때, mutationFn을 지정해 두었기 때문에 뮤테이션 옵션만 타입을 지정해 주었다.

// useMutation 옵션을 사용하기 위한 커스텀 타입
type UseMutationOptionsType<TData = unknown, TError = unknown, TVariables = unknown> = Omit<
  UseMutationOptions<TData, TError, TVariables>,
  'mutationFn'
>;

const useCustomMutation = (mutationOptions?: UseMutationOptionsType) => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: deleteNotice,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.getApi] });
    },
    ...mutationOptions,
  });
};

 

이 방법도 기존에 중복된 로직을 분리하기에 괜찮았지만 쿼리를 업데이트하는 부분(invalidateQueries)이 여전히 중복되었다. 그래서 mutationFn까지 받을 수 있도록 수정해 보았다.

 

✔️ 두 번째 방법,

mutationFn과 나머지 옵션까지 매개변수로 받을 수 있도록 적용해서 하나의 훅으로 만들었다.

const useCustomMutation = <TData = unknown, TError = unknown, TVariables = unknown>(
  mutationFn: (variables: TVariables) => Promise<TData>, // ✅ 추가
  mutationOptions?: Omit<UseMutationOptions<TData, TError, TVariables>, 'mutationFn'>
) => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.getApi] });
    },
    ...mutationOptions,
  });
};

 

이렇게 개선해 보니 확실히 처음에 useMutation을 한 곳에서 여러 번 사용했을 때보다 가독성이 좋아졌다. 또한, 경우에 따라 필요한 옵션을 추가로 받아을 수 있으므로 유연성과 재사용성을 높일 수 있고, 유지보수 하기도 쉬워졌다. 최종 만들어 둔 커스텀 훅은 각각의 API를 호출하는 부분을 하나의 훅으로 만들어서 한 곳에서 불러오게끔 작성해 주었다.

// React-query의 useMutation을 wrapping해주는 Notice 관련 custom hook
const useNotice = () => {
  const deleteMutation = useCustomMutation(deleteApi);
  const sendAlarmMutation = useCustomMutation(sendAlarmApi);
  const updateMutation = useCustomMutation(updateApi);

  return {
    deleteMutation,
    sendAlarmMutation,
    updateMutation,
  };
};

// 사용처
const { deleteMutation, sendAlarmMutation, updateMutation } = useNotice();

// deleteMutation.mutate()

 

Trigger additional callbacks

useMutation에 정의한 콜백 외에 추가로 콜백함수를 실행하려면 순서를 염두에 두어야 한다. 추가로 받은 mutationOption에서 onSuccess 함수를 전달한다면 useMutation에서 정의해 둔 onSuccess 함수에 덮어씌워질 수 있다.

// React-query 콜백 실행 순서
useMutation({
  mutationFn: addTodo,
  onSuccess: (data, variables, context) => {
    // I will fire first
  },
  ...
})

mutate(todo, {
  onSuccess: (data, variables, context) => {
    // I will fire second!
  },
  ...
})

 

그러므로 mutate를 실행할 때 필요한 콜백을 추가해야지 정상적으로 실행될 수 있다.

// 사용처
const { deleteMutation, sendAlarmMutation, updateMutation } = useNotice();

deleteMutation.mutate(id, {
  onSuccess: () => alert('알림을 보냈어요.'), // ✅ mutationOptions 콜백 추가
})

 

빌드 시 타입 에러 해결

로컬에서는 문제가 없었지만 빌드 시 아래와 같은 타입에러가 발생했다.

 

The inferred type of 'useNotice' cannot be named without a reference to '../../../node_modules/@tanstack/react-query/build/modern/types'. This is likely not portable. A type annotation is necessary.

 

이 문제는 useNotice 훅에서 반환된 값을 TypeScript가 추론하면서 외부 모듈(../../../node_modules/...)의 경로를 참조하는 타입을 생성하려고 시도하다가 타입을 찾지 못하여 빌드 시 오류를 발생시키게 된 것이다. 빌드 오류를 해결하기 위해 useNotice 훅에서 반환된 값의 타입을 명시적으로 선언해 주었다.

 

useCustomMutation 훅에 마우스를 올려보면 반환값의 타입은 리액트쿼리의 UseMutationResult<TData, TError, TVariables, unknown> 타입임을 알 수 있다. 이 타입을 바탕으로 useNotice 훅의 타입을 다음과 같이 정의하니 빌드 시 타입에러가 해결되었다.

// useCustomMutation이 반환하는 타입은 UseMutationResult<TData, TError, TVariables, unknown> 타입으로,
// useNotice 훅이 반환하는 각 함수의 반환값(void), 에러, 변수 타입(number), context 타입을 정의해주었다.
type UseNoticeReturnType = {
  deleteMutation: UseMutationResult<void, unknown, number, unknown>;
  sendAlarmMutation: UseMutationResult<void, unknown, number, unknown>;
  updateMutation: UseMutationResult<void, unknown, number, unknown>;
};

// React-query의 useMutation을 wrapping해주는 Notice 관련 custom hook
const useNotice = (): UseNoticeReturnType => {
  ...
};

 

 

 

이번 기회에 프로젝트의 모든 리액트쿼리 사용 부분에 대해서 리팩토링 해봐야겠다. 👀

 

나의 취향 탐색

 


참고

리액트쿼리 useMutation