[React] React-Query Optimistic Updates로 UI 업데이트 최적화하기

배준형·2024년 4월 12일
1
post-thumbnail

서문

안녕하세요. 마이다스인에서 인재 채용 플랫폼 잡다의 웹 프론트엔드 개발을 담당하고 있는 배준형입니다.

잡다에는 기업과 인재를 매칭해주는 서비스가 있습니다. 기업의 인사담당자가 특정 구직자를 추천하면 구직자에게 알림이 전송되고, 구직자는 해당 기업에 관심을 표현할 수 있습니다. 이는 SNS 서비스의 '좋아요' 기능과 유사한 개념인데요. 이 기능을 구현한 후 반응이 조금 느리다는 느낌을 받았고, 이를 개선하기 위해 React-Query의 Optimistic Updates 기법을 적용했습니다.

React-Query의 Optimistic Updates는 '좋아요' 기능뿐만 아니라 특정 목록의 추가, 제거와 같이 특정 액션에 의해 API 요청을 보내고 응답을 받아 UI를 업데이트해야 하는 다양한 상황에서 유용하게 활용할 수 있는 최적화 방식입니다.


React-Query Optimistic Updates

React-Query의 Optimistic Updates는 사용자 경험을 향상시키기 위해 서버 응답을 기다리지 않고 UI를 미리 업데이트하는 기법입니다. 이를 통해 사용자는 서버 응답을 기다리는 동안 지연 시간을 느끼지 않고 피드백을 받을 수 있습니다.

좋아요 기능을 예로 들면,

  1. 사용자가 좋아요 버튼을 클릭한다.
  2. 좋아요 API 요청을 서버로 전송한다.
  3. 서버로부터 요청에 대한 응답을 받는다.
  4. 받은 응답을 바탕으로 UI를 업데이트한다.

위와 같은 단계로 처리됩니다. 반면 Optimistic Updates를 적용하면 아래와 같이 절차가 변경됩니다.

  1. 사용자가 좋아요 버튼을 클릭한다.
  2. 좋아요 API 요청이 성공할 것이라고 가정하고 UI를 먼저 업데이트한다.
  3. 만약 에러가 발생하면 에러에 대한 UI를 보여주고 변경사항을 롤백한다.

결론은 서버의 응답을 기다리지 않고 먼저 UI를 업데이트하는 방식을 의미합니다. React-Query의 useMutation hook에서 제공하는 onMutate, onError, onSuccess, onSettled 등의 옵션을 활용하면 UI를 미리 업데이트한 후 에러가 발생했을 경우 롤백하는 것도 가능해집니다.

Optimistic Updates가 필요한 경우

위 이미지는 잡다 → 매칭 현황 페이지(테스트 환경)입니다. 하트 모양 버튼을 클릭하면 구직자가 해당 기업에 관심이 있다고 표현하는 기능인데요.


이 버튼을 클릭하면 API를 호출하고, 성공할 경우 빨간 하트 UI를 표시합니다. 다시 클릭하면 관심 표현을 해제하는 API를 호출하고, 성공하면 빈 하트 UI로 변경됩니다.


대부분의 PC와 모바일 기기는 잡다 서비스를 원활하게 이용할 수 있을 만큼 충분한 성능을 가지고 있어 위의 GIF처럼 즉각적인 반응을 보입니다. 다만, 성능이 좋지 않은 디바이스를 사용하거나, PC에서 많은 작업을 동시에 처리 중이어서 일시적으로 느려진 상태, 또는 네트워크 환경이 좋지 않은 경우에는 즉각적인 반응이 나타나지 않아 마치 오류가 발생한 것처럼 보일 수 있습니다.

위의 GIF는 네트워크 속도가 느린 상황에서 발생할 수 있는 화면을 보여줍니다.


재현을 위해 Network를 Fast 3G로 세팅해 놓은 것이지만, 제가 서비스 이용자라면 답답해서 서비스를 빠져 나가고 싶을 것 같아요. 이런 경우 Optimistic Updates를 활용하여 API 응답이 도착하기 전에 미리 UI에 변경사항을 반영해 놓고, 이후에 에러 처리나 상태 값을 업데이트하는 방식을 적용하면 실제 API 처리 시간은 비슷하더라도 사용자가 경험하는 체감 속도는 크게 개선될 것입니다.


적용해보기

잡다에선 react-query v3.39.1 버전을 사용 중입니다.
버전에 따라 내용이 다를 수 있습니다.

// Mutate Custom Hook
export const useLikeFavoritePosition = ({ onSuccess }: { onSuccess?: () => void }) => {
  return useMutation([LIKE_FAVORITE_POSITION_MUTATION_KEY], fetchLikeFavoritePosition, {
    onSuccess,
    onError,
  });
};

// use
const { mutateAsync: addCompanyLikeAsync } = useLikeFavoritePosition({
  onSuccess: () => {
    refetchMatchingList();
    openToast({ iconType: 'info', content: '인사담당자에게 관심 표현이 전달되었어요.' });
  },
});

기존 코드는 onSuccess 옵션만 넘겨주고 있었는데, 이 외에도 다양한 options를 넘길 수 있습니다.

  • onMutate: mutation 시작 전에 실행되는 콜백 함수
  • onError: mutation 실패 시 실행되는 콜백 함수
  • onSuccess: mutation 성공 시 실행되는 콜백 함수
  • onSettled: mutation 성공, 실패와 상관 없이 mutation이 종료될 때 실행되는 콜백 함수

이를 적절히 잘 활용하여 Optimistic Updates를 구현해 봅시다.


※ 참고

useMutation(addTodo, {
  onSuccess: (data, variables, context) => {
    // I will fire first
  },
  onError: (error, variables, context) => {
    // I will fire first
  },
  onSettled: (data, error, variables, context) => {
    // I will fire first
  },
})

mutate(todo, {
  onSuccess: (data, variables, context) => {
    // I will fire second!
  },
  onError: (error, variables, context) => {
    // I will fire second!
  },
  onSettled: (data, error, variables, context) => {
    // I will fire second!
  },
})
  • useMutation의 options로 넘기는 onSuccess와 mutate 함수의 options로 넘기는 onSuccessuseMutation의 options가 먼저 동작하고, 그 이후 mutate 함수의 options가 동작하게 됩니다.

수정

export const useLikeFavoritePosition = (
  options?: Omit<UseMutationOptions<AxiosResponse<void>, AxiosError, number>, 'mutateKey' | 'mutateFn'>,
) => {
  return useMutation([LIKE_FAVORITE_POSITION_MUTATION_KEY], fetchLikeFavoritePosition, options);
};
  • 기존 onSuccess만 넘겨주던 것에서 useMutation 옵션들을 모두 넘겨주도록 수정했습니다.
const { mutateAsync: addCompanyLikeAsync, isLoading: isLoadingAddCompanyLike } = useLikeFavoritePosition({
  onMutate: async () => {
    // 뮤테이션 시작 전에 실행되는 콜백 함수
    // 현재 쿼리 데이터를 백업하고 optimistic 업데이트를 수행
    await queryClient.cancelQueries([MATCH_LIST_QUERY_KEY, pageParams]);
    const previousData = queryClient.getQueryData<{
      pages: {
        matchingList: MatchingStatusListVO;
        pages: PageState;
      }[];
    }>([MATCH_LIST_QUERY_KEY, pageParams]);

    // Optimistic 업데이트 수행
    queryClient.setQueryData([MATCH_LIST_QUERY_KEY, pageParams], () => {
      return {
        ...previousData,
        pages: [
          {
            ...previousData?.pages[0],
            matchingList: {
              ...previousData?.pages[0].matchingList,
              matchingList: previousData?.pages[0].matchingList.matchingList.map((item) => {
                if (item.matchingSn === matchingSn) {
                  return {
                    ...item,
                    likeYn: true,
                  };
                }
                return item;
              }),
            },
          },
        ],
      };
    });

    return { previousData };
  },
  onError: (_error, _newData, context) => {
    // 뮤테이션 실패 시 실행되는 콜백 함수
    // 백업된 데이터를 사용하여 이전 상태로 롤백
    queryClient.setQueryData(
      [MATCH_LIST_QUERY_KEY, pageParams],
      (context as { previousData: MatchingStatusDto[] }).previousData,
    );
    openToast({ iconType: 'info', type: 'error', content: '에러 발생 시 나타나는 토스트' })
  },
  onSuccess: () => {
    openToast({ iconType: 'info', content: '인사담당자에게 관심 표현이 전달되었어요.' });
  },
  onSettled: () => {
    // 뮤테이션 완료 후 실행되는 콜백 함수
    // 쿼리 무효화 또는 리페칭 등의 작업 수행
    queryClient.invalidateQueries([MATCH_LIST_QUERY_KEY, pageParams]);
  },
});
  • onMutate
    • Optimistic Updates를 덮어씌우지 않기 위해 refetch하려는 쿼리를 cancel합니다.
    • refetch 하려는 쿼리의 getQueryData 메서드로 이전 데이터를 가져오고, setQueryData 메서드로 데이터를 업데이트 합니다.
  • onError
    • 에러가 발생한 경우 onMutate로 넘겨준 previousData를 setQueryData를 통해 이전 데이터로 롤백합니다.
  • onSettled
    • 모든 error 또는 success 이후에 원하는 쿼리를 refetch 하도록 합니다.

React-Query 관련 코드 외 코드는 개선될 여지가 있습니다.
코드의 효율성보단 Optimistic Updates의 흐름을 봐주시면 감사하겠습니다.🙏


결과

  • Like - Optimistic Updates 적용
  • Unlike - Optimistic Updates 미적용

보시는 바와 같이 최적화가 적용된 Like에는 바로 UI가 업데이트 돼서 관심 표현이 즉시 적용된 것처럼 보이지만, Unlike는 이전과 동일하게 느리게 반영되는 것처럼 느껴집니다.

두 가지 경우를 모두 적용했을 때 기존과 비교하면 아래와 같은 차이를 볼 수 있습니다.

적용 전

  • 반응이 느립니다.
  • 이에 따라 전체적인 속도도 느려 보입니다.

적용 후

  • 반응이 빠릅니다.
  • 토스트 메시지는 늦게 나타나지만, UI 변경은 빠르게 반영되어 전체적인 속도가 빠른 것처럼 느껴집니다.

토스트 메시지가 나타나는 시점을 보면 두 경우 모두 비슷한 속도로 API 응답을 받는 것을 알 수 있습니다. 그러나 Optimistic Updates 적용 후에는 UI 업데이트가 즉각적으로 이루어지기 때문에 사용자 입장에서는 더 빠르게 느껴질 수 있습니다. 그렇다면 에러가 발생하는 경우에는 어떻게 될까요?


적용 전 (2초 후 에러 발생)

  • 에러가 발생하는 순간 토스트가 나타납니다.
  • 하트는 아무 반응 없습니다.

적용 후 (2초 후 에러 발생)

  • 우선 UI는 반영합니다.
  • 이후 에러가 발생하면 이전 데이터로 롤백하여 UI를 업데이트 합니다.

네트워크 속도가 빠를 때 에러가 발생하는 경우에는 두 가지 방식이 비슷하게 동작합니다. 즉시 에러가 발생한다면 적용 전에는 단순히 onError만 호출되는 반면, 적용 후에는 onMutate에서 setQueryData로 하트 UI 관련 데이터를 업데이트하고, 이후 onError에서 다시 setQueryData를 호출하여 데이터를 롤백하는 차이가 있습니다.

네트워크 속도가 느리다면 위 처럼 뭔가 어색하게 변경되는 것을 볼 수 있습니다. 의도적으로 2초 뒤에 Error를 발생시키도록 코드를 작성했는데, 적용 전에는 아무 반응이 없다가 에러 토스트가 뜨게 되고, 적용 후에는 UI가 한 번 바뀌었다가 에러가 발생하는 순간 데이터를 롤백하고 있어요.

개인적으로 아무런 변경이 없다가 토스트 메시지가 뜨는 것보단 즉시 빨간 하트로 바뀌고 에러 발생 시점에 토스트 메세지가 뜨는게 무언가 피드백이 있다는 느낌이 들어서 더 좋아 보이는데요. 적어도 변하는 피드백이라도 있으면 사이트가 동작은 하고 있다는 것을 알려줄 수 있을 것 같아요. 다만, 보는 관점에 따라 의견이 나뉠 것 같기도 합니다.


정리

React-Query의 Optimistic Updates는 Mutation이 성공할 것이라고 가정하고 미리 UI를 업데이트하는 기법입니다. 이를 구현하기 위해 useMutation 훅의 옵션 중 onMutate, onError, onSuccess, onSettled 등을 활용할 수 있습니다.

해당 사항을 비교적 최근에 알게 되었는데, 여태까지의 모든 작업은 특정 Mutation에 대해 Mutation이 성공 했을 때만 후속 작업을 하도록 코드를 작성했었어요. 특히 좋아요 같은 기능에는 반응이 느린 것 같다는 생각이 들었어도 안정성을 위해 Mutation이 성공한 후에 UI를 업데이트 하도록 했던 것이 기억이 납니다.

Optimistic Updates를 활용하게 되면 빠른 사용자 피드백이라는 이점을 얻으면서도 혹시 에러가 발생하더라도 추가적인 UI 표시와 데이터 롤백이 가능하기에 좋은 사용자 경험을 만들 수 있을 것 같습니다. 다만, 에러 처리나 데이터 일관성 유지에 주의를 기울여야 하고, 상황에 맞게 적절히 활용하는 것이 중요할 것 같습니다.


참조

profile
프론트엔드 개발자 배준형입니다.

0개의 댓글