Optimistic Update로 UX 개선하기 with Tanstack Query

chaaerim·2023년 11월 15일
4

Toks를 피보팅하고 ver2가 마무리되어 가고 있던 중 ..
슬랙에 위와 같은 내용이 QA 채널에 올라왔다.
심하면 얼마나 심하겠어? 하고 확인해 봤는데 . 너무너무너무너무 거슬렸다. FE 개발자라면 참지 못할 정도로,,,

좋아요 버튼을 누르자마자 ui에 반영되지 않고 서버를 통해 업데이트를 진행하고 업데이트 된 데이터를 화면에 보여주어 딜레이가 발생하는 문제였다.

사용자 경험을 위해 optimistic update를 적용하는 것이 좋겠다고 판단이 되어 React Query를 통해 구현한 과정을 정리해보고자 한다.


Optimistic Update란?

  • 낙관적 업데이트는 서버로부터 응답을 받기 전에 ui를 업데이트 하는 것을 의미한다. 즉 서버에서 데이터 업데이트가 문제 없이 작동할 것이라고 예측하는 것이다.
  • 따라서 낙관적 업데이트를 적용하게 되면 서버를 거치는 시간을 단축하게 되고, 사용자는 동작에 대한 빠른 피드백을 받으므로 애플리케이션이 더 빠르게 반응한다고 느끼게 된다.
  • 그렇다면 낙관적 업데이트가 항상 좋은 것이냐? 그건 당연히 아니다. 모든 것에는 trade off가 존재함 😊
    • 서버 업데이트가 실패한 경우는 코드가 더 복잡해진다는 단점이 존재한다.
    • 서버 업데이트가 실패한 경우에는 업데이트 이전의 데이터로 rollback 해야하며,
    • 따라서 낙관적 업데이트를 진행할 때 이전의 데이터 또한 저장이 필요하다.

기존 구현 방식

기존 코드는 아래와 같다. 기존에 작성하던 mutation 코드와 별반 다르지 않다.

