blog

mutation 팩토리에 다중·동적 쿼리 무효화를 추가한 과정

Front-end / React#TanStack Query#useMutation#리팩터링#TypeScript

시작: 왜 이 훅들은 팩토리를 안 썼을까

프로젝트에는 mutation 훅을 만드는 공용 팩토리 createApiMutation이 있었다.

export const useChangePartStatus = createApiMutation({
  mutationFn: changePartStatus,
  successTitle: '상태 변경 완료',
  successDescription: '상태가 변경되었습니다.',
  errorTitle: '상태 변경 실패',
  invalidateKey: ASSETS_KEY, // 당시 시그니처
});

그런데 코드 항목 관리 훅들(등록·수정·삭제)은 팩토리를 쓰지 않고, 훅마다 거의 같은 코드를 반복하고 있었다.

export const useCreateCodeItem = (onSuccess?: () => void) => {
  const { toast } = useToast();
  const invalidateQueries = useInvalidateQueries();

  return useCustomApiMutation(createCodeItem, {
    onSuccess: ({ data }, { groupSeq }) => {
      if (data.code === CustomStatusCode.FAIL) {
        // 에러 토스트
        return;
      }
      invalidateQueries(queryKeys.codeItems(groupSeq));
      invalidateQueries(queryKeys.codeGroupList());
      onSuccess?.();
      toast({ title: '등록 완료' });
    },
  });
};
// 수정·삭제 훅도 거의 같은 코드

원인: 팩토리가 "고정된 키 하나"만 받았다

type ApiMutationConfig<TData, TVariables> = {
  mutationFn: (vars: TVariables) => Promise<AxiosResponse<CustomData<TData>>>;
  successTitle: string;
  successDescription: string;
  errorTitle: string;
  invalidateKey?: readonly unknown[]; // 정적 키 하나
};

코드 항목을 바꾸면 두 쿼리를 함께 갱신해야 했다.

  1. codeItems(groupSeq): 오른쪽 코드 목록
  2. codeGroupList(): 왼쪽 탭의 그룹별 개수(codeCount)

게다가 1번은 호출할 때 넘기는 groupSeq 에 따라 달라진다. 정적 배열 하나로는 "여러 키"와 "호출 시점의 값"을 둘 다 표현할 수 없었다.

설계: 값 대신 함수로 받기

무효화할 키 목록을 값이 아니라 계산하는 함수로 받도록 바꿨다.

type ApiMutationConfig<TData, TVariables> = {
  // ...
  invalidateKeys?: (
    data: CustomData<TData>,
    variables: TVariables,
  ) => readonly (readonly unknown[])[];
};

함수는 훅을 정의할 때 만들어지지만, 실제로는 mutation이 성공한 뒤 호출된다. 그 시점에는 variables가 확정되어 있으므로 codeItems(groupSeq) 같은 동적 키를 만들 수 있다.

// 팩토리 내부
onSuccess: async ({ data }, variables) => {
  if (data.code === CustomStatusCode.FAIL) {
    // 에러 토스트
    return;
  }

  const keys = invalidateKeys?.(data, variables) ?? [];
  await Promise.all(keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })));

  onSuccess?.(data);
  toast({ title: successTitle, description: successDescription, variant: 'success' });
},

invalidateQueries를 await하면 화면에 보이는 쿼리의 리패치가 끝난 뒤에 성공 토스트가 뜬다. 목록이 갱신되기 전에 "완료"가 먼저 보이는 어색함을 줄일 수 있다. 대신 리패치가 느리면 토스트도 늦어지므로, 즉시 피드백이 중요하다면 await 없이 실행만 걸어 두는 선택도 있다.

결과

const invalidateCodeKeys = (_: unknown, { groupSeq }: { groupSeq: number }) => [
  queryKeys.codeItems(groupSeq),
  queryKeys.codeGroupList(),
];

export const useCreateCodeItem = createApiMutation({
  mutationFn: ({ groupSeq, body }: { groupSeq: number; body: CreateCodeItemRequest }) =>
    createCodeItem(groupSeq, body),
  successTitle: '등록 완료',
  successDescription: '코드가 등록되었습니다.',
  errorTitle: '등록 실패',
  invalidateKeys: invalidateCodeKeys,
});
// 수정·삭제 훅도 invalidateCodeKeys 재사용

고정 키만 쓰던 기존 훅은 함수로 감싸기만 하면 된다.

invalidateKeys: () => [ASSETS_KEY],

기존 invalidateKey와 새 invalidateKeys를 함께 두지 않고 새 옵션으로 완전히 대체했다. 같은 일을 하는 옵션이 두 개면 "이럴 땐 무엇을 쓰지?"라는 고민이 생기고, 둘 다 넘겼을 때의 동작도 정의해야 한다.

정리

  • 정적 키 하나(invalidateKey: readonly unknown[])를 함수(invalidateKeys: (data, variables) => keys[])로 바꿔 다중 키와 동적 키를 모두 표현했다.
  • 공용 팩토리가 필요한 기능을 지원하지 않을 때, 바로 우회 구현을 하기보다 팩토리를 확장할 수 있는지 먼저 본다.
  • 확장할 때는 옵션을 늘리기보다 기존 옵션을 더 일반적인 형태로 대체해서 API 표면을 작게 유지한다.

참고