[React] Optimistic Update

Ganziman·5일 전
2
post-thumbnail

이번 포스팅은 Tanstack Query의 Optimistic Update 적용기입니다.

출결 UI의 반응 속도를 개선하기 위해 낙관적 업데이트를 어떻게 적용했는지,

그리고 그 과정에서 어떤 문제를 발견하고 해결했는지를 정리했습니다.

적용하게 된 배경

최근 프로젝트를 개선하는 과정에서 생각보다 치명적인 문제 하나를 발견했습니다.

체쿠리 프로젝트는 소규모 음악학원을 위한 출석부 관리 시스템입니다. 선생님들이 학생의 스케줄을 관리할 수 있는 웹 기반 출석부죠.

이 플랫폼에서 가장 핵심적인 기능은 바로 학생의 출결을 쉽게 기록할 수 있는 버튼입니다.

위에 보이는 출석 / 결석 버튼은 클릭만으로 출결 상태를 바로 확인할 수 있게 설계되어 있습니다.

기존에는 버튼을 클릭하면 API를 호출한 후, 응답을 받아 성공했을 때 UI 상태를 업데이트하도록 되어 있었습니다.
하지만 이렇게 서버 응답에 의존하면, 네트워크 속도가 느릴 때 UI 반영이 지연되는 문제가 발생합니다.

그래서 이 문제를 개선하기 위해 Optimistic Update를 적용했습니다.

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

Optimistic Update는 사용자 인터페이스(UI)가 데이터를 변경할 때, 실제 서버에 요청하기 전에 UI를 즉시 업데이트하는 기법 (feat.GPT)

가장 흔한 예는 인스타그램의 ‘좋아요’ 버튼입니다. 누르자마자 하트가 채워지지만, 실제 API는 그 뒤에 처리되죠. 서버 요청이 성공한다는 가정 하에 UI를 먼저 바꾸는 방식입니다.

기존 데이터 관리 방식

낙관적 업데이트를 적용하기 이전에 데이터 상태관리를 어떻게 했는지 알아보겠습니다.

저는 데이터를 Tanstack Query를 활용해 캐싱하고 있었습니다.

그러면 작업 방법은 mutation을 이용하여 캐싱된 데이터를 조작할 것인데요, 기존에 작업 방식은 onSuccess 케이스에 담에 캐싱된 데이터를 수정했었습니다.

아래 코드는 mutation을 사용해 출결 데이터를 기록하고, 성공 시 invalidateQueries로 캐시를 갱신하는 구조였죠.

export const useRecordCreate = ({
  bookId,
  currentDate,
}: {
  bookId: number
  currentDate: string
}) => {
  const key = bookKeys.schedules(bookId, currentDate).queryKey
  const queryClient = useQueryClient()
  const recordTime = `${getCurrentTimeParts()
    .hour.toString()
    .padStart(2, '0')}:${getCurrentTimeParts()
    .minute.toString()
    .padStart(2, '0')}`

  return useMutation({
    mutationFn: async ({
      attendeeId,
      scheduleId,
      status,
    }: {
      attendeeId: number
      scheduleId?: number
      status: STATUS

      startTime?: string
    }) =>
      await createRecord({
        params: {
          attendanceBookId: bookId,
          attendeeId: attendeeId,
          scheduleId: scheduleId,
          attendDate: currentDate,
          attendTime: recordTime,

          status: status,
        },
      }),
    onSuccess: (res) => {
      queryClient.invalidateQueries({
        queryKey: key,
      })
    },
    onError: handleError,
  })
}

InvalidateQueries란?

QueryClient API 중 하나로, 특정 쿼리를 “무효화”(invalidate)해서 다시 최신 상태로 불러오도록 트리거하는 메서드입니다.

이렇게 함으로써 API response 후에 상태를 반영했습니다.

이 방식은 동작은 확실하지만, 앞서 말한 대로 응답 대기 시간 동안 UI가 바로 반응하지 않는 문제가 있었습니다.

그래서 이번에는 onSuccess가 아닌, onMutate에서 출결 상태를 먼저 반영하는 식으로 구조를 변경했습니다.

setQueryData란?

QueryClient 메서드로, 원하는 쿼리의 캐시 데이터를 직접 수정할 때 사용합니다. 주로 다음과 같은 상황에 유용합니다.

onMutate란?

useMutation 훅에 전달할 수 있는 옵션 중 하나로, mutation 함수가 서버에 요청을 보내기 직전에 실행되는 콜백입니다.

그래서 위 코드에서 onSuccess 케이스가 아닌 onMutate에서 서버에 요청을 보내기 직전에 Optimistic Update 로직을 작성하였습니다.

아래는 다음 작성한 코드입니다.

  onMutate: async ({ scheduleId, status, startTime }) => {
      const previous =
        queryClient.getQueryData<GetScheduleAttendeeResponse>(key)

      queryClient.setQueryData<GetScheduleAttendeeResponse>(key, (old) => {
        if (!old || old.status !== 200) return old

        return {
          ...old,
          data: {
            ...old.data,
            content: old.data.content.map((slot) => {
              if (slot.startTime !== startTime) return slot
              return {
                ...slot,
                schedules: slot.schedules.map((s) =>
                  s.scheduleId === scheduleId
                    ? {
                        ...s,
                        recordStatus: status,
                        recordTime,
                      }
                    : s,
                ),
              }
            }),
          },
        }
      })

      // 3. 롤백용 컨텍스트 반환
      return { previous }
    },
     onError: (_err, _vars, context) => {
      if (context?.previous) {
        queryClient.setQueryData(key, context.previous)
      }
    },

