React|서버 상태와 비동기 데이터

useMutation과 Optimistic Update: 사용자에게 0.1초의 딜레이도 느끼게 하지 마라

1
useMutation과 Optimistic Update: 사용자에게 0.1초의 딜레이도 느끼게 하지 마라

크리스가 만든 SNS 앱에서 '좋아요' 버튼을 눌러보자.

클릭을 하면 로딩 스피너가 0.5초 동안 빙글 돌다가 하트가 빨갛게 변한다. 기능상으로는 완벽하다. 하지만 인스타그램이나 트위터를 쓸 때의 그 "착착 붙는" 맛이 없다.

사용자는 자신의 인터넷이 느린 건 참아도, 앱의 반응이 느린 건 참지 못한다. 서버 응답이 올 때까지 기다리는 것은 너무 정직하고 느린 방식이다.

오늘은 서버가 답하기 전에 일단 성공했다고 치고 화면부터 바꿔버리는 대담한 기술, 낙관적 업데이트(Optimistic Update)를 구현해 본다.

1. 정직한 업데이트 vs 낙관적 업데이트

보통의 변이(Mutation) 흐름은 다음과 같다.

  • 요청: 사용자가 버튼 클릭 -> 서버에 요청 전송.
  • 대기: 서버가 DB를 업데이트하는 동안 로딩 표시.
  • 반영: 성공 응답이 오면 UI를 업데이트.
  • 이 틈새의 0.2~0.5초가 사용자 경험(UX)을 결정한다.

  • 낙관적 업데이트(Optimistic Update)는 순서를 뒤바꾼다.
  • 반영: 사용자가 버튼 클릭 -> 즉시 UI 업데이트 (하트 뿅!).
  • 요청: 뒷단에서 서버에 요청 전송.
  • 확인: 성공하면 유지, 실패하면 조용히 원상 복구(Rollback).
  • 2. TanStack Query로 구현하기

    TanStack Query의 useMutation은 이러한 낙관적 업데이트를 위한 완벽한 생명주기(Lifecycle) 훅을 제공한다.

    핵심 3단계 로직

  • onMutate (실행 직전): 요청이 나가자마자 실행된다. 진행 중인 쿼리를 취소하고, 현재 데이터를 백업(Snapshot)한 뒤, 캐시를 조작해 화면을 바꾼다.
  • onError (실패 시): 백업해둔 데이터로 캐시를 되돌린다.
  • onSettled (종료 시): 성공하든 실패하든, 서버와 확실한 동기화를 위해 최신 데이터를 다시 가져온다(Refetch).
  • 코드로 보는 구현

    크리스가 작성한 '좋아요' 기능을 살펴보자.

    // 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) 메시지 등을 통해 "반영되지 않았습니다"라고 친절하게 알려주는 것이 필수다.

    핵심 정리

  • 낙관적 업데이트: 서버 응답을 기다리지 않고 미리 성공했다고 가정하여 UI를 바꾸는 기법이다. 앱이 훨씬 빠르고 쾌적하게 느껴진다.
  • onMutate: 여기서 cancelQueries로 쿼리를 멈추고, setQueryData로 캐시를 조작한다. 반드시 이전 데이터를 리턴해서 백업해야 한다.
  • onError: 백업받은 데이터(context)를 이용해 캐시를 원상 복구한다.
  • onSettled: 데이터 정합성을 위해 invalidateQueries로 최신 데이터를 다시 불러온다.
  • 사용자에게 쾌적함을 선물했다면, 이제 대용량 데이터를 다루는 방법을 고민해야 한다. 스크롤을 끝도 없이 내리는 피드는 어떻게 구현할까?

    "useInfiniteQuery와 Intersection Observer: 무한 스크롤의 정석" 편에서 계속된다.


    🔗 참고 링크

  • TanStack Query - Optimistic Updates
  • Mastering Mutations in React Query