export const useLikeCommentMutation = (commentId: string, quizId: string) => {
  const queryClient = useQueryClient();

  const { mutate: likeComment, isLoading } = useMutation(
    async () => {
      try {
        await postCommentLikeByCommentId({ commentId });
      } catch {
        throw new Error('댓글 좋아요 요청에 실패하였습니다.');
      }
    },
    {
      onSuccess: () => {
        queryClient.invalidateQueries(QUERY_KEYS.GET_COMMENT_LIST(quizId));
      },
    }
  • 댓글에 해당하는 commentId를 가지고 좋아요 데이터를 서버에서 변경하는 코드이다.
  • 해당 mutation이 성공하면 comment list와 관련된 쿼리키를 가지고 있는 캐싱된 쿼리를 무효화하게 된다.

여기서 잠깐,

invalidateQueries(쿼리 무효화)란?

  • QueryClient 는 invalidateQueries 메소드를 가지고 있다.
  • invalidateQueries를 가지고 쿼리를 무효화하게 되면 해당 쿼리는 stale(만료)상태가 된다.
  • invalidate된 쿼리가 현재 렌더링 중이면 refetch를 트리거 하게 된다.
    • 즉 사용자가 페이지를 refresh하지 않고도 mutation 이후에 fresh한 데이터를 볼 수 있게 되는 것이다.
  • invalidateQueries는 정확한 쿼리 키가 아닌 접두사를 이용한다. 따라서 동일한 쿼리 키 접두사로 서로 관련된 쿼리를 설정하면 해당 접두사를 가진 모든 쿼리를 한 번에 무효화할 수 있다.
    • queryClient.invalidateQueries() 와 같이 내부에 아무 쿼리 키도 작성하지 않으면 캐시에 있는 모든 쿼리를 invalidate하게 된다.
    • 정확한 키로 설정하고 싶다면 exact: true 로 설정하면 된다.

Optimistic Update를 Tanstack Query로 어떻게 구현하나요?

수정된 코드를 보면서 하나씩 따라가보자.

아래는 Tanstack Query를 이용해서 수정한 mutation 코드이다.

'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';

import { QUERY_KEYS } from '@/app/quiz/constants/queryKeys';
import { postCommentLikeByCommentId } from '@/app/quiz/remotes/comment';

import { CommentListResponse } from '../models/comment';

export const useLikeCommentMutation = (commentId: string, quizId: string) => {
  const queryClient = useQueryClient();

  const { mutate: likeComment, isLoading } = useMutation(
    async () => {
      try {
        await postCommentLikeByCommentId({ commentId });
      } catch {
        throw new Error('댓글 좋아요 요청에 실패하였습니다.');
      }
    },

    {
      onMutate: async () => {
        await queryClient.cancelQueries(QUERY_KEYS.GET_COMMENT_LIST(quizId));

        const previousLiked: CommentListResponse | undefined =
          queryClient.getQueryData(QUERY_KEYS.GET_COMMENT_LIST(quizId));

        queryClient.setQueryData(QUERY_KEYS.GET_COMMENT_LIST(quizId), () => {
          return {
            ...previousLiked,
            content: previousLiked?.content.map((el) => {
              if (el.id === Number(commentId)) {
                return {
                  ...el,
                  isLiked: true,
                  likeCount: el.likeCount + 1,
                };
              } else {
                return el;
              }
            }),
          };
        });

        return { previousLiked };
      },
      onError: (err, previousLiked) => {
        queryClient.setQueryData(
          QUERY_KEYS.GET_COMMENT_LIST(quizId),
          previousLiked
        );
        console.log(err);
      },
      onSettled: () => {
        queryClient.invalidateQueries(QUERY_KEYS.GET_COMMENT_LIST(quizId));
      },
    }
  );

  return {
    likeComment,
    isLoading,
  };
};
  • 상단에 mutation function을 적어주는 것까지는 기존 코드와 동일하다.

onMutate

 onMutate: async () => {
        await queryClient.cancelQueries(QUERY_KEYS.GET_COMMENT_LIST(quizId));

        const previousLiked: CommentListResponse | undefined =
          queryClient.getQueryData(QUERY_KEYS.GET_COMMENT_LIST(quizId));

        queryClient.setQueryData(QUERY_KEYS.GET_COMMENT_LIST(quizId), () => {
          return {
            ...previousLiked,
            content: previousLiked?.content.map((el) => {
              if (el.id === Number(commentId)) {
                return {
                  ...el,
                  isLiked: true,
                  likeCount: el.likeCount + 1,
                };
              } else {
                return el;
              }
            }),
          };
        });

        return { previousLiked };
      },
  • Optimistic Update는 useMutation에서 제공해주는 onMutate을 이용해서 구현할 수 있다.
  • onMutate을 이용하면 내부 구현을 통해 서버로 쿼리가 보내지는 것을 가로채고 즉각적으로 캐시를 업데이트할 수 있다.
  • 또한 Optimistic Update를 진행하면 mutation이 완료되기 전에 상태를 변경하는 것이기 때문에 mutation이 실패할 가능성이 존재한다.
    • 따라서 이를 해결하기 위해 useMutation의 onMutate 핸들러 옵션을 사용하면 나중에 onError 및 onSettled 핸들러 모두에 마지막 인수로 전달될 값을 리턴하여 이전 값으로 rollback을 진행할 수 있다.

이제 진짜 Optimistic Update를 하기 위한 과정을 살펴보자.

  1. 먼저 queryClient.cancelQueries 를 이용해서 업데이트할 캐시를 포함하고 있는 특정 쿼리에 대한 refetch를 취소하게 된다.
    • 쿼리를 취소하지 않게 되면 업데이트한 캐시를 refetch 해온 data로 overwrite할 수 있기 때문이다.
  2. 다음으로는 queryClient.getQueryData 를 이용해서 mutation이 정상적으로 완료되지 못했을 때 rollback을 위한 이전 데이터의 스냅샷을 저장해놓는다.
  3. queryClient.setQueryData 를 이용해서 이전에 캐싱된 데이터를 UI에 노출하고 싶은 데이터로 업데이트한다. 좋아요 기능에서는 commentId에 해당하는 isLikeed값을 true로 바꾸고 likeCount값을 +1 한 값으로 쿼리 데이터를 변경하였다.
    • setQueryData는 쿼리의 캐싱된 데이터를 즉시 업데이트하는데 사용할 수 있는 synchronous 함수이다.
  4. 그리고 이전에 getQueryData를 이용하여 저장했던 데이터의 스냅샷을 리턴하여 onError 및 onSettled 핸들러에 전달해 mutation이 실패한 경우를 대비할 수 있도록 한다.

onError

   onError: (err, previousLiked) => {
        queryClient.setQueryData(
          QUERY_KEYS.GET_COMMENT_LIST(quizId),
          previousLiked
        );
        console.log(err);
      },
  • onError는 mutation이 실패한 경우 실행된다.
  • onMutate을 이용하여 Optimistic Update을 구현한 경우 onMutate에서 리턴한 값도 전달 받아 rollback을 처리할 수 있게된다.
    • mutation이 실패했다면 queryClient.setQueryData 를 이용해서 이전 데이터를 저장해놓은 스냅샷을 다시 쿼리 데이터에 세팅해준다.

onSettled

 onSettled: () => {
        queryClient.invalidateQueries(QUERY_KEYS.GET_COMMENT_LIST(quizId));
      },
  • onSettle은 mutation이 성공하거나 실패했을 때 실행된다.
    • 따라서 mutation이 성공, 실패한 경우 둘 다 쿼리를 무효화해서 refetch를 트리거하게 된다.

개선된 화면

optimistic update를 적용한 이후 클릭이 되자마자 ui에 좋아요 클릭 여부가 반영되는 것을 확인할 수 있다.
아주 속이 시원하다 !!!!

해당 로직과 관련된 전반적인 흐름은 아래 pr에서 확인할 수 있다.
https://github.com/depromeet/toks-web/pull/363



참고자료
https://tecoble.techcourse.co.kr/post/2023-08-15-how-to-improve-ux-with-optimistic-update/
https://tanstack.com/query/latest/docs/react/guides/optimistic-updates

0개의 댓글

관련 채용 정보