🔥 뮤테이션(Mutations)

1631자
18분

쿼리와 달리, 뮤테이션은 주로 데이터를 생성, 수정, 삭제하거나 서버 측 부작용을 수행하는 데 사용합니다. 이를 위해 TanStack Query는 useMutation 훅을 제공합니다.

다음은 서버에 새로운 할 일을 추가하는 뮤테이션의 예제입니다:

function App() {
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return axios.post('/todos', newTodo)
    },
  })
 
  return (
    <div>
      {mutation.isPending ? (
        ' 일을 추가하는 ...'
      ) : (
        <>
          {mutation.isError ? (
            <div>오류가 발생했습니다: {mutation.error.message}</div>
          ) : null}
 
          {mutation.isSuccess ? <div> 일이 추가되었습니다!</div> : null}
 
          <button
            onClick={() => {
              mutation.mutate({ id: new Date(), title: '빨래하기' })
            }}
          >
              만들기
          </button>
        </>
      )}
    </div>
  )
}
 
typescript

뮤테이션은 주어진 시점에 다음 상태 중 하나만 가질 수 있습니다:

  • isIdle 또는 status === 'idle' - 뮤테이션이 현재 대기 중이거나 초기 상태입니다.
  • isPending 또는 status === 'pending' - 뮤테이션이 현재 실행 중입니다.
  • isError 또는 status === 'error' - 뮤테이션 중 오류가 발생했습니다.
  • isSuccess 또는 status === 'success' - 뮤테이션이 성공적으로 완료되었고 데이터를 사용할 수 있습니다.

이러한 주요 상태 외에도 뮤테이션의 상태에 따라 더 많은 정보를 얻을 수 있습니다:

  • error - 뮤테이션이 오류 상태일 때, error 속성을 통해 오류 정보에 접근할 수 있습니다.
  • data - 뮤테이션이 성공 상태일 때, data 속성을 통해 데이터에 접근할 수 있습니다.

위 예제에서 볼 수 있듯이, mutate 함수를 호출할 때 단일 변수나 객체를 전달하여 뮤테이션 함수에 변수를 전달할 수 있습니다.

변수만으로는 뮤테이션이 특별하지 않지만, onSuccess 옵션, Query Client의 invalidateQueries 메서드, Query Client의 setQueryData 메서드와 함께 사용하면 뮤테이션은 매우 강력한 도구가 됩니다.

중요: mutate 함수는 비동기 함수이므로 React 16 이하 버전에서는 이벤트 콜백에서 직접 사용할 수 없습니다. onSubmit에서 이벤트에 접근해야 한다면 mutate를 다른 함수로 감싸야 합니다. 이는 React 이벤트 풀링 때문입니다.

다음은 React 16 이하 버전에서 작동하지 않는 예와 작동하는 예입니다:

// React 16 이하에서는 작동하지 않습니다
const CreateTodo = () => {
  const mutation = useMutation({
    mutationFn: (event) => {
      event.preventDefault()
      return fetch('/api', new FormData(event.target))
    },
  })
 
  return <form onSubmit={mutation.mutate}>...</form>
}
 
// 이렇게 하면 작동합니다
const CreateTodo = () => {
  const mutation = useMutation({
    mutationFn: (formData) => {
      return fetch('/api', formData)
    },
  })
  const onSubmit = (event) => {
    event.preventDefault()
    mutation.mutate(new FormData(event.target))
  }
 
  return <form onSubmit={onSubmit}>...</form>
}
 
typescript

뮤테이션 상태 초기화하기

개발 과정에서 뮤테이션 요청의 오류나 데이터를 지워야 할 때가 있습니다. 이런 경우 reset 함수를 사용하면 간단히 처리할 수 있습니다.

다음 예제를 통해 뮤테이션 상태를 초기화하는 방법을 살펴보겠습니다:

const CreateTodo = () => {
  const [title, setTitle] = useState('')
  const mutation = useMutation({ mutationFn: createTodo })
 
  const onCreateTodo = (e) => {
    e.preventDefault()
    mutation.mutate({ title })
  }
 
  return (
    <form onSubmit={onCreateTodo}>
      {mutation.error && (
        <h5 onClick={() => mutation.reset()}>{mutation.error}</h5>
      )}
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
      <br />
      <button type="submit">Create Todo</button>
    </form>
  )
}
 
