낙관적 업데이트로 UX 개선하기

BangDori·2024년 5월 14일
0
post-custom-banner

SNS 등과 같은 서비스를 개발 하다보면 좋아요, 북마크 기능과 같이 DB에 저장하면서 UI에 변경사항을 반영하여 사용자에게 보여주어야 하는 상황들을 자주 만나게된다.

이러한 상황에서 서버로부터의 응답 시간을 대기하는 것은 오히려 사용자 경험을 저하시킬 수 있는데, 변경사항을 UI에 반영하여 보여주는 것이 아닌 미리 사용자에게 보여주는 것이 좋은 해결책이 될 수 있다.

Optimistic Updates

Optimistic Updates는 낙관적 업데이트라는 뜻으로 앞서 말한 변경사항을 UI에 즉각 적용하여 사용자에게 보여주는 기술이다.

기존 상호작용

좋아요 버튼 클릭(API 호출) -> 성공 -> UI 반영

좋아요 버튼에 대한 동작 과정은 위와 같다. 그럼 좋아요 Mutation에 대한 코드를 작성하고 실제 확인을 해보자.

export const useLikes = (feedId: number, isLiked: boolean) => {
  const queryClient = useQueryClient();

  const { mutate: handleLikeFeed, isPending } = useMutation({
    mutationFn: () => isLiked ? requestUnlikeFeed(feedId) : requestLikeFeed(feedId),
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.feeds] });
    },
  });

  return { handleLikeFeed, isPending };
};

서버가 전혀 혼잡하지 않음에도, 위와 같이 서버로부터 응답이 올 때 까지 대기하고 응답이 도착한 후 UI에 반영하는 것을 확인할 수 있다.

현재 위 상호작용은 늦은 상호작용으로 인해 사용자 입장에서 네트워크가 불안정하다고 판단할 수 있으며, 응답이 도착할 때 까지 기다려야하기 때문에 UX가 저하될 수 있다.

그렇다면 낙관적 업데이트를 적용해보자.

낙관적 업데이트를 반영한 상호작용

낙관적 업데이트 기능을 적용하면 위와 같이 사용자와의 빠른 상호작용이 발생하는 것을 확인할 수 있다.

React Query에서 제공해주는 useMutation 훅에는 onMutate 함수를 지정할 수 있는데, 이를 통해 낙관적 업데이트를 구현할 수 있다.

export const useLikes = (feedId: number, isLiked: boolean) => {
  const queryClient = useQueryClient();

  const { mutate: handleLikeFeed, isPending } = useMutation({
    mutationFn: () => isLiked ? requestUnlikeFeed(feedId) : requestLikeFeed(feedId),
    // ✨ 낙관적 업데이트
    // 1️⃣ mutate가 호출되면 낙관적 업데이트를 위해 onMutate를 실행
    onMutate: async () => {
      // 2️⃣ 진행중인 refetch가 있다면 취소
      await queryClient.cancelQueries({
        queryKey: [QUERY_KEYS.feeds],
      });

      // 3️⃣ 이전 쿼리값의 스냅샷
      const previousQueryData = queryClient.getQueryData<FeedsQueryData>([
        QUERY_KEYS.feeds,
      ]);

      if (!previousQueryData) return;

      // 4️⃣ 업데이트 될 쿼리값
      const updatedQueryData = updateLikeStatusInFeeds(
        previousQueryData,
        feedId,
      );

      // 5️⃣ setQueryData 함수를 사용해 새로운 feeds에 Optimistic Update를 실시한다.
      await queryClient.setQueryData([QUERY_KEYS.feeds], updatedQueryData);

      return { previousQueryData };
    },
    // ...
  });

  return { handleLikeFeed, isPending };
};

onMutation 함수에서는 mutate가 호출되면 실행되게 되는데 이때 다음과 같은 단계를 가진다. (무조건 이 단계를 거친다는 의미가 아닌, 해당 프로젝트에서 낙관적 업데이트를 적용하는 단계)

  1. mutate가 호출되면 onMutate를 실행한다.
  2. 진행중인 refetch가 있다면 취소시킨다.
  3. 이전 쿼리값의 스냅샷을 가져온다.
  4. 업데이트 될 쿼리값을 가져온다.
  5. setQueryData 함수를 사용해 새로운 feeds에 Optimistic Update를 실시한다.

