React-Query] - Optimistic Updates(낙관적 업데이트)

치맨·2024년 6월 6일

React-Query

목록 보기
1/1
post-thumbnail

목차


Optimistic Updates

Optimistic Updates란

Optimistic Updates(낙관적 업데이트)란 데이터가 어차피 바뀔꺼니까(낙관적) 미리 UI를 변경시키고, 실패하면 그때 되돌려버리자! 라는 의미입니다.

조금 더 유식하게 표현해보자면 낙관적 업데이트는 UI를 미리 업데이트하여 사용자 경험을 향상시키는 방법입니다. 사용자는 작업이 성공적으로 완료될 것으로 예상하고 진행할 수 있으며, 실패한 경우에만 이전 상태로 되돌립니다.

사용 이유

  • 저는 좋아요 버튼에 낙관적 업데이트를 적용했습니다. 이유는 좋아요 버튼을 누를 경우 즉시 UI가 변경되는 것이 사용자 경험에 좋고, 실패 시 되돌리는 코드가 짧아 유지보수 측면에서도 좋다고 판단했습니다.

영상으로 보는 사용자 경험의 차이

  • 낙관적 업데이트 전에는 여러 번 연속으로 누를 경우 속도도 느리지만, 심지어 마이너스로 가버리는 문제가 발생합니다.

위쪽 영상은 낙관적 업데이트 전
아래쪽 영상은 업데이트 후

낙관적 업데이트 전 낙관적 업데이트 후

지표로 보는 차이

  • 아래의 지표와 같이 약 2배의 속도차이가 발생합니다.

지표 차이 사진


적용 방법

  • 우선 공식문서를 보겠습니다. 공식문서
  • 아래쪽으로 내리다보면 Updating a single todo 카테고리를 보시면 됩니다.

공식문서 확인

useMutation({
  mutationFn: updateTodo,
  // When mutate is called:
  onMutate: async (newTodo) => {
    // Cancel any outgoing refetches
    // (so they don't overwrite our optimistic update)
    await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] })

    // Snapshot the previous value
    const previousTodo = queryClient.getQueryData(['todos', newTodo.id])

    // Optimistically update to the new value
    queryClient.setQueryData(['todos', newTodo.id], newTodo)

    // Return a context with the previous and new todo
    return { previousTodo, newTodo }
  },
  // If the mutation fails, use the context we returned above
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(
      ['todos', context.newTodo.id],
      context.previousTodo,
    )
  },
  // Always refetch after error or success:
  onSettled: (newTodo) => {
    queryClient.invalidateQueries({ queryKey: ['todos', newTodo.id] })
  },
})
  1. 낙관적 업데이틑 onMutate에서 미리 처리를 하는 방식입니다.

  2. 해당 query를 우선 취소해 줍니다. why? ->they don't overwrite our optimistic update

  3. 이전 값을 snapshot(기억) 해줍니다.

  4. setQueryData를 통해 Optimistically하게 업데이트를 해줍니다.

  5. 이전값과 새로운 값을 리턴 해줍니다.


내코드에 적용

  • 이제 제 코드에 적용해보겠습니다.
  const { id } = useParams();

  const { mutate: likeMutate } = useMutation({
    mutationFn: (payload: AddLike) => addLikeFetcher(payload),

    onMutate: async (newReview) => {
      /* 
      		1. reviews에 대한 쿼리를 취소합니다. 
            2. reviews에 id값이 필요하기 때문에 id를 같이 전달해줍니다, 삽질했던 부분은 아래쪽에서 확인
      */ 
      await queryClient.cancelQueries({ queryKey: ['reviews', id] });

      /* 
      	1. 이전 리뷰를 스냅샷 해줍니다.
      */ 
      const previousReviews = queryClient.getQueryData<Review[]>(['reviews', id]);
		
      /* 
      	1. review 형식에 맞게 새로운 review를 만들어줍니다.
      */ 
      const updatedReviews = previousReviews?.map((review) => {
        if (review.id === Number(newReview.listId)) {
          return { ...review, like: newReview.like };
        } else return review;
      });
      
      /* 
      	1. 수정한 부분을 setQueryData로 설정해줍니다.
      */ 
      queryClient.setQueryData(['reviews', id], updatedReviews);
		
      /* 
      	1. 이전 데이터와, 새로운 값을 리턴해줍니다. 
      */ 
      return { previousReviews, updatedReviews };
    },

    onError: (err, newTodo, context) => {
      alert('좋아요를 수정할 수 없습니다.');
      queryClient.setQueryData(['reviews', id], context?.previousReviews);
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['reviews', id] });
    },
  });

삽질

  • 공식문서를 보고 따라하던 중 이전값을 snapshot 할때 계속 undefined가 출력이 되어 많은 시간을 소비했었습니다.

문제 발견

  • 코드로 보면 아래와 같이 작성했고, console.log로 찍어본 결과 계속 undefined가 출력이 됐습니다.
 const { mutate: likeMutate } = useMutation({
    mutationFn: (payload: AddLike) => addLikeFetcher(payload),

    onMutate: async (newReview) => {
      await queryClient.cancelQueries({ queryKey: ['reviews'] });

      const previousReviews =	queryClient.getQueryData(['reviews']);
	/*
      console.log(previousReviews)   // undefined 
    */

      queryClient.setQueryData(['reviews'], updatedReviews);

      return { previousReviews, updatedReviews };
    },
  });

문제 해결

  • 고민하던 중, 다시 살펴본 reviews 쿼리를 보니 queryKey를 ['reviews', id']로 받아왔습니다.

  • 저는 queryKey의 이름만 맞춰주면 되리라 생각했지만, 실제로는 id 값까지 같이 전달해주어야 값이 나타났습니다

  • 특정 쿼리에 접근이 필요 할 때 초기에 설정해둔 포맷을 지켜줘야 제대로 쿼리에 접근할 수 있습니다

  • 따라서 아래와 같이 수정해주니 잘 동작했습니다.

 const { mutate: likeMutate } = useMutation({
    mutationFn: (payload: AddLike) => addLikeFetcher(payload),

    onMutate: async (newReview) => {
      await queryClient.cancelQueries({ queryKey: ['reviews', id] });

      const previousReviews = queryClient.getQueryData<Review[]>(['reviews', id]);
	/*
      console.log(previousReviews)   // 정상적으로 출력
    */

      queryClient.setQueryData(['reviews'], updatedReviews);

      return { previousReviews, updatedReviews };
    },
  });

의문점

  • onMutate를 통해 미리 업데이트를 해주고, 실패할 경우 onError에서 setQueryData처리를 해준다면 잘 동작하는데 왜 공식문서에서는 onSettled에서 invalidateQueries를 해주는걸까? 왜 한 번의 네트워크 요청이면 되는데 2번이나 요청을 보낼까? 라는 의문이 생겼습니다.

아직까지 왜 onSettled를 해주는지 모르겠습니다. 혹시 누가 이 글을 읽고 정답을 아신다면 댓글 부탁드립니다.

profile
기본기가 탄탄한 개발자가 되자!

0개의 댓글