typescript

이 코드는 새로운 할 일을 생성하는 간단한 폼을 구현합니다. 주목할 점은 오류 발생 시 사용자가 오류 메시지를 클릭하면 뮤테이션 상태를 초기화한다는 것입니다.

useMutation 훅을 사용해 뮤테이션을 생성하고, 이를 통해 createTodo 함수를 호출합니다. 폼 제출 시 mutation.mutate 메서드를 호출해 새 할 일을 생성합니다.

오류가 발생하면 화면에 표시되며, 사용자가 이 오류 메시지를 클릭하면 mutation.reset() 함수를 호출해 뮤테이션 상태를 초기화합니다. 이렇게 하면 오류 메시지가 사라지고 사용자가 다시 시도할 수 있습니다.

이 방식은 사용자 경험을 개선하는 데 도움이 됩니다. 오류가 발생했을 때 사용자가 직접 오류 상태를 제거하고 새로운 시도를 할 수 있게 해주기 때문입니다. 또한 개발자 입장에서도 뮤테이션 상태를 쉽게 관리할 수 있어 유용합니다.

뮤테이션 부작용

TanStack Query의 useMutation 훅은 뮤테이션 생명주기의 모든 단계에서 빠르고 쉽게 부작용을 처리할 수 있는 도우미 옵션을 제공합니다. 이 옵션들은 뮤테이션 후 쿼리 무효화 및 재조회낙관적 업데이트에 특히 유용합니다.

useMutation({
  mutationFn: addTodo,
  onMutate: (variables) => {
    // 뮤테이션이 곧 일어날 것입니다!
 
    // 선택적으로 롤백 등에 사용할 데이터를 포함한 컨텍스트를 반환합니다
    return { id: 1 }
  },
  onError: (error, variables, context) => {
    // 오류가 발생했습니다!
    console.log(`ID가 ${context.id}인 낙관적 업데이트를 롤백합니다`)
  },
  onSuccess: (data, variables, context) => {
    // 성공입니다!
  },
  onSettled: (data, error, variables, context) => {
    // 오류든 성공이든 상관없습니다!
  },
})
 
typescript

콜백 함수에서 프로미스를 반환하면 다음 콜백이 호출되기 전에 먼저 프로미스가 해결됩니다:

useMutation({
  mutationFn: addTodo,
  onSuccess: async () => {
    console.log("나는 첫 번째입니다!")
  },
  onSettled: async () => {
    console.log("나는 두 번째입니다!")
  },
})
 
typescript

useMutation에 정의된 콜백 외에 추가 콜백을 실행하고 싶을 수 있습니다. 이는 컴포넌트 특정 부작용을 트리거하는 데 사용할 수 있습니다. 이를 위해 mutate 함수에 뮤테이션 변수 다음에 동일한 콜백 옵션을 제공할 수 있습니다. 지원되는 옵션에는 onSuccess, onError, onSettled가 있습니다. 단, 이러한 추가 콜백은 뮤테이션이 완료되기 전에 컴포넌트가 언마운트되면 실행되지 않습니다.

useMutation({
  mutationFn: addTodo,
  onSuccess: (data, variables, context) => {
    // 나는 먼저 실행됩니다
  },
  onError: (error, variables, context) => {
    // 나는 먼저 실행됩니다
  },
  onSettled: (data, error, variables, context) => {
    // 나는 먼저 실행됩니다
  },
})
 
mutate(todo, {
  onSuccess: (data, variables, context) => {
    // 나는 두 번째로 실행됩니다!
  },
  onError: (error, variables, context) => {
    // 나는 두 번째로 실행됩니다!
  },
  onSettled: (data, error, variables, context) => {
    // 나는 두 번째로 실행됩니다!
  },
})
 
typescript

연속적인 뮤테이션

연속적인 뮤테이션 처리에서 onSuccess, onError, onSettled 콜백의 동작은 약간 다릅니다. mutate 함수에 전달된 경우, 이들은 한 번만 실행되며 컴포넌트가 여전히 마운트된 경우에만 실행됩니다. 이는 mutate 함수가 호출될 때마다 뮤테이션 관찰자가 제거되고 다시 구독되기 때문입니다. 반면 useMutation 핸들러는 각 mutate 호출마다 실행됩니다.

