Optimistic Update로 사용자 체감 반응성 높이기

jade·2025년 9월 16일
0

intro

스팟잇 서비스에서는 알림 기능을 지원하고 있어요. 서비스 특성상 여러개의 알림이 짧은 시간안에 도착할 때가 많은 데, 이때 삭제 버튼을 눌러 알림을 차례차례 지워나갈때, 덜걱거리는 듯한 사용성이 있다는 피드백을 받게되었어요.

이번 글에서는 낙관적 업데이트를 적용하여 알림 삭제시의 사용성을 개선한 경우를 소개보겠습니다.

Optimistic Update(낙관적 업데이트)란?

낙관적 업데이트는 웹 어플리케이션에서 사용자 경험을 향상시키기 위해 사용되는 개념입니다.

프론트엔드에서 일반적으로 상태가 업데이트 되는 방식은 다음과 같습니다.

  1. 사용자가 어떤 동작을 수행합니다. (예: 스크랩, 좋아요, 삭제 등)
  2. 서버에 변경사항을 요청하고 응답을 기다립니다.
  3. 서버의 응답에 따라 UI를 업데이트합니다.

이런 방식은 사용자가 서버의 응답이 오고 UI가 업데이트 될때까지 기다려야 하기때문에, 네트워크지연이나 서버의 응답속도가 느릴경우 사용자 경험이 저하될 수 있습니다.

낙관적 업데이트

낙관적 업데이트는 이런 문제를 해결하는 방법으로 사용자의 동작에 대한 응답을 기다리지않고 UI를 먼저 업데이트하는 것입니다. 만약 응답이 실패로 돌아온다면 오류 메세지를 보여주고 기존의 UI상태로 롤백합니다.

낙관적으로 생각하여 응답이 성공할 것이라고 예측하고, 미리 UI를 업데이트한다고 이해하면 이름이 꽤 직관적이죠.

사용자는 동작을 수행하자 마자 UI 변경이 되는 빠른 피드백을 받을 수 있으므로 애플리케이션이 보다 빠르게 반응한다고 느끼게 됩니다.

그런데 이런 질문이 떠오를 수도 있어요.

서버 응답을 기다리는게 그렇게 유의미한 차이가 나나요?

낙관적 업데이트는 네트워크 round-trip에 의한 지연을 UI 피드백에서 제거하는 전략이에요.

여기서 네트워크 round-trip time(RTT)이란 클라이언트 -> 서버 요청 전송 -> 서버에서 처리 -> 응답 수신까지 한번 왕복하는데 걸리는 시간을 의미하는데, 여기에선 TCP 연결, TLS handshake, 패킷 왕복지연, DB 조회/연산/비즈니스로직 처리, 클라이언트로의 패킷 전달 과정이 모두 포함되어요. 만약 물리적으로 더 원거리의 리전서버라면 300~500ms까지 차이날 수 있어요.

UX 기준으로 100ms이면 사용자가 멈칫거림을 인식하는 수준이니 꽤 유의미한 수치에요.

서버의 성능을 높이면 되지 않나요?

서버의 응답이 아무리 빠르더라도 사용자가 어떤 네트워크 환경에 처해있느냐에 다라서 애플리케이션의 응답속도가 달라져요. 예를들어 지하철의 공공와이파이로 접속했다면, 어떤 경우 네트워크가 불안정 할 수도 있습니다.

서버의 응답에만 의존해서 상태를 업데이트 하는것이 아닌 낙관적 업데이트와 같은 로직을 활용하여 다양한 환경의 사용자 경험에 대비하는 것이 바람직할것 같아요.

생활 속의 낙관적 업데이트 예시

대표적인 낙관적 업데이트를 사용한 예시는 채팅창에서도 찾아볼 수 있습니다.

디스코드에서 채팅이 완전히 전송되기 전임에도, 채팅창에는 이미 메세지가 나타납니다. 보통 흐린 글씨로 '아직 전송중인 상태'를 알려주고, 서버에서 메세지전송이 성공적으로 처리되면 진한 글씨로 바뀌어 정상적인 메세지로 보이게 되요.

사용자는 입력 후 즉시 피드백을 받기 때문에 서비스가 훨씬 빠르고 즉각적으로 반응한다고 느끼게 되는 것입니다.

실제 서비스에 낙관적 업데이트 적용하기

업데이트 전 체감 시간 측정하기

사용자가 체감하는 시간은 삭제 버튼 클릭(시작지점) ~ UI 갱신 완료 (완료 지점)사이의 시간이에요.

아래 코드에서는 브라우저Performance api를 사용하여 시작 지점과 완료지점 사이의 시간차이를 측정하였습니다.

export default function NotificationCardList() {
  const currentDeleteIdRef = useRef<number | null>(null);
  const { data, hasNextPage, isFetchingNextPage, fetchNextPage } =
    useNotificationList();
  const { mutate: deleteNotification } = useDeleteNotification();

  ...

  const handleDeleteClick = (notification: NotificationType) => {
    const { notificationId } = notification;

    // 1. 클릭 시작
    performance.mark(`notification_delete_${notificationId}_start`);

    // 2.상태 없데이트
    currentDeleteIdRef.current = notificationId; // 현재 삭제할 알림id 기록
    deleteNotification(notificationId);
  };

  // api응답으로 ui 갱신 시점
  useEffect(() => {
    if (data) {
      const deleteId = currentDeleteIdRef.current;
      
      // 삭제할 아이템이 여전히 목록에 존재하는지 확인
      const stillExists = data.content.some(
        noti => noti.notificationId === deleteId
      );

      if (deleteId && !stillExists) {
        performance.mark(`notification_delete_${deleteId}_end`);
        performance.measure(
          `notification_delete_${deleteId}_latency`,
          `notification_delete_${deleteId}_start`,
          `notification_delete_${deleteId}_end`
        );

        const entries = performance.getEntriesByName(
          `notification_delete_${deleteId}_latency`
        );

        const duration = entries[0]?.duration;

        if (duration) {
          logger.debug(
            `[Perf] Notification ${deleteId} delete latency: ${duration} ms`
          );
        }
      }
    }
  }, [data]);

  return (
      <NotificationCardListView
        data={data.content}
        handleDelete={handleDeleteClick}
        lastElementRef={lastElementRef}
      />
  );
}

145.7ms

낙관적 업데이트 적용하기


// NotificationCardList.tsx
 ...
  const handleDeleteClick = (notification: NotificationType) => {
    const { notificationId } = notification;
    deleteNotification(notificationId);
  };
...

알림 삭제 로직은 Tanstack-Query의 useMutation을 감싼 훅인 useDeleteNotification에서 처리됩니다.useMutation의 인자로 전달하는 객체에 onMutate을 추가해줍니다.

onMutate은 mutationFn이 실행되기 직전에 호출되는 훅입니다. 즉, 서버에 삭제 요청을 보내기 직전에 미리 UI를 업데이트해 줍니다. onMutate의 리턴값으로는 서버 응답이 실패햇을 경우 되돌릴 데이터를 전달해줍니다.

// useDeleteNotification.ts
  onMutate: async (notificationId: number) => {
      performance.mark(`delete_${notificationId}_start`);
      // 1. 현재 쿼리 백업
      const prevData = queryClient.getQueryData<NotificationListData>(
        NOTIFICATION_LIST_QUERY_KEY
      );

      // 2. 알림 목록에서 해당 ID를 제거
      if (prevData) {
        queryClient.setQueryData(NOTIFICATION_LIST_QUERY_KEY, {
          ...prevData,
          pages: prevData.pages.map(page => ({
            ...page,
            content: page.content.filter(
              n => n.notificationId !== notificationId
            ),
          })),
        });
      }

      performance.mark(`delete_${notificationId}_end`);
      performance.measure(
        `delete_${notificationId}_perceived_latency`,
        `delete_${notificationId}_start`,
        `delete_${notificationId}_end`
      );

      const duration = performance
        .getEntriesByName(`delete_${notificationId}_perceived_latency`)
        .at(-1)?.duration;

      logger.debug(`[Perf] perceived latency: ${duration} ms`);

      // 3. 에러시 롤백할 데이터
      return { prevData };
    },

onMutate에서 전달된 삭제전 알림목록의 데이터는 onError의 context에 담겨 전달됩니다.
알림 삭제에 실패했을 경우, 적절한 알림 메세지와 함께 삭제전 UI로 되돌려주는 작업을 수행해줍니다.

  onError: (error, notificationId, context) => {
      logger.error('[onError]:', error.message);
      toast.error('알림 삭제에 실패했습니다.');

      if (context?.prevData) {
        queryClient.setQueryData(NOTIFICATION_LIST_QUERY_KEY, context.prevData);
      }
    },

추가적으로 onSettled는 성공혹은 실패 후, invalidate Query (쿼리무효화) -> refetch가 완료되고 난 후 호출됩니다. 최종적으로 서버와 데이터 동기화가 완료된 시점이므로 여기에 완료 마커를 찍어 확인해보겠습니다.

    onSettled: (data, error, notificationId) => {
      performance.mark(`delete_${notificationId}_consistency_end`);
      performance.measure(
        `delete_${notificationId}_consistency_latency`,
        `delete_${notificationId}_start`,
        `delete_${notificationId}_consistency_end`
      );

      const duration = performance
        .getEntriesByName(`delete_${notificationId}_consistency_latency`)
        .at(-1)?.duration;

      logger.debug(`[Perf] consistency latency: ${duration} ms`);
    },

사용자 체감 지연 시간(perceived_latency): 0.77ms

사용자 체감 지연시간은 버튼 클릭 이후 ui가 변경되는 것을 확인할 수 있는 시간입니다.
보다 정확한 측정으로 위해 콘솔, 그리고 Performance 탭에서 알림 삭제 동작을 녹화하려 성능을 측정해았습니다. 그결과 0.77ms로 아주 빠른 반응시간을 확인할 수 있었습니다.

일관성 지연 시간(consistency_latency)

일관성 지연 시간은 낙관적 업데이트(optimistic update)와 같은 기법을 사용했을 때, 사용자에게 보여지는 UI가 실제 서버 데이터와 완벽하게 일치하는 데 걸리는 총 시간을 의미합니다.

사용자에게는 0ms로 빠른 체감 지연 시간을 제공하지만, 실제 시스템 내부에서는 여러 네트워크 요청과 데이터 동기화 과정으로 인해 더 긴 일관성 지연 시간이 발생합니다.

결론

낙관적 업데이트 이전 (145.7ms): 사용자가 삭제 버튼을 누르면, 실제 서버의 응답을 기다린 후에야 UI가 업데이트됩니다. 이 과정에서 네트워크 지연 시간이 포함되어 사용자는 145.7ms라는 긴 시간을 기다려야 합니다.

낙관적 업데이트 이후 (0.5ms): 사용자가 삭제 버튼을 누르자마자, 서버 응답과 관계없이 클라이언트에서 즉시 UI를 업데이트합니다. 네트워크 요청은 백그라운드에서 이루어지기 때문에, 사용자는 즉각적인 피드백을 받게 되고 체감 시간은 0.5ms로 측정됩니다.

UX 관점에서 약 145ms 이상의 개선으로 사용자가 확실히 체감할 수 있는 유의미한 개선사항을 만들어내었어요.

profile
keep on pushing

0개의 댓글