
좋고 나쁨을 떠나서, 개발자는 처음 라이브러리를 접했을 때 익힌 문법과 사용 방식을 관성처럼 유지하는 것 같다. 내가 처음 개발을 배우던 시절에 리액트 쿼리는 막 이름을 탠스택 쿼리로 바꾸면서 5버전을 냈다. 부트캠프에서는 4버전을 권장했지만, 딱히 강요하지는 않았고, 그리하여 나를 포함한 대부분은 5버전으로 프로젝트를 진행했다. 메이저 업데이트 직후였던 만큼 수많은 버그로 들끓었고, 코드 문제인지 버그 문제인지 확인하기 위해 공식 문서를 닳도록 볼 수밖에 없었다. 그 이후로 따로 공식문서를 정독한 적은 없는 것 같다. 캐시를 건드려야 하는 순간이 오면 나는 관성적으로 useQueryClient부터 호출했다.
const queryClient = useQueryClient()const updateTodoMutation = useMutation({ mutationFn: updateTodo, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['todos'] }) },})낙관적 업데이트를 하든, 성공한 뒤 목록을 다시 가져오든, queryClient는 컴포넌트에서 꺼내 놓고 옵션 객체의 클로저로 넘기는 물건이라고 생각했다. 틀린 코드는 아니다. 그런데 @tanstack/react-query v5.89.0부터는 굳이 이렇게까지 할 필요가 없어졌다. 모든 뮤테이션 단계가 MutationFunctionContext를 받고, 그 안에 이미 client가 들어 있기 때문이다.
type MutationFunctionContext = { client: QueryClient mutationKey?: MutationKey meta?: MutationMeta}그래서 위에서 작성했던 '성공 뒤 목록을 갱신하는 코드'는 이렇게 바뀔 수 있다.
const updateTodoMutation = useMutation({ mutationFn: updateTodo, onSuccess: async (_data, _variables, _onMutateResult, context) => { await context.client.invalidateQueries({ queryKey: ['todos'] }) },})useQueryClient가 사라졌다. 한 줄 줄어든 일이라서 별것 아닌 것처럼 보이지만, 옵션 객체에만 필요한 의존성을 컴포넌트 스코프에 미리 꺼내 놓지 않아도 된다는 점은 꽤 마음에 든다. 특히 뮤테이션 설정을 팩터리로 빼거나 queryClient.setMutationDefaults에 넣을 때는 더 그렇다. 그쪽에서는 훅을 부를 수도 없으니까.
context가 두 개처럼 보이는 이유
이 기능을 찾고 잠깐 헷갈린 이유는 React Query의 뮤테이션 콜백이 원래도 context라는 말을 쓰고 있었기 때문이다.
예전 onSuccess, onError, onSettled의 세 번째 인자는 onMutate가 반환한 값이었다. 낙관적 업데이트라면 보통 이전 캐시를 담아 두는 자리다. 이름을 제대로 붙이면 onMutateResult에 가깝다.
onMutate: async ({ id, title }) => { const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) queryClient.setQueryData<Todo[]>(['todos'], (todos = []) => todos.map((todo) => (todo.id === id ? { ...todo, title } : todo)), ) return { previousTodos }},onError: (_error, _variables, onMutateResult) => { queryClient.setQueryData(['todos'], onMutateResult?.previousTodos)},v5.89.0의 PR #9615는 이 흐름 뒤에 진짜 실행 문맥을 하나 더 넣었다. 이제 각 단계의 인자는 이렇게 읽는 편이 정확하다.
useMutation({ mutationFn: (variables, context) => {}, onMutate: (variables, context) => {}, onSuccess: (data, variables, onMutateResult, context) => {}, onError: (error, variables, onMutateResult, context) => {}, onSettled: (data, error, variables, onMutateResult, context) => {},})둘은 같은 것이 아니다. onMutateResult는 내가 앞 단계에서 돌려준 값이고, 마지막 context는 TanStack Query가 넣어 준 MutationFunctionContext다. 여기에 client, mutationKey, meta가 있다.
그래서 낙관적 업데이트도 이제 같은 문맥을 따라가며 쓸 수 있다.
const updateTodoMutation = useMutation({ mutationFn: ({ id, title }, context) => { // mutationFn에서도 client, mutationKey, meta를 읽을 수 있다. return api.updateTodo({ id, title }) }, onMutate: async ({ id, title }, context) => { await context.client.cancelQueries({ queryKey: ['todos'] }) const previousTodos = context.client.getQueryData<Todo[]>(['todos']) context.client.setQueryData<Todo[]>(['todos'], (todos = []) => todos.map((todo) => (todo.id === id ? { ...todo, title } : todo)), ) return { previousTodos } }, onError: (_error, _variables, onMutateResult, context) => { context.client.setQueryData(['todos'], onMutateResult?.previousTodos) }, onSettled: (_data, _error, _variables, _onMutateResult, context) => { return context.client.invalidateQueries({ queryKey: ['todos'] }) },})이 정도면 context라는 이름 하나 때문에 생기는 혼동을 줄일 수 있다. 낙관적 업데이트의 되돌릴 값은 onMutateResult에, QueryClient가 필요하면 마지막 context에 있다. 문법의 변화라기보다, 뮤테이션 안에서 어느 값이 어디서 왔는지 다시 구분하게 해 주는 변화에 가깝다.
메이저 버전만 읽던 습관
나는 메이저 버전이 바뀔 때만 릴리스 노트를 읽는 편이었다. 마이너 업데이트는 버그 수정이나 내부 정리일 것이라고 생각했고, 실제로 그런 날도 많았다.
그런데 이런 기능은 마이너 버전에 들어온다. API를 갈아엎지는 않지만, 이미 쓰던 코드의 모양과 의존성의 위치를 조금 더 낫게 만든다. 모르고 있으면 몇 년 동안 useQueryClient를 한 줄씩 붙이며 살게 된다. 물론 그 한 줄이 나를 파산시키지는 않겠지만, 내가 모르는 선택지가 있다는 사실은 조금 억울하다.
그러니 앞으로는 메이저 업데이트만 볼 게 아니라 마이너 릴리스의 기능 추가와 개선도 훑어보려고 한다. 버전 번호는 호환성의 약속일 뿐, 변화의 크기를 알려주는 지표는 아니다.
댓글
댓글을 불러오는 중...