useMutation에 전달된 mutationFn은 대부분 비동기적입니다. 이 경우 뮤테이션이 완료되는 순서는 mutate 함수 호출 순서와 다를 수 있습니다.

useMutation({
  mutationFn: addTodo,
  onSuccess: (data, error, variables, context) => {
    // 3번 호출됩니다
  },
})
 
const todos = ['할 일 1', '할 일 2', '할 일 3']
todos.forEach((todo) => {
  mutate(todo, {
    onSuccess: (data, error, variables, context) => {
      // 어떤 뮤테이션이 먼저 해결되든 상관없이
      // 마지막 뮤테이션('할 일 3')에 대해 한 번만 실행됩니다
    },
  })
})
 
typescript

프로미스

useMutation 훅을 사용할 때 mutate 대신 mutateAsync를 활용하면 성공 시 결과를 반환하고 오류 발생 시 예외를 던지는 프로미스를 얻을 수 있습니다. 이 방식은 부수 효과를 조합하는 데 유용합니다.

다음 예제를 살펴보겠습니다:

const mutation = useMutation({ mutationFn: addTodo })
 
try {
  const todo = await mutation.mutateAsync(todo)
  console.log(todo)
} catch (error) {
  console.error(error)
} finally {
  console.log('done')
}
 
typescript

이 코드에서 mutateAsync를 사용하면 비동기 작업의 결과를 직접 다룰 수 있습니다. 성공적으로 할 일을 추가하면 그 결과를 로그에 출력하고, 오류가 발생하면 콘솔에 오류를 표시합니다. 작업이 완료되면 'done' 메시지를 출력합니다.

재시도

기본적으로 TanStack Query는 뮤테이션 오류 발생 시 재시도하지 않습니다. 하지만 retry 옵션을 사용하면 재시도 기능을 활성화할 수 있습니다.

예를 들어:

const mutation = useMutation({
  mutationFn: addTodo,
  retry: 3,
})
 
typescript

이 설정을 통해 뮤테이션은 실패 시 최대 3번까지 재시도합니다.

기기가 오프라인 상태여서 뮤테이션이 실패한 경우, 기기가 다시 연결되면 실패한 뮤테이션들이 원래 순서대로 재시도됩니다. 이 기능은 네트워크 연결이 불안정한 환경에서 데이터 일관성을 유지하는 데 도움이 됩니다.

뮤테이션 지속하기

TanStack Query는 필요한 경우 뮤테이션을 저장소에 저장하고 나중에 재개할 수 있는 기능을 제공합니다. 이 기능은 수화(hydration) 함수를 통해 구현할 수 있습니다. 다음은 이 과정을 자세히 설명하는 예제입니다:

const queryClient = new QueryClient()
 
// "addTodo" 뮤테이션 정의
queryClient.setMutationDefaults(['addTodo'], {
  mutationFn: addTodo,
  onMutate: async (variables) => {
    // 현재 todos 목록에 대한 쿼리 취소
    await queryClient.cancelQueries({ queryKey: ['todos'] })
 
    // 낙관적 todo 생성
    const optimisticTodo = { id: uuid(), title: variables.title }
 
    // 낙관적 todo를 todos 목록에 추가
    queryClient.setQueryData(['todos'], (old) => [...old, optimisticTodo])
 
    // 낙관적 todo와 함께 컨텍스트 반환
    return { optimisticTodo }
  },
  onSuccess: (result, variables, context) => {
    // todos 목록에서 낙관적 todo를 결과로 교체
    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) =>
        todo.id === context.optimisticTodo.id ? result : todo,
      ),
    )
  },
  onError: (error, variables, context) => {
    // todos 목록에서 낙관적 todo 제거
    queryClient.setQueryData(['todos'], (old) =>
      old.filter((todo) => todo.id !== context.optimisticTodo.id),
    )
  },
  retry: 3,
})
 
// 컴포넌트에서 뮤테이션 시작:
const mutation = useMutation({ mutationKey: ['addTodo'] })
mutation.mutate({ title: 'title' })
 
