23. Mutation 처리 패턴
23. Mutation 처리 패턴

23. Mutation 처리 패턴

Authors
Date
Tags
frontend
Published
Published
Slug
📝
주관적인 생각이 많이 담겨있는 글입니다
 

Mutation이란 무엇인가?

서버에 데이터를 변경 요청하는 작업을 "mutation"이라고 부른다.
예를들어, 사용자가 게시판에서 게시글을 작성하고, ‘작성 완료’ 버튼을 눌렀을때 이뤄지는 작업을 말한다.
구현 방식에 따라 다르겠지만, 이 단순해 보일수도 있는 작업에서 아래와 같은 흐름이 나타난다
1. 사용자가 제목, 게시글 내용 등을 입력함 (필드 입력) 2. 사용자가 '작성 완료' 버튼을 클릭함 (submit) 3. 입력된 게시글이 즉각적으로 등록된것으로 보이도록 업데이트함 (optimistic update) 4. 입력된 필드에 문제가 있으면 에러를 표시함 (validation error) 5. 서버에서 에러가 발생하면 에러를 표시함 (error) 6. 4또는 5이 만족하면 3에서 수행한ㄷ 업데이트를 롤백함 (rollback) 7. 입력된 정보가 서버에 저장됨 (mutation)
 
 
게시글 등록을 예로 들었지만, form을 사용하는 다른 mutation작업에서도 위의 흐름과 크게 다르지 않은 경우가 다수 존재한다.
ex) 댓글 등록, 프로필 수정 등
 
흐름을 잘 살펴보면, 다른 mutation작업에서도 공통적으로 수행되어야하는 부분이 있다.
2~7번을 자동적으로 수행하도록 구현할 수 있다면, 다른 mutation작업도 쉽게 구현할 수 있겠다는 아이디어가 이번 글의 핵심 내용이다.
 
 
내용을 살펴보면 수행되야 하는 작업은 크게 두가지로 생각해볼 수 있다.
  1. 캐시 업데이트 (+낙관적 업데이트)
  1. 에러 표시
 

 

캐시 업데이트란?

캐시 업데이트를 수행하지 않는다면, 새롭게 업데이트된 항목이 있어도 이전의 데이터를 보여주게 된다.
📝
예시:
  1. 게시글을 새로 등록함
  1. 새로운 데이터가 있지만, 게시글 리스트는 업데이트 되지 않고 이전의 데이터들만 보여줌
  • 기본 예제
이를 해결하기 위한 대중적인 방법으로, queryClient.invalidateQueries로 쿼리를 stale상태로 만들어 refetch를 유도할 수 있다.
 
하지만 이방식에서는 게시글 등록과 refetch 총 두번의 네트워크 왕복이 발생하게 된다.
  • invalidate-queries 예제
notion image
refetch 대신에 post response body를 활용하여 캐시의 업데이트를 수행한다면, 한번의 네트워크 왕복으로 수행이 가능하다.
notion image
  • 캐시 업데이트 예제
 
추가로 아래와 같이 낙관적 업데이트를 수행할 수도 있다.
notion image
  • 낙관적 업데이트 예제

구현 1

