React|서버 상태와 비동기 데이터
useMutation과 Optimistic Update: 사용자에게 0.1초의 딜레이도 느끼게 하지 마라
1

크리스가 만든 SNS 앱에서 '좋아요' 버튼을 눌러보자.
클릭을 하면 로딩 스피너가 0.5초 동안 빙글 돌다가 하트가 빨갛게 변한다. 기능상으로는 완벽하다. 하지만 인스타그램이나 트위터를 쓸 때의 그 "착착 붙는" 맛이 없다.
사용자는 자신의 인터넷이 느린 건 참아도, 앱의 반응이 느린 건 참지 못한다. 서버 응답이 올 때까지 기다리는 것은 너무 정직하고 느린 방식이다.
오늘은 서버가 답하기 전에 일단 성공했다고 치고 화면부터 바꿔버리는 대담한 기술, 낙관적 업데이트(Optimistic Update)를 구현해 본다.
1. 정직한 업데이트 vs 낙관적 업데이트
보통의 변이(Mutation) 흐름은 다음과 같다.
이 틈새의 0.2~0.5초가 사용자 경험(UX)을 결정한다.
2. TanStack Query로 구현하기
TanStack Query의 useMutation은 이러한 낙관적 업데이트를 위한 완벽한 생명주기(Lifecycle) 훅을 제공한다.
핵심 3단계 로직
코드로 보는 구현
크리스가 작성한 '좋아요' 기능을 살펴보자.
// useLikeTodo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useLikeTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todoId: string) => api.likeTodo(todoId),
// 1. 뮤테이션이 시작되는 순간 실행됨
onMutate: async (todoId) => {
// 꼬임 방지를 위해 진행 중인 쿼리 취소
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 실패했을 때 되돌리기 위해 이전 상태 저장 (Snapshot)
const previousTodos = queryClient.getQueryData(['todos']);
// 캐시를 강제로 수정 (서버 응답 안 기다림!)
queryClient.setQueryData(['todos'], (old: Todo[]) => {
return old.map(todo =>
todo.id === todoId ? { ...todo, liked: true } : todo
);
});
// 스냅샷 반환 (onError에서 사용)
return { previousTodos };
},
// 2. 에러가 났을 때 실행됨
onError: (err, newTodo, context) => {
// 스냅샷으로 롤백
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
alert("좋아요 실패! 잠시 후 다시 시도해주세요.");
},
// 3. 성공하든 실패하든 끝났을 때 실행됨
onSettled: () => {
// 서버 데이터와 확실히 맞추기 위해 재요청
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}3. UX 디테일 챙기기
낙관적 업데이트는 강력하지만, 남용하면 안 된다.
언제 써야 할까?
언제 쓰면 안 될까?
실패 시나리오 대응
낙관적 업데이트가 실패하면 사용자는 당황한다. 빨간 하트가 갑자기 빈 하트로 바뀌기 때문이다. 따라서 onError에서 토스트(Toast) 메시지 등을 통해 "반영되지 않았습니다"라고 친절하게 알려주는 것이 필수다.
핵심 정리
사용자에게 쾌적함을 선물했다면, 이제 대용량 데이터를 다루는 방법을 고민해야 한다. 스크롤을 끝도 없이 내리는 피드는 어떻게 구현할까?
"useInfiniteQuery와 Intersection Observer: 무한 스크롤의 정석" 편에서 계속된다.