공모전에 참가했던 우리 팀은 `Tanstack Query`를 사용하면서 `caching` 및 `mutation`에 사용되는 `queryKey`, `mutationKey`를 효과적으로 관리하는 방법에 대해 고민을 많이 하였습니다.

1. 문제인식

1. `useMutation`으로 인해 컴포넌트가 너무 길어져 가독성을 저해한다.
2. 각 컴포넌트에서 키를 선언하면 통합 및 관리가 어렵다
3. 같은 `mutation`을 사용하는 경우, 컴포넌트마다 선언해줘야 한다.

 

이러한 문제를 해결하기 위해 저희 팀은 모듈화를 통한 Key 관리 를 진행하였습니다.

 

어떻게 모듈화를 진행했는지 설명해보겠습니다.

 


2. 기본 Tanstack Query 호출

useMutation
공식문서
queries와는 다르게, mutations는 생성(POST) / 수정(PATCH) / 삭제(DELETE) 작업을 수행할 때 사용됩니다.

 

`useMutation`을 사용하여 비동기 호출을 하면 다음과 같은 코드로 작성됩니다.

const App = ():ReactNode => {
  const { data, isPending, isSuccess, isError, error, mutate } = useMutation({
    mutationFn: newTodo => {
      return axios.post('/todos', newTodo)
    },
  })

  let content
  if (isPending) {
    content = 'Adding todo...'
  }
  if (isError) {
    content = <div>An error occurred: {error.message}</div>
  }
  if (data) {
    if (isSuccess) content = <div>Todo added!</div>
  }

  return (
    <>
      <button
        onClick={() => {
          mutate({ id: new Date(), title: 'Do Laundry' })
        }}
      >
        Create Todo
      </button>
      {content}
    </>
  )
}

 

하지만, 대부분의 경우 `mutation`은 `queries`와 함께 사용됩니다.

 

예를 들어, `Todo`의 정보를 데이터베이스에 저장하는 위의 코드에서, 한 가지 기능을 추가해보겠습니다.

유저의 기존 `Todo`를 불러와서 렌더링하고, 새로운 `Todo`를 생성하면 불러온 값을 `Update`하는 코드로 수정해야 한다면 어떻게 할까요?

 

invalidateQuries를 통해 caching된 값을 stale 상태로 만들어, Refetch하도록 유도해야 합니다.

 

실행방법

  1. `useQuery`를 통해 데이터를 불러와야 합니다.
  2. 유저가 `Todo`를 추가하면 `useMutation`을 호출하고, 데이터베이스를 업데이트합니다.
  3. 기존에 페칭했던 `useQuery` 데이터를 `invalidate` 해서 refetch를 유도해야 합니다.

(주석을 따라가며 이해해 보세요)