여기서 2번 단계가 매우 중요한데, 진행중인 refetch가 있다면 이를 왜 취소시킬까? 그 이유는 다음과 같은 이유가 있을 수 있다.

📌 진행중인 refetch를 취소시키는 이유

  1. 불필요한 네트워크 부하 방지
  2. 낙관적 업데이트 일관성

낙관적 업데이트는 기존의 서버 상태를 클라이언트단에서 조작하여 미리 업데이트를 진행하고, 서버로부터의 응답을 가져와 동기화한다. 그렇기 때문에 낙관적 업데이트가 진행중인 동안에는 불필요한 네트워크의 요청을 줄이기 위해 refetch를 취소시키고 낙관적 업데이트의 상태가 반영될 수 있도록 한다.

낙관적 업데이트 동기화 고찰

export const useLikes = (feedId: number, isLiked: boolean) => {
  const queryClient = useQueryClient();

  const { mutate: handleLikeFeed, isPending } = useMutation({
    mutationFn: () => isLiked ? requestUnlikeFeed(feedId) : requestLikeFeed(feedId),
    onMutate: () => {
      // 낙관적 업데이트
    }
    onError: (_, __, context) => {
      // ✨ Network Error일 경우 이전 쿼리값으로 롤백
      queryClient.setQueryData([QUERY_KEYS.feeds], context?.previousQueryData);
    },
    onSuccess: (response, _, context) => {
      if (isErrorResponse(response)) {
        // ✨ 서버 응답 실패 시 이전 쿼리값으로 롤백
        queryClient.setQueryData([QUERY_KEYS.feeds], context.previousQueryData);
      }
    },
  });

  return { handleLikeFeed, isPending };
};

낙관적 업데이트를 진행한 이후 API 요청을 최소화하기 위해 다음과 같이 코드를 작성하였었다.

  1. 네트워크 에러가 발생하게 된다면 이전 쿼리값으로 롤백한다.
  2. 서버에 반영되지 않을 경우, 이전 쿼리값으로 롤백한다.
  3. 서버에 반영되었을 경우, 낙관적 업데이트가 된 쿼리값으로 유지한다.

하지만 이 경우에는 치명적인 문제점이 하나 있다. 바로 서버와의 동기화가 진행되지 않는다는 점이다.

React Query는 서버 상태를 관리하기 위한 라이브러리로 서버 상태에 대한 동기화를 유지하는 것이 중요하다. 그렇기 때문에 서버와의 동기화가 유지되지 않게 되면 치명적인 사이드 이펙트가 발생할 수 있어서, mutation이 완료된 이후에는 쿼리를 무효화하고 업데이트된 서버 상태를 가져와야만 한다.

export const useLikes = (feedId: number, isLiked: boolean) => {
  const queryClient = useQueryClient();

  const { mutate: handleLikeFeed, isPending } = useMutation({
    mutationFn: () => isLiked ? requestUnlikeFeed(feedId) : requestLikeFeed(feedId),
    onMutate: async () => {
      // 낙관적 업데이트
    },
    onError: (_, __, context) => {
      // Network Error일 경우 이전 쿼리값으로 롤백
      queryClient.setQueryData([QUERY_KEYS.feeds], context?.previousQueryData);
    },
    onSuccess: (response, _, context) => {
      // Nextwork Success일 경우 실행

      if (isErrorResponse(response)) {
        // 실패 시 이전 쿼리값으로 롤백
        queryClient.setQueryData([QUERY_KEYS.feeds], context.previousQueryData);
      }
    },
    onSettled: () => {
      // ✨ 서버와의 동기화 유지
      queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.feeds] });
    },
  });

  return { handleLikeFeed, isPending };
};

참고

profile
Happy Day 😊❣️ >> bangdori.kr
post-custom-banner

0개의 댓글