// 기기가 오프라인 상태 등의 이유로 뮤테이션이 일시 중지된 경우,
// 애플리케이션 종료 시 일시 중지된 뮤테이션을 탈수화할 수 있습니다:
const state = dehydrate(queryClient)
 
// 애플리케이션 시작 시 뮤테이션을 다시 수화할 수 있습니다:
hydrate(queryClient, state)
 
// 일시 중지된 뮤테이션 재개:
queryClient.resumePausedMutations()
 
javascript

이 코드는 'addTodo' 뮤테이션을 정의하고, 낙관적 업데이트를 구현하며, 오류 처리 로직을 포함합니다. 또한 뮤테이션 상태를 저장하고 복원하는 방법을 보여줍니다.

오프라인 뮤테이션 유지하기

persistQueryClient 플러그인을 사용해 오프라인 뮤테이션을 유지하는 경우, 기본 뮤테이션 함수를 제공하지 않으면 페이지 새로고침 시 뮤테이션을 재개할 수 없습니다. 이는 기술적 한계 때문입니다. 외부 저장소에 지속할 때는 함수를 직렬화할 수 없어 뮤테이션의 상태만 유지됩니다. 수화 후 뮤테이션을 트리거하는 컴포넌트가 마운트되지 않을 수 있어, resumePausedMutations를 호출하면 'mutationFn을 찾을 수 없음' 오류가 발생할 수 있습니다.

이 문제를 해결하기 위한 예제 코드는 다음과 같습니다:

const persister = createSyncStoragePersister({
  storage: window.localStorage,
})
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24, // 24시간
    },
  },
})
 
// 페이지 새로고침 후 일시 중지된 뮤테이션을 재개할 수 있도록 기본 뮤테이션 함수 설정
queryClient.setMutationDefaults(['todos'], {
  mutationFn: ({ id, data }) => {
    return api.updateTodo(id, data)
  },
})
 
export default function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister }}
      onSuccess={() => {
        // localStorage에서 초기 복원이 성공한 후 뮤테이션 재개
        queryClient.resumePausedMutations()
      }}
    >
      <RestOfTheApp />
    </PersistQueryClientProvider>
  )
}
 
javascript

이 코드는 로컬 스토리지를 사용해 쿼리 클라이언트 상태를 유지하고, 기본 뮤테이션 함수를 설정하며, 앱이 시작될 때 일시 중지된 뮤테이션을 재개합니다.

TanStack Query는 쿼리와 뮤테이션을 모두 다루는 포괄적인 오프라인 예제도 제공합니다. 이 예제를 통해 오프라인 상황에서의 데이터 관리에 대해 더 자세히 알아볼 수 있습니다.

뮤테이션 범위

기본적으로 모든 뮤테이션은 동시에 실행됩니다. 같은 뮤테이션의 .mutate() 메서드를 여러 번 호출해도 마찬가지입니다. 하지만 이런 동작을 피하고 싶다면 뮤테이션에 범위를 지정할 수 있습니다. 같은 scope.id를 가진 모든 뮤테이션은 순차적으로 실행됩니다. 이 말은 뮤테이션이 실행될 때, 해당 범위에 이미 진행 중인 뮤테이션이 있다면 새로운 뮤테이션은 isPaused: true 상태로 시작한다는 뜻입니다. 이 뮤테이션들은 대기열에 들어가고 차례가 되면 자동으로 재개됩니다.

다음은 뮤테이션에 범위를 지정하는 예제 코드입니다:

const mutation = useMutation({
  mutationFn: addTodo,
  scope: {
    id: 'todo',
  },
})
 
typescript

이 코드는 todo라는 ID로 뮤테이션의 범위를 지정합니다. 이렇게 하면 같은 ID를 가진 다른 뮤테이션과 순차적으로 실행됩니다.

더 자세히 알아보기

뮤테이션에 대해 더 자세히 알고 싶다면, 커뮤니티 리소스에 있는 #12: React Query에서 뮤테이션 마스터하기 문서를 참고하세요. 이 문서에서 뮤테이션에 대한 더 깊이 있는 내용을 확인할 수 있습니다.