캐시 업데이트 기능을 수행하는 추상화 훅을 아래와 같이 구현할 수있다
구현 코드
import { QueryKey, useMutation, UseMutationOptions } from '@tanstack/react-query'; import { FieldValues } from 'react-hook-form'; import { nanoid } from 'nanoid'; import queryClient from './query-client'; export type SetErrorFunction<T> = (name: keyof T | 'root', error: { type: string; message: string }) => void; type MutationContext<TReq> = { prevData?: any; optimisticId?: string; optimisticReq?: TReq; }; /** * 낙관적 업데이트와 form에서의 에러 핸들링이 추상화된 훅입니다. * @param queryKey queryKey가 있다면 낙관적 업데이트를 수행합니다. * @param mutationFn * @param setError form의 setError를 전달받아 서버의 validation에러 응답을 특정 필드까지 전달할 수 있습니다. * @param options onSuccess, onError등 option을 오버라이드 할 수 있습니다. * @param onSuccessCallback onSuccess 콜백 * @param disableOptimisticUpdate 낙관적 업데이트 비활성화 여부 * @param isCollection 콜랙션 데이터를 다루는지에 대한 여부입니다. */ export function useSmartMutation<TRequest extends FieldValues, TResponse>({ queryKey, mutationFn, setError, options, onSuccessCallback, disableOptimisticUpdate = false, isCollection = false, }: { queryKey?: QueryKey; mutationFn: (data: TRequest) => Promise<TResponse>; setError?: SetErrorFunction<TRequest>; options?: Omit<UseMutationOptions<TResponse, any, TRequest, MutationContext<TRequest>>, 'mutationFn'>; onSuccessCallback?: (response: TResponse) => void; disableOptimisticUpdate?: boolean; isCollection?: boolean; }) { const mutationOptions: UseMutationOptions<TResponse, any, TRequest, MutationContext<TRequest>> = { mutationFn, ...(queryKey && { ...(!disableOptimisticUpdate && { /** ---------- optimistic ---------- */ onMutate: async (updatedData) => { await queryClient.cancelQueries({ queryKey }); const prevData = queryClient.getQueryData<any>(queryKey); if (isCollection && prevData) { const optimisticId = nanoid(); const optimisticItem = { ...updatedData, __optimisticId: optimisticId }; queryClient.setQueryData(queryKey, [...prevData, optimisticItem]); return { prevData, optimisticId, optimisticReq: updatedData }; } else if (!isCollection && prevData) { queryClient.setQueryData(queryKey, { ...prevData, ...updatedData, }); } return { prevData }; }, }), /** ---------- success ---------- */ onSuccess: (response, variable, context) => { if (isCollection && queryKey) { queryClient.setQueryData<any[]>(queryKey, (old = []) => old.map((item) => (item.__optimisticId === context?.optimisticId ? response : item)) ); } else if (queryKey) { queryClient.setQueryData<TResponse>(queryKey, (old) => { if (!old) return old; return { ...old, ...response, }; }); } if (onSuccessCallback) onSuccessCallback(response); }, }), /** ---------- error / rollback ---------- */ onError: (error, variable, context) => { // rollback if (context?.prevData && queryKey) { queryClient.setQueryData(queryKey, context.prevData); } console.error('error', error); }, ...options, }; return useMutation(mutationOptions); }
 

+ isCollection은 무엇인가?

캐시 업데이트 및 낙관적 업데이트를 수행할때 각 쿼리에 데이터가 콜랙션인지, 단건인지에 따라 구현에 차이가 생긴다.
// 콜랙션 // GET /api/post [ { "id": 1, "title": "mutation 패턴", "description" : "..." }, { "id": 2, "title": "useSuspenseQuery란?", "description" : "..." }, { "id": 3, "title": "use 훅", "description" : "..." }, ] // 단건 // GET /api/post/1 { "id": 1, "title": "mutation 패턴", "description" : "..." }
 
콜랙션에서 새로운 데이터가 추가된다면, 리스트에 기존 데이터들을 남겨두고 새로운 데이터를 추가한다.
[ { // 기존 데이터들은 남기고 "id": 1, "title": "mutation 패턴", "description" : "..." }, { "id": 2, "title": "useSuspenseQuery란?", "description" : "..." }, { "id": 3, "title": "use 훅", "description" : "..." }, { // <-- 마지막에 새로 추가함 "id": 4, "title": "서버컴포넌트", "description" : "..." } ]
단건에서 새로운 데이터가 추가된다면 기존의 데이터를 대체하는 방식으로 구현하기 위해
콜랙션 데이터 여부에 따라 별도로 분기처리를 하여 구현하였다.
 
이제 캐시 업데이트 및 낙관적 업데이트를 수행하는 mutation을 아래와 같이 사용할 수 있다.
const mutation = useSmartMutation({ queryKey: ['posts'], mutationFn: createPost, isCollection: true }); const onSubmit = (data: CreatePostInput) => { mutation.mutate(data); };
 
 
 

결론

  • 여러 mutation과정에서 비슷한 흐름이 나타난다.
  • 대표적으로 캐시 업데이트와 낙관적 업데이트, 에러 핸들링이 있다.
  • 이 과정들을 하나의 패턴으로 정의하고 한번에 처리할수 있는 추상화 훅을 만든다면, 효율적으로 mutation을 구현할 수 있다.