previous 변수에 기존 캐싱된 데이터를 담았습니다. previous에 return으로 보내는 이유는 API 응답 실패시에 캐싱 데이터를 조작하면 안되기 때문에 이전 데이터를 넘겨주었습니다.

캐싱된 데이터를 조작하기 위한 setQueryData 함수를 사용하였습니다.
간단한 로직을 설명 드리자면 old(기존 캐싱데이터)를 스프레드 연산자로 데이터를 유지하면서 데이터를 업데이트 할 부분만 수정하였습니다.
그리고 onError 케이스에서 API 응답이 실패할 경우 낙관적 업데이트를 하면 안되기 때문에 이전 데이터로 되돌릴 수 있도록 작업하였습니다.

Tanstack Query의 낙관적 업데이트 방식 정리

Tanstack-Query Docs 에서 다음과 같이 정리해주고 있습니다.
(요약)

Tanstack Query에서 낙관적 업데이트(Optimistic Update)는 뮤테이션이 완료되기 전에 UI를 즉시 업데이트하여 더 부드러운 사용자 경험을 제공합니다. 주요 두 가지 접근 방식은 다음과 같습니다:

UI를 통한 업데이트 (직접 UI 업데이트):

뮤테이션 결과를 기다리는 동안 UI를 즉시 업데이트합니다.

이 방식은 캐시와 직접적으로 상호작용하지 않아 더 간단합니다.

예시: 새 할 일을 추가할 때, 뮤테이션이 완료될 때까지 목록에 새로운 항목을 임시로 표시하고, 뮤테이션이 성공하면 항목이 그대로 유지되며, 실패하면 항목을 제거하거나 오류를 표시합니다.

캐시를 통한 업데이트 (낙관적 캐시 업데이트):

useMutation의 onMutate 핸들러를 사용하여 캐시를 직접 수정합니다.

진행 중인 데이터를 취소하고, 새로운 데이터를 설정하며, 뮤테이션이 실패하면 업데이트를 롤백할 수 있습니다.

이 방식은 여러 컴포넌트에서 동시에 업데이트가 필요할 때 유용합니다.

예시: 새 할 일을 추가할 때, 뮤테이션이 완료되기 전에 캐시를 즉시 업데이트하고, 실패하면 캐시를 이전 상태로 되돌립니다.

언제 무엇을 사용할지:
UI 기반 접근: 낙관적 업데이트가 한 곳에서만 필요할 때 간단한 코드로 해결할 수 있어 더 직관적이고 사용하기 쉬운 방법입니다.

캐시 기반 접근: 여러 곳에서 업데이트가 필요하거나, 여러 컴포넌트에서 동시에 낙관적 업데이트를 관리하려면 캐시를 수정하는 방법이 더 적합합니다.

결론적으로, UI 기반 접근은 한 곳에서만 업데이트가 필요할 때 사용하고, 캐시 기반 접근은 여러 컴포넌트에서 동일한 업데이트를 반영해야 할 때 사용하는 것이 좋습니다.

네트워크 환경에서 테스트

저는 개발자 도구(DevTools)에서 네트워크 속도를 강제로 느리게 설정해 테스트했습니다

웹 페이지에서 F12를 눌러 devTool을 키게 되면 네트워크 속도를 선택해서 조절할 수 있습니다.

DevTools → Network 탭 → Throttling 옵션

No throttling
Fast 4G
Slow 4G
3G
Offline

저는 테스트를 위해 Slow 4G를 사용했었습니다. 3G를 사용하면 딜레이가 꽤 길어서 기다리기가 힘들었습니다..

전 후 비교

Optimistic Update 적용 전

Optimistic Update 적용 후

적용 전에는 네트워크 지연으로 인해 출결 상태 반영이 늦어졌습니다.
이런 딜레이는 선생님 입장에서 꽤 답답할 수 있습니다.

하지만 Optimistic Update 적용 후에는, API 응답을 기다리지 않고 UI를 즉시 업데이트하기 때문에
네트워크 속도가 느려도 출결 처리가 빠르게 반영되어 훨씬 매끄럽게 사용할 수 있습니다.

끝으로..

이렇게 Optimistic Update를 적용하면서 출결 UI 반응 속도가 눈에 띄게 빨라졌고, 전체적인 사용성도 향상됐습니다.
느린 네트워크 환경에서도 사용자에게는 즉시 반응하는 것처럼 보이기 때문에 체감 성능이 크게 개선되었습니다.

작업했던 내역은 Optimistic Update PR에서 보실 수 있습니다.

앞으로도 체쿠리 프로젝트는 계속 개선해 나가면서,
사용자가 더 편리하게 사용할 수 있도록 발전시켜 나가겠습니다.

읽어주셔서 감사합니다 🙇‍♂️

profile
GanziMan 입니다.

0개의 댓글