const Todo = () => {
  const queryClient = useQueryClient();

  // 1. useQuery로 Todo 데이터 불러오기
  const { data: todos, isLoading, isError } = useQuery(['todos'], fetchTodos);

  // 2. useMutation으로 Todo 추가
  const mutation = useMutation(addTodo, {
    onSuccess: () => {
      // 3. 데이터를 invalidate하여 최신 상태를 가져오기
      queryClient.invalidateQueries(['todos']);
    },
  });

  // mutate 사용처
  const handleAddTodo = () => {
    const newTodo = { title: `Todo ${Date.now()}` };
    mutation.mutate(newTodo);
  };

  if (isLoading) return <p>Loading...</p>;
  if (isError) return <p>Error loading todos</p>;

  return (
    <div>
      <h1>Todo List</h1>
      <ul>
        {todos.map((todo: { id: number; title: string }) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <button onClick={handleAddTodo} disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Adding...' : 'Add Todo'}
      </button>
    </div>
  );
};

 

저희도 처음에는 이렇게 개발 초기에는 개발을 하다보니, 몇 가지 문제점들이 발생했습니다.

  1. `useMutation`으로 인해 컴포넌트가 너무 길어져 가독성을 저해한다.
    • 모든 `Mutation` 마다 `invalidateQueries`를 해주는 행위는 상당히 번잡합니다.
    • `useMutation`의 `onSuccess`, `onError`에 모달, 토스트 띄우기 등 로직을 추가하다 보면, 코드가 길어집니다.
    • 혹여나, 하나의 컴포넌트에서 여러개의 `Mutation`을 사용한다면, 컴포넌트는 겉잡을 수 없을 정도로 길어집니다.
    // 하나의 컴포넌트에서 Mutation이 너무 많은 경우
    // 1. Todo 추가
    const { mutate : addTodoMutation }= useMutation(addTodo, {
      onSuccess: () => {
        queryClient.invalidateQueries(['todos']);
      },
    });
    
    // 2. Todo 수정
    const { mutate : updateTodoMutation }= useMutation(updateTodo, {
      onSuccess: () => {
        queryClient.invalidateQueries(['todos']);
      },
    });
    
    // 3. Todo 삭제
    const { mutate : deleteTodoMutation }= useMutation(deleteTodo, {
      onSuccess: () => {
        queryClient.invalidateQueries(['todos']);
      },
    });
    
    
  2. queryKey 통합 관리가 어렵다.
    • 특정 mutation 이후 `invalidate` 하는 `queryKey`를 찾기 위해 코드를 찾아다녀야 합니다.
    • 새로운 `Query`의 `queryKey` 설정을 위해서는 기존에 사용되었던 값 들을 찾아다녀야 합니다
  3. 반복되는 `mutation`을 계속해서 선언해주어야 한다.
    • 좋아요, 스크랩을 추가하고 삭제하는 기능을 개발한다고 할 때, 좋아요를 누르는 행위는 많은 페이지에서 동일하게 이루어졌습니다.
    • 모든 컴포넌트에서 동일한 `useMutation`을 선언하여 사용해야 했습니다. 이는 매우 번거롭고 귀찮은 행위입니다. (유지보수에 최악)
    • 이때, 같은 `mutation`은 같은 `invalidate`를 수행해야 하므로, 반복 코드가 발생할 뿐만 아니라, 실수의 가능성도 있습니다.

 

💡 이러한 이유로, queryKey와 mutationKey, useMutation 함수를 모두 하나의 파일에서 관리하는 모듈화 방법을 사용하였습니다.


3. 모듈화 방법

1. Key 관리 (QueryKey, MutationKey)

QueryKey

  • `QueryKey`는 `caching`된 값에 접근할 수 있는 매우 중요한 값입니다.
  • 매번 접근할 때마다 잘못된 값에 접근하지 않고 편리하게 접근하기 위해 객체로 관리합니다.
// cache-key.ts
export const QUERY_KEYS = {
  USER: {
    PLANS: {
      INDEX: ['plans', 'user'],
      SCRAP: ['plans', 'user', 'scrap'],
    },
    PLAN: {},
    PLACES: {
      SCRAP: ['places', 'user', 'scrap'],
    },
    PLACE: {},
  },
  GENERAL: {
    PLANS: {
      INDEX: ['plans'],
    },
    PLAN: {
      INDEX: (planId: number) => ['plan', planId],
    },
    PLACES: {
      INDEX: ['places'], // 일반 여행지
    },
  },
}

MutationKey

  •  `mutationKey`는 `queryKey` 보다는 중요하지 않습니다. `mutationKey`는 `mutation`을 구분하거나, `useMutationState`를 통해 이전 `mutation` 값을 불러오는데 사용됩니다.
  • `mutation`에는 항상 비동기 함수가 매핑됩니다. 따라서, `mutationKey` 값 이외에 비동기 함수도 같이 값으로 저장해주었습니다.
// todo가 아닌, 실제 사용했던 코드로 대체합니다
// cache-key.ts
export const MUTATION_KEYS = {
  PLAN: {
    CREATE: {
      key: ['createPlan'],
      fc: createPlan, // 비동기 함수
    },
    UPDATE: {
      key: ['updatePlan'],
      fc: updatePlan,
    },
     ...
    },
    SCRAPS: {
      ADD: {
        key: ['addPlanScrap'],
        fc: addPlanScrap,
      },
      DELETE: {
        key: ['deletePlanScrap'],
        fc: deletePlanScrap,
      },
    },
    COMMENTS: {
      ADD: {
        key: ['addPlanComment'],
        fc: addPlanComment,
      },
    },
  },
} as const

💡setDefaultMutation으로 미리 useMutation 정의하기

setDefaultMutation 공식문서

 

왜 `setDefaultMutation`을 사용하나요❓
`setDefaultMutation`은 `mutationKey`와 `mutationFn`을 연결하여 미리 선언된 `queryClient`에 정의할 수 있습니다.

⭐이후, 컴포넌트에서 `mutationKey`만 으로 간편하게 `mutation`에 접근 가능합니다.⭐

 

어떻게 선언하나요?

 

1. 아래와 같이 미리 `mutation`에 `mutationKey`와 `mutationFn`이 연결되도록 선언합니다.

// #1. Plan
queryClient.setMutationDefaults(MUTATION_KEYS.PLAN.CREATE.key, {
  mutationFn: MUTATION_KEYS.PLAN.CREATE.fc,
  onSuccess(data, variables, context) {
    queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER.PLANS.INDEX })
  },
  onError: () => {
    toast({ title: '서버 오류 다시 시도해주세요' })
  },
})

 

2. `useMutation`을 사용하고자 하는 컴포넌트에서 `mutationKey`만 지정해주면 간편하게 사용 가능합니다.

const { mutate: createPlanMutate, isPending: isCreating } = useMutationStore<CreatePlanType>(MUTATION_KEYS.PLAN.CREATE.key)

 

 `useMutationStore`은 무엇인가요

`useMutationStore`는 Tanstack Query의 기본 함수가 아닙니다.

useMutation의 Response Type을 지정하고 (백엔드 팀과 Response 형태 맞추기), 비동기 호출의 매개변수 variables의 Type을 재네릭으로 지정하여 사용한 함수입니다.

/**
 * Type T에서 key K의 value들로 이루어진 Type 생성하는 유틸함수 (재귀)
 */
export type ExtractValueByKey<T, K extends string> = T extends { [key in K]: infer V }
  ? V
  : {
      [key in keyof T]: ExtractValueByKey<T[key], K>
    }[keyof T]

// 위에서 정의한 MUTATION_KEYS의 key값들을 유니온 값으로 갖는 Type
export type MutationKeyType = ExtractValueByKey<typeof MUTATION_KEYS, 'key'>

export const useMutationStore = <T>(mutationKey: MutationKeyType) => {
  return useMutation<SuccessResponse, Error, T, unknown>({ mutationKey })
}

  • 제너릭 타입으로 제공하는 Variables Type은 해당 useMutation이 사용하는 비동기 함수의 객체 형태로 제공하는 매개변수의 Type입니다.
// 객체 형태의 함수 매개변수 Type
export interface CreatePlanType {
  state: StateType
  startDate: Date
  endDate: Date
  accessToken: string
}
/**
 * 새로운 여행 계획 만들기
 * @param param0 `{state: 지역, startDate: Date, endDate: Date, accessToken: string}`
 * @returns data: `{planId: number}`
 */
export const createPlan = async ({ state, startDate, endDate, accessToken }: CreatePlanType) => {
  let backendRoute = BACKEND_ROUTES.PLAN.CREATE
  const body = {
    state: state,
    startDate: formatDateToHyphenDate(startDate),
    endDate: formatDateToHyphenDate(endDate),
  }

  const res = await fetch('/server' + backendRoute.url, {
    method: backendRoute.method,
    headers: {
      Authorization: accessToken,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
    credentials: 'include',
  })
  ... api logic
  
}

 

참고) `useMutation`의 기본 Type 선언을 참고하여 변형하였습니다.

// 기본 타입선언
// useMutation.d.ts
import { UseMutationOptions, UseMutationResult } from './types.js';
import { DefaultError, QueryClient } from '@tanstack/query-core';

declare function useMutation<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown>(options: UseMutationOptions<TData, TError, TVariables, TContext>, queryClient?: QueryClient): UseMutationResult<TData, TError, TVariables, TContext>;

export { useMutation };

 


4. 최종 형태

이렇게 하면, 초반에 작성했던 코드는 다음과 같이 매우 짧고 가독성이 좋은 코드로 바뀝니다.

const Todo = () => {
  const queryClient = useQueryClient();

  // 1. useQuery로 Todo 데이터 불러오기
  const { data: todos, isLoading, isError } = useQuery(['todos'], fetchTodos);

  // 2. useMutation으로 Todo 추가 (매우 짧아짐)
  const {mutate, isPending} = useMutationStore<addTodoType>(MUTATION_KEYS.TODO.ADD.key)

  // 새로운 Todo 추가 핸들러
  const handleAddTodo = () => {
    const newTodo = { title: `Todo ${Date.now()}` };
    mutation.mutate(newTodo);
  };

  if (isLoading) return <p>Loading...</p>;
  if (isError) return <p>Error loading todos</p>;

  return (
    <div>
      <h1>Todo List</h1>
      <ul>
        {todos.map((todo: { id: number; title: string }) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <button onClick={handleAddTodo} disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Adding...' : 'Add Todo'}
      </button>
    </div>
  );
};

💡 로직 분리하기

개발을 진행하며, 유지보수를 위해 `caching` 로직과 컴포넌트에서 사용될 로직을 구분지어 사용하였습니다.

cache-key.ts`에서의 `onSuccess`와 개별 클라이언트의 onSuccess의 용도를 구분해서 사용했습니다.

컴포넌트의 mutate의 onSuccess(onError)가 cache-key.ts의 onSuccess(onError) 보다 먼저 실행됩니다.
// 컴포넌트
const {mutate, isPending} = useMutationStore<addTodoType>(MUTATION_KEYS.TODO.ADD.key)
  
const handleAddTodo = () => {
  const newTodo = { title: `Todo ${Date.now()}` };
  mutation.mutate(newTodo, {
	  // 실행 1번
	  onSuccess() {
	  	setState(어떤 값으로 바꾼다)
        router.push(어디로 이동한다)
	  }
  });
};

// cache-key.ts
queryClient.setMutationDefaults(MUTATION_KEYS.PLAN.CREATE.key, {
  mutationFn: MUTATION_KEYS.PLAN.CREATE.fc,
  // 실행 2번
  onSuccess(data, variables, context) {
    queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER.PLANS.INDEX })
  },
})​
  • 특정 `mutation`이 공통적으로 수행하는 `invalidate`과 같은 행위는 `cache-key.ts`에서 다루며
  • `state`관리 등의 컴포넌트 단위 프로세스는 컴포넌트 내부에서 선언한 `useMutationStore`의 리턴 값인 `mutate` 함수의 `onSuccess`, `onError`에서 관리합니다.

  • 컴포넌트 `mutate`: state 변화, toast 처리, routing 등을 처리
  • `cache-key.ts` : invalidateQuries 등 caching 처리

 


참고 문헌

Tanstack Query useMutation 공식문서

Tanstack Query setMutaitonDefault 공식문서

Tanstack Query invalidate Query 공식문서

+ Recent posts