
공모전에 참가했던 우리 팀은 Tanstack Query를 사용하면서 caching 및 mutation에 사용되는 queryKey, mutationKey를 효과적으로 관리하는 방법에 대해 고민을 많이 하였습니다.
TL;DR
useMutation으로 인해 컴포넌트가 너무 길어진다.- 키 통합 관리가 어렵다
- 같은
mutation을 사용하는 경우, 컴포넌트마다 선언해줘야 한다.
이를 해결하기 위해, 모듈화를 통한 Key 관리 를 진행하였습니다.
어떻게 모듈화를 진행했는지 설명해보겠습니다.
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하도록 유도해야 합니다.
실행방법
useQuery를 통해 데이터를 불러와야 합니다.Todo를 추가하면 useMutation을 호출하고, 데이터베이스를 업데이트합니다.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>
);
};
컴포넌트가 지나치게 길어져 가독성이 저해된다.
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']);
},
});
반복되는 mutation을 계속해서 선언해주어야 한다.
useMutation을 선언하여 사용해야 했습니다. 이는 매우 번거롭고 귀찮은 행위입니다. (유지보수에 최악)mutation은 같은 invalidate를 수행해야 하므로, 반복 코드가 발생할 뿐만 아니라, 실수의 가능성도 있습니다.queryKey 통합 관리가 어렵다.
invalidate 하는 queryKey를 찾기 위해 코드를 찾아다녀야 합니다.Query의 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는 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을 사용하나요?
setDefaultMutation은 mutationKey와 mutationFn을 연결하여 미리 선언된 queryClient에 정의할 수 있습니다.
⭐이후, 컴포넌트에서 mutationKey만 으로 간편하게 mutation에 접근 가능합니다.
어떻게 선언하나요?
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: '서버 오류 다시 시도해주세요' })
},
})
useMutation을 사용하고자 하는 컴포넌트에서 mutationKey만 지정해주면 간편하게 사용 가능합니다.const { mutate: createPlanMutate, isPending: isCreating } = useMutationStore<CreatePlanType>(MUTATION_KEYS.PLAN.CREATE.key)
❓ 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 };
이렇게 하면, 초반에 작성했던 코드는 다음과 같이 매우 짧고 가독성이 좋은 코드로 바뀝니다.
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 처리https://tanstack.com/query/v4/docs/framework/react/reference/useMutation
https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientsetmutationdefaults
https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation