공모전에 참가했던 우리 팀은 `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하도록 유도해야 합니다.
실행방법
- `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>
);
};
저희도 처음에는 이렇게 개발 초기에는 개발을 하다보니, 몇 가지 문제점들이 발생했습니다.
- `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']);
},
});
- queryKey 통합 관리가 어렵다.
- 특정 mutation 이후 `invalidate` 하는 `queryKey`를 찾기 위해 코드를 찾아다녀야 합니다.
- 새로운 `Query`의 `queryKey` 설정을 위해서는 기존에 사용되었던 값 들을 찾아다녀야 합니다
- 반복되는 `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 공식문서