🔥 React Query에서 뮤테이션 마스터하기
강의 목차
React Query에서 제공하는 기능과 개념에 대해 지금까지 많은 내용을 다뤘습니다. 대부분은 useQuery
훅을 사용해 데이터를 가져오는 것에 관한 내용이었죠. 하지만 데이터를 다룰 때 또 다른 중요한 부분이 있습니다. 바로 데이터를 업데이트하는 것입니다.
이러한 용도로 React Query는 useMutation
훅을 제공합니다.
뮤테이션이란 무엇일까요?
일반적으로 뮤테이션은 부수 효과를 가진 함수를 말합니다. 예를 들어 배열의 push
메서드를 살펴봅시다. 이 메서드는 배열에 값을 추가하면서 원래 배열을 직접 변경하는 부수 효과를 갖고 있습니다.
const myArray = [1] myArray.push(2) console.log(myArray) // [1, 2]
javascript
이와 달리 불변성을 지키는 방식으로는 concat
이 있습니다. 이 메서드도 배열에 값을 추가할 수 있지만, 원본 배열을 변경하는 대신 새로운 배열을 반환합니다.
const myArray = [1] const newArray = myArray.concat(2) console.log(myArray) // [1] console.log(newArray) // [1, 2]
javascript
이름에서 알 수 있듯이 useMutation
도 일종의 부수 효과를 갖고 있습니다. React Query가 서버 상태를 관리하는 맥락에서 볼 때, 뮤테이션은 서버에서 부수 효과를 수행하는 함수를 의미합니다. 데이터베이스에 할 일을 생성하는 것이 뮤테이션의 한 예입니다. 사용자 로그인도 전형적인 뮤테이션입니다. 사용자를 위한 토큰을 생성하는 부수 효과가 있기 때문입니다.
useMutation
은 일부 측면에서 useQuery
와 비슷하지만, 다른 면에서는 꽤 다릅니다.
useQuery와의 유사점
useMutation
은 useQuery
가 쿼리 상태를 추적하는 것처럼 뮤테이션의 상태를 추적합니다. loading
, error
, status
필드를 제공해 사용자에게 현재 상황을 쉽게 보여줄 수 있게 합니다.
useQuery
가 가진 onSuccess
, onError
, onSettled
같은 유용한 콜백도 사용할 수 있습니다. 하지만 여기까지가 유사점의 끝입니다.
업데이트
v5부터는 유사점이 더 줄어들었습니다. 콜백이 더 이상 useQuery에서 사용할 수 없게 되었기 때문입니다. 이에 대한 자세한 설명은 React Query의 API를 의도적으로 깨뜨리기 글에서 확인할 수 있습니다.
useQuery와의 차이점
useQuery는 선언적이고, useMutation은 명령적입니다.
이 말은 쿼리가 대부분 자동으로 실행된다는 뜻입니다. 의존성을 정의하면 React Query가 쿼리를 즉시 실행하고, 필요하다고 판단될 때 스마트하게 백그라운드 업데이트를 수행합니다. 이는 화면에 보이는 내용을 실제 백엔드 데이터와 동기화하려는 쿼리에 잘 맞는 방식입니다.
하지만 뮤테이션에는 이 방식이 적합하지 않습니다. 브라우저 창에 포커스를 맞출 때마다 새로운 할 일이 생성된다고 상상해 보세요. 그래서 React Query는 뮤테이션을 즉시 실행하는 대신, 원하는 시점에 호출할 수 있는 함수를 제공합니다.
function AddComment({ id }) { // 이것만으로는 아무 일도 일어나지 않습니다 const addComment = useMutation({ mutationFn: (newComment) => axios.post(`/posts/${id}/comments`, newComment), }) return ( <form onSubmit={(event) => { event.preventDefault() // ✅ 폼이 제출될 때 뮤테이션이 실행됩니다 addComment.mutate( new FormData(event.currentTarget).get('comment') ) }} > <textarea name="comment" /> <button type="submit">댓글 달기</button> </form> ) }
javascript
또 다른 차이점은 뮤테이션이 useQuery
처럼 상태를 공유하지 않는다는 점입니다. 같은 useQuery
호출을 여러 컴포넌트에서 여러 번 사용해도 캐시된 동일한 결과를 받을 수 있지만, 뮤테이션에서는 이렇게 작동하지 않습니다.
업데이트
v5부터는 useMutationState 훅을 사용해 컴포넌트 간에 뮤테이션 상태를 공유할 수 있습니다.
뮤테이션을 쿼리와 연결하기
뮤테이션은 설계상 쿼리와 직접 연결되어 있지 않습니다. 블로그 글에 좋아요를 누르는 뮤테이션은 해당 블로그 글을 가져오는 쿼리와 연결되어 있지 않습니다. 이런 연결을 위해서는 기본 스키마가 필요한데, React Query는 이를 제공하지 않습니다.
뮤테이션이 만든 변경사항을 쿼리에 반영하기 위해 React Query는 주로 두 가지 방법을 제공합니다.
무효화
개념적으로 가장 간단한 방법은 화면을 최신 상태로 유지하는 것입니다. 서버 상태를 다룰 때는 특정 시점의 데이터 스냅샷만을 보여주고 있다는 점을 기억하세요. React Query는 물론 이를 최신 상태로 유지하려 노력하지만, 뮤테이션으로 서버 상태를 의도적으로 변경한다면 이는 React Query에 캐시된 일부 데이터가 이제 "무효화"되었다고 알려주기 좋은 시점입니다. 그러면 React Query는 현재 사용 중인 데이터를 다시 가져오고, 가져오기가 완료되면 화면이 자동으로 업데이트됩니다. 라이브러리에 알려야 할 유일한 것은 어떤 쿼리를 무효화할지입니다.
const useAddComment = (id) => { const queryClient = useQueryClient() return useMutation({ mutationFn: (newComment) => axios.post(`/posts/${id}/comments`, newComment), onSuccess: () => { // ✅ 블로그 글의 댓글 목록을 다시 가져옵니다 queryClient.invalidateQueries({ queryKey: ['posts', id, 'comments'] }) }, }) }
javascript
쿼리 무효화는 꽤 스마트합니다. 모든 쿼리 필터처럼 쿼리 키에 대해 퍼지 매칭을 사용합니다. 따라서 댓글 목록에 대한 여러 키가 있다면 모두 무효화됩니다. 하지만 현재 활성 상태인 것들만 다시 가져옵니다. 나머지는 오래된 상태로 표시되어 다음에 사용될 때 다시 가져오게 됩니다.
예를 들어, 댓글을 정렬하는 옵션이 있고 새 댓글을 추가할 때 캐시에 두 개의 댓글 쿼리가 있다고 가정해 봅시다.
['posts', 5, 'comments', { sortBy: ['date', 'asc'] } ['posts', 5, 'comments', { sortBy: ['author', 'desc'] }
javascript
화면에 하나만 표시하고 있다면 invalidateQueries
는 그것만 다시 가져오고 다른 하나는 오래된 상태로 표시합니다.
직접 업데이트
때로는 데이터를 다시 가져오고 싶지 않을 수 있습니다. 특히 뮤테이션이 이미 필요한 모든 정보를 반환할 때 그렇습니다. 블로그 글의 제목을 업데이트하는 뮤테이션에서 백엔드가 전체 블로그 글을 응답으로 반환한다면, setQueryData
를 사용해 쿼리 캐시를 직접 업데이트할 수 있습니다.
const useUpdateTitle = (id) => { const queryClient = useQueryClient() return useMutation({ mutationFn: (newTitle) => axios .patch(`/posts/${id}`, { title: newTitle }) .then((response) => response.data), // 💡 뮤테이션의 응답이 onSuccess로 전달됩니다 onSuccess: (newPost) => { // ✅ 상세 보기를 직접 업데이트합니다 queryClient.setQueryData(['posts', id], newPost) }, }) }
javascript
setQueryData
로 데이터를 캐시에 직접 넣으면 마치 백엔드에서 이 데이터를 반환한 것처럼 작동합니다. 즉, 해당 쿼리를 사용하는 모든 컴포넌트가 그에 따라 다시 렌더링됩니다.
직접 업데이트와 두 가지 접근 방식의 조합에 대한 더 많은 예제는 #8: 효과적인 React Query 키에서 확인할 수 있습니다.
개인적으로는 대부분의 경우 무효화가 더 좋은 방법이라고 생각합니다. 물론 사용 사례에 따라 다르지만, 직접 업데이트가 안정적으로 작동하려면 프론트엔드에 더 많은 코드가 필요하고 어느 정도 백엔드 로직을 중복해야 합니다. 예를 들어 정렬된 목록은 직접 업데이트하기 꽤 어렵습니다. 업데이트로 인해 항목의 위치가 바뀔 수 있기 때문입니다. 전체 목록을 무효화하는 것이 더 "안전한" 접근 방식입니다.
낙관적 업데이트(Optimistic updates)
낙관적 업데이트는 React Query 뮤테이션을 사용하는 주요 이점 중 하나입니다. useQuery 캐시는 특히 프리페칭과 결합하면 쿼리 간 전환 시 데이터를 즉시 제공합니다. 이로 인해 전체 UI가 매우 반응이 빠르게 느껴집니다. 그렇다면 뮤테이션에서도 같은 이점을 얻지 못할 이유가 있을까요?
대부분의 경우 업데이트가 성공할 것이라고 꽤 확신합니다. 그렇다면 백엔드로부터 확인을 받을 때까지 사용자가 몇 초 동안 기다리게 할 필요가 있을까요? 낙관적 업데이트의 아이디어는 서버에 요청을 보내기도 전에 뮤테이션이 성공한 것처럼 가장하는 것입니다. 실제로 성공 응답을 받으면 뷰를 다시 무효화해 실제 데이터를 볼 수 있게 하면 됩니다. 요청이 실패한 경우에는 뮤테이션 이전 상태로 UI를 되돌립니다.
이 방식은 즉각적인 사용자 피드백이 필요한 작은 뮤테이션에 잘 작동합니다. 요청을 수행하는 토글 버튼이 요청이 완료될 때까지 전혀 반응하지 않는 것보다 더 나쁜 것은 없습니다. 사용자들은 그 버튼을 두 번, 심지어 세 번 클릭할 것이고, 전체적으로 "지연"되는 느낌을 받게 될 것입니다.
예제
여기서는 추가 예제를 보여주지 않겠습니다. 공식 문서에서 이 주제를 잘 다루고 있으며, TypeScript로 작성된 코드샌드박스 예제도 제공하고 있습니다.
더 나아가, 낙관적 업데이트가 약간 과도하게 사용된다고 생각합니다. 모든 뮤테이션을 낙관적으로 수행할 필요는 없습니다. 실패하는 경우가 드물다는 확신이 있어야 합니다. 롤백에 대한 사용자 경험이 그리 좋지 않기 때문입니다. 제출 시 닫히는 대화 상자 안의 양식이나 업데이트 후 상세 보기에서 목록 보기로 리디렉션되는 경우를 상상해 보세요. 이런 작업을 미리 수행하면 되돌리기가 어려워집니다.
또한 즉각적인 피드백이 정말 필요한지 확인해야 합니다(위의 토글 버튼 예제처럼). 낙관적 업데이트를 구현하는 데 필요한 코드는 "표준" 뮤테이션에 비해 간단하지 않습니다. 특히 백엔드에서 수행하는 작업을 모방할 때 결과를 가짜로 만드는 것은 불리언 값을 뒤집거나 배열에 항목을 추가하는 것처럼 쉬울 수도 있지만, 매우 빠르게 복잡해질 수 있습니다:
- 추가하는 할 일에 ID가 필요하다면 어디서 가져올까요?
- 현재 보고 있는 목록이 정렬되어 있다면 새 항목을 올바른 위치에 삽입할 수 있을까요?
- 다른 사용자가 그 사이에 다른 것을 추가했다면 어떻게 될까요? 낙관적으로 추가한 항목이 다시 가져온 후에 위치를 바꿀까요?
이런 모든 엣지 케이스들이 일부 상황에서는 오히려 사용자 경험을 나쁘게 만들 수 있습니다. 뮤테이션이 진행되는 동안 버튼을 비활성화하고 로딩 애니메이션을 보여주는 것만으로도 충분할 수 있습니다. 항상 그렇듯이, 적절한 상황에 적절한 도구를 선택하세요.
흔한 실수들
마지막으로, 뮤테이션을 다룰 때 알아두면 좋은 몇 가지 사항들을 살펴보겠습니다. 이들은 처음에는 그다지 명확하지 않을 수 있습니다:
Promise 기다리기
React Query는 뮤테이션 콜백에서 반환된 Promise를 기다립니다. 그리고 invalidateQueries
는 Promise를 반환합니다. 관련 쿼리들이 업데이트되는 동안 뮤테이션이 loading
상태를 유지하게 하려면 콜백에서 invalidateQueries
의 결과를 반환해야 합니다:
{ // 🎉 쿼리 무효화가 완료될 때까지 기다립니다 onSuccess: () => { return queryClient.invalidateQueries({ queryKey: ['posts', id, 'comments'], }) } } { // 🚀 발사 후 잊기 - 기다리지 않습니다 onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['posts', id, 'comments'] }) } }
javascript
mutate 또는 mutateAsync
useMutation
은 두 가지 함수를 제공합니다 - mutate
와 mutateAsync
입니다. 둘의 차이점은 무엇이고, 언제 어떤 것을 사용해야 할까요?
mutate
는 아무것도 반환하지 않지만, mutateAsync
는 뮤테이션 결과를 포함한 Promise를 반환합니다. 뮤테이션 응답에 접근해야 할 때 mutateAsync
를 사용하고 싶을 수 있지만, 거의 항상 mutate
를 사용하는 것이 좋다고 주장하고 싶습니다.
콜백을 통해 여전히 data
나 error
에 접근할 수 있으며, 오류 처리에 대해 걱정할 필요가 없습니다. mutateAsync
는 Promise를 제어할 수 있게 해주지만, 오류를 수동으로 잡아야 합니다. 그렇지 않으면 처리되지 않은 프로미스 거부가 발생할 수 있습니다.
const onSubmit = () => { // ✅ onSuccess를 통해 응답에 접근합니다 myMutation.mutate(someData, { onSuccess: (data) => history.push(data.url), }) } const onSubmit = async () => { // 🚨 작동하지만 오류 처리가 누락되었습니다 const data = await myMutation.mutateAsync(someData) history.push(data.url) } const onSubmit = async () => { // 😕 이렇게 해도 되지만 너무 장황합니다 try { const data = await myMutation.mutateAsync(someData) history.push(data.url) } catch (error) { // 아무것도 하지 않습니다 } }
javascript
mutate
를 사용하면 오류 처리가 필요하지 않습니다. React Query가 내부적으로 오류를 잡아(그리고 무시해) 주기 때문입니다. 실제로 *mutateAsync().catch(noop)*로 구현되어 있습니다. 😎
mutateAsync
가 더 나은 상황은 정말로 Promise가 필요한 경우뿐입니다. 여러 뮤테이션을 동시에 실행하고 모두 완료될 때까지 기다려야 하거나, 의존적인 뮤테이션이 있어 콜백 지옥에 빠질 수 있는 경우에 필요할 수 있습니다.
뮤테이션은 변수에 대해 하나의 인자만 받습니다
mutate
의 마지막 인자가 옵션 객체이기 때문에, useMutation
은 현재 변수에 대해 하나의 인자만 받을 수 있습니다. 이는 분명 제한사항이지만 객체를 사용하여 쉽게 해결할 수 있습니다:
// 🚨 이는 잘못된 문법이며 작동하지 않습니다 const mutation = useMutation({ mutationFn: (title, body) => updateTodo(title, body), }) mutation.mutate('안녕하세요', '세계') // ✅ 여러 변수에 대해 객체를 사용하세요 const mutation = useMutation({ mutationFn: ({ title, body }) => updateTodo(title, body), }) mutation.mutate({ title: '안녕하세요', body: '세계' })
javascript
이것이 현재 필요한 이유에 대해 더 자세히 알아보려면 이 토론을 참조하세요.
일부 콜백이 실행되지 않을 수 있습니다
useMutation
과 mutate
자체에 모두 콜백을 가질 수 있습니다. useMutation
의 콜백이 mutate
의 콜백보다 먼저 실행된다는 점을 아는 것이 중요합니다. 더욱이 뮤테이션이 완료되기 전에 컴포넌트가 언마운트되면 mutate
의 콜백이 전혀 실행되지 않을 수 있습니다.
그래서 콜백에서 관심사를 분리하는 것이 좋은 방법이라고 생각합니다:
- 절대적으로 필요하고 로직과 관련된 작업(쿼리 무효화 등)은
useMutation
콜백에서 수행하세요. - 리디렉션이나 토스트 알림 표시와 같은 UI 관련 작업은
mutate
콜백에서 수행하세요. 뮤테이션이 완료되기 전에 사용자가 현재 화면에서 벗어났다면, 이런 작업들은 의도적으로 실행되지 않을 것입니다.
이러한 분리는 특히 useMutation
이 커스텀 훅에서 온 경우에 유용합니다. 쿼리 관련 로직을 커스텀 훅에 유지하면서 UI 관련 작업은 UI에 남겨둘 수 있기 때문입니다. 이는 또한 커스텀 훅을 더 재사용 가능하게 만듭니다. UI와 상호 작용하는 방식은 경우에 따라 다를 수 있지만, 무효화 로직은 대부분 동일할 것이기 때문입니다:
const useUpdateTodo = () => useMutation({ mutationFn: updateTodo, // ✅ 항상 할 일 목록을 무효화합니다 onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }) }, }) // 컴포넌트에서 const updateTodo = useUpdateTodo() updateTodo.mutate( { title: '새로운제목' }, // ✅ 뮤테이션이 완료될 때 여전히 상세 페이지에 있는 경우에만 리디렉션합니다 { onSuccess: () => history.push('/todos') } )
javascript
이렇게 하면 뮤테이션을 더 효과적으로 관리하고, 코드의 구조를 개선하며, 재사용성을 높일 수 있습니다. React Query의 뮤테이션 기능을 잘 활용하면 데이터 업데이트와 관련된 복잡한 로직을 훨씬 쉽게 다룰 수 있습니다.