이번 글은 별 이야기는 아니고, 코칭 중인 분 중 한 분께서 useMutation에 대한 용례를 물어보셔서
이것저것 알아보다 문득 궁금해진 것에 의해 쓰게 되었다.
문득 mutate나 mutateAsync가 둘 다 똑같이 API를 호출한다는 점에 있어서는 같을텐데,
어떻게 react-query가 mutateAsnyc는 Promise로 반환하고, mutate는 그렇게 하지 않는지에 대해 코드를 까보기로 했다.
useMutation의 상세 로직의 첫 부분부터 살펴보면 MutationObserver라는 클래스로 정의된 observer 상태를 정의한다.
이 때 MutationObserver란, tanstack/query의 코어를 담당하는 query-core 패키지에 정의되어 있는데,
내 방식대로 아래처럼 표현하고자 한다.
하나의 useMutation을 제어하는 캡슐화된 객체
생성자에서 쿼리클라이언트를 받고,
아마, QueryClientProvider에 들어간 클라이언트를 받아 쓸 것 같다.
useMutation 내부에서 useQueryClient로 불러다가 맥락을 주입하는데, 똑같이 우리가 개발할 때 쿼리클라이언트 객체가 필요할 때 useQueryClient로 쓰지 않는가?
전달된 옵션들을 받아 내부 클래스 필드들을 초기화한다.
옵션이라 함은, onSuccess, onError, onSettled, retry 등등... 그런 것들.
MutationObserver에서 정의된 mutate는 Promise를 반환하도록 되어 있다.
더 깊숙히 들어가면 getMutationCache를 거쳐, query-core/Mutation 클래스로 파고들어가며
그 내부에 execute라는 함수까지 이어지고, 해당 함수가 Promise를 최종적으로 반환한다.
이 execute 함수는 내부에서 실제 API 호출을 진행하고, 전달받은 옵션값에 따른 동작을 잇는다.
이를테면 아래와 같은 것들.
onSuccess?
onSettled?
onError?
마지막으로 담백하게 data 를 반환하는데, 이 data 객체가 API 호출 Promise 자체다.
이제 알았다, 내가 생각한대로 mutate 자체는 Promise를 반환하는 게 맞다.
그런데 개발자와의 인터페이스에서는 mutate가 왜 Promise가 아닌 걸까?
충분히 low level까지 파고 들어가본 것 같으니, 다시 본진으로 돌아와본다.
이쯤되니, 그냥 이름만 같지 다른 거구나! 라는 걸 확실하게 느낄 수 있다.
useMutation에서 정의된 mutate는 observer.mutate,
즉 내부에서 반환하는 Promise를 실행한 결과를 반환해주는 함수이지. 그 Promise를 반환하는 게 아니었다.
우리가 API 호출 대신 해줄테니까, 너한텐 결론만 전달해줄게~
어렵게 얘기하면, API의 호출 제어권을 MutationObserver에게 위임하고, 그 결과를 우리가 받는단 소리다.
어차피 전달해주는 파라미터가 똑같은데, 굳이 왜 함수로 한 번 더 감쌌을까? 싶은 생각이 드는데.
그것은 dependency에 observer를 넣어준 것처럼. 한 번 정의된 useMutation에 대해 무결성을 보장해야 됐기 때문이었을까? 라는 생각이 든다.
간단하다. 맨 마지막에 호출된 request에 대한 응답만 처리된다.
그 이유는 MutationObserver에서 mutate 동작을 수행할 때, 이미 수행되고 있는 mutation이 있다면 그것을 할당 해제해버리고 그 다음에 받은 값으로 다시 mutation을 수행하기 때문이다.
확신이 드는 것은 아니지만, useSyncExternalStore를 보며 생각을 해본 건.
mutateAsync는 이 result 객체가 가지고 있는 mutate 함수 자체, 즉 Promise를 반환한다.
result를 살펴보면, useSyncExternalStore훅을 이용해
mutationObserver가 외부 저장소 ( 코드를 치고 있는 개발자 쪽의 맥락 ) 의 호출시점에 대응되는 스냅샷과 동기화되는 것으로 보인다.
그러므로 mutateAsync에 전달된 request객체와 옵션들이 각각의 mutate에 잘 대응되어, 같은 useMutation 으로부터 떨어져나온 mutateAsync가 반복되더라도
각각이 고유한 값을 가지게 되면서 동기적인 순서가 보장되는 것 아닐까? 라는 생각을 하게 되었다.
번외로, notifyManager.batchCalls 라는 함수도 살펴봤는데. 전달된 콜백들을 큐 자료구조(배열로 구현됨)에 심고, 배치를 돌려가며 내부적으로 Transaction을 처리하는 듯한 흥미로운 로직도 살펴볼 수 있었다.
아직 이해가 부족해서 뭐라고 말하기가 어렵긴한데, 이런 동작들을 마주하면 어렴풋이
자바스크립트가 싱글 스레드라 한 번에 하나만 처리할 수 있다는 점으로 인해, 개발자들이 머리를 쥐어짜냈겠구나 하는 생각도 든다.
한 편, '버전이 올라간다는 것은 곧, 이전 버전의 실패를 의미한다' 라는 Will McGugan의 트윗처럼,
https://twitter.com/willmcgugan/status/1423678688802058244
react-query가 v4 -> v5로의 패치를 앞두고 있고, 곧 useQuery의 onSuccess, onError, onSettled 등이 사라질 예정에 있다.
사실 내부 코드를 살펴보면 엄청 복잡하다거나, 엄청 길다거나 하는 건 생각보다 잘 없고, 대신 내가 생각하지 않았던 발상으로부터 파생된 것들인 것이라고 느끼고 있다.
언젠가 읽었던 것 중에....
Tony dinh라는 개발자가 자기가 불편해서 만들었다는 https://devutils.com/ 이게 결국, 유수의 사람들이 쓰게된 날이 온 것 처럼. 언젠가 나도 내가 불편해서 만들고, 그것이 다른 개발자들을 의도치 않게(?) 도와주게 되는 그런 날이 왔음 좋겠다는 생각을 해본다.