리액트쿼리로 옵티미스틱 ui 이해하기

willy·2023년 10월 25일
1

프로덕트에서 좋아요 버튼을 누르면, 서버에 통신을 요청하고 서버에 2xx 요청이 떨어지면, 좋아요가 적용되는 상황입니다. 콜드스타트 이슈때문에 사용자가 버튼을 누르고 최대 3초까지 기다려야하는 상황이 만들어지기도 했습니다.

이를 해결하기 위해 optimistic update를 적용하기로 했다.

Optimistic Updates?

서버 업데이트시 UI에서도 어차피 업데이트 할것이란 (낙관적인) 가정으로 미리 UI를 업데이트 시켜주고 서버를 통해 검증을 받고 업데이트, 실패하면 롤백하는 방식이다.

이를 사용하기 위해선 onMutate라는 메서드들 사용하게 된다.

onMutate란?

useMutation훅에서 제공하는 onMutate 속성을 사용하면 mutation 전에 수행할 작업을 정의할 수 있음.

보통 mutate는 patch, post, delete와 같이 상태를 변경하는 용도로 많이 사용된다.

onMutate?: (variables: TVariables) => Promise<R | undefined> | R | undefined;

여기서 TVariables는 mutation에 전달되는 변수들의 타입을 나타내고, R은 mutation의 결과 타입을 나타낸다.

Point

onMutate 함수는 mutation이 수행되기 전에 실행된다. 이 함수는 mutation 실행 전에 수행할 작업을 정의한다. 예를 들어, onMutate 함수에서는 optimistic update를 수행하거나, 캐시 업데이트를 수행하거나, 에러 처리 등을 수행할 수 있다.

공식문서에서도 optimisitc update는 onMutate에서 처리하는 것을 권장한다. 두가지 방안을 제시해, 두가지 방안에 대한 장단점을 리서치해보았다.

Optimistic Updates

React Query provides two ways to optimistically update your UI before a mutation has completed. You can either use the onMutate option to update your cache directly, or leverage the returned **variables** to update your UI from the useMutation result.

onMutate

  • 장점:
    • 옵티미스틱 업데이트 로직을 더 세밀하게 제어할 수 있습니다. onMutate 내부에서 직접 데이터 캐싱 및 상태 업데이트 로직을 작성할 수 있습니다.
    • 비동기 작업을 직접 관리하며, 사용자 지정 로직을 적용할 수 있습니다.
    • 더 많은 유연성을 제공하며, 특정한 요구사항에 맞게 커스터마이징할 수 있습니다.
  • 단점:
    • 더 많은 코드를 작성해야 하며, 구현하기에 시간과 노력이 더 소요됩니다.
    • 사용자 정의 옵티미스틱 업데이트 로직을 작성해야 하므로, 실수를 범할 가능성이 있습니다.

variables

  • 장점:
    • 간단하게 사용할 수 있으며, 작성해야 하는 코드 양이 적습니다. variables를 이용하면 React Query가 자동으로 옵티미스틱 업데이트를 수행합니다.
    • 간단한 케이스에서는 효과적이며, 빠르게 구현할 수 있습니다.
  • 단점:
    • 더 복잡한 옵티미스틱 업데이트 시나리오에는 제한적일 수 있습니다. variables는 반환된 데이터를 기반으로 UI를 업데이트하는 간단한 방법으로 사용됩니다.
    • 특정한 상황에 따른 커스텀 로직을 구현하기 어려울 수 있습니다.

각각의 장단점을 리서치해보니, onMutate는 비교적 복잡한 대신 세밀한 조작이 가능하고, varaibles는 간단하지만 세밀한 조작이 불가능했다. 지금까지의 구조로 봤을때, 세밀한 조작이 필요한 형태가 많아 첫번째 onMutate로 진행하기로 결정했다.


queryClient

쿼리 클라이언트 내부에 메서드를 활용하면 옵티미스틱 구현이 가능할 것으로 보임.

필요한 메서드는 세가지가 있다.

**[queryClient.cancelQueries](https://tanstack.com/query/v4/docs/react/reference/QueryClient#queryclientcancelqueries)**

The cancelQueries method can be used to cancel outgoing queries based on their query keys or any other functionally accessible property/state of the query.

This is most useful when performing optimistic updates since you will likely need to cancel any outgoing query refetches so they don't clobber your optimistic update when they resolve.

cancelQueries 메소드는 쿼리 키 또는 기능적으로 액세스할 수 있는 다른 쿼리 속성/상태를 기반으로 나가는 쿼리를 취소하는 데 사용할 수 있습니다.

이는 낙관적 업데이트를 수행할 때 가장 유용합니다. 나가는 쿼리 다시 가져오기를 취소하여 문제가 해결될 때 낙관적 업데이트가 방해받지 않도록 해야 하기 때문입니다.

즉, refetch를 막아서 optimisitc update에 필요한 데이터를 덮어쓰지 않도록 미리 막는 것

**[queryClient.getQueryData](https://tanstack.com/query/v4/docs/react/reference/QueryClient#queryclientgetquerydata) getQueryData is a synchronous function that can be used to get an existing query's cached data. If the query does not exist, undefined will be returned.

getQueryData는 기존 쿼리의 캐시된 데이터를 가져오는 데 사용할 수 있는 동기 함수입니다.

즉, 이전에 useQuery로 가져온 데이터를 Key값 기반으로 가져올 수 있다. 이렇게 받아둔 데이터는 혹여라도 통신에 실패하게 됐을때, optimistic update로 바뀐 값을 롤백할 수 있게 한다.

**[queryClient.setQueryData](https://tanstack.com/query/v4/docs/react/reference/QueryClient#queryclientsetquerydata) setQueryData is a synchronous function that can be used to immediately update a query's cached data. If the query does not exist, it will be created. If the query is not utilized by a query hook in the default cacheTime of 5 minutes, the query will be garbage collected. To update multiple queries at once and match query keys partially, you need to use [queryClient.setQueriesData](https://tanstack.com/query/v4/docs/react/reference/QueryClient#queryclientsetqueriesdata) instead.

The difference between using setQueryData and fetchQuery is that setQueryData is sync and assumes that you already synchronously have the data available. If you need to fetch the data asynchronously, it's suggested that you either refetch the query key or use fetchQuery to handle the asynchronous fetch.

setQueryData는 쿼리의 캐시된 데이터를 즉시 업데이트하는 데 사용할 수 있는 동기 함수입니다. 쿼리가 존재하지 않으면 생성됩니다. 기본 캐시 시간인 5분 동안 쿼리 후크가 쿼리를 활용하지 않으면 해당 쿼리는 가비지 수집됩니다. 여러 쿼리를 한 번에 업데이트하고 쿼리 키를 부분적으로 일치시키려면 대신 queryClient.setQueriesData를 사용해야 합니다.

setQueryDatafetchQuery 사용의 차이점은 setQueryData가 동기화되어 이미 동기적으로 사용 가능한 데이터가 있다고 가정한다는 것입니다. 데이터를 비동기식으로 가져와야 하는 경우 쿼리 키를 다시 가져오거나 fetchQuery를 사용하여 비동기식 가져오기를 처리하는 것이 좋습니다.


이제 단계를 정리해보자면 다음과 같다.

  1. mutate함수를 생성하고
  2. mutate함수가 실행될때 onMutate 속성을 사용하고
  3. 해당 onMutate내부에서 query를 캔슬.
  4. 기존에 가져온 데이터를 get해서 백업
  5. 업데이트 할 값을 set 하는 것.

특정한 아이템에 대한 update시

→ id값 기준으로 진행

interface IBookmarkedForProject {
    activityId: number;
    projectId: number;
    isBookmarked: boolean;
  }

  const useUpdateActivityProjectBookmarked = () => {
    return useMutation(
      (data: IBookmarkedForProject) =>
        updateActivityProjectBookmarked(
          data.activityId,
          data.projectId,
          data.isBookmarked,
        ),
      {
        onMutate: async (newData: IBookmarkedForProject) => {
          // 특정한 한 id를 변경
          await queryClient.cancelQueries({
            queryKey: [QUERY_KEYS.ACTIVITY_PROJECTS, newData.projectId],
          });
          //
          const previousData = queryClient.getQueryData([
            QUERY_KEYS.ACTIVITY_PROJECTS,
            newData.projectId,
          ]);
          queryClient.setQueryData(
            [QUERY_KEYS.ACTIVITY_PROJECTS, newData.projectId],
            newData,
          );
          // Return a context with the previous and new todo
          return { previousData, newData };
        },
        // If the mutation fails, use the context we returned above
        onError: (err, newTodo, context: any) => {
          queryClient.setQueryData(
            [QUERY_KEYS.ACTIVITY_PROJECTS, context.newData.projectId],
            context.previousData,
          );
        },
        onSettled: (newData) => {
          queryClient.invalidateQueries({
            queryKey: [QUERY_KEYS.ACTIVITY_PROJECTS, newData.projectId],
          });
        },
      },
    );
  };

특정 리스트에 대한 update시

→ 해당 리스트의 key를 들고옴

interface IBookmarkedForProject {
    activityId: number;
    projectId: number;
    isBookmarked: boolean;
  }

  const useUpdateActivityProjectBookmarked = () => {
    return useMutation(
      (data: IBookmarkedForProject) =>
        updateActivityProjectBookmarked(
          data.activityId,
          data.projectId,
          data.isBookmarked,
        ),
      {
        onMutate: async (newData: IBookmarkedForProject) => {
          await queryClient.cancelQueries({
            queryKey: [QUERY_KEYS.ACTIVITY_PROJECTS],
          });

          const previousData = queryClient.getQueryData([
            QUERY_KEYS.ACTIVITY_PROJECTS,
          ]);

          queryClient.setQueryData(
            [QUERY_KEYS.ACTIVITY_PROJECTS],
            (oldData: any) => {
              // 해당 항목의 인덱스를 찾습니다.
              const index = oldData.findIndex(
                (item: any) => item.id === newData.projectId,
              );
              // 배열의 항목을 업데이트합니다.
              if (index !== -1) {
                const updatedData = [...oldData];
                updatedData[index] = {
                  ...updatedData[index],
                  isBookmarked: newData.isBookmarked,
                };
                return updatedData;
              }
              return oldData;
            },
          );

          return { previousData, newData };
        },

        onError: (err, newData, context: any) => {
          queryClient.setQueryData(
            [QUERY_KEYS.ACTIVITY_PROJECTS],
            context.previousData,
          );
        },
        onSettled: (newData) => {
          queryClient.invalidateQueries({
            queryKey: [QUERY_KEYS.ACTIVITY_PROJECTS],
          });
        },
      },
    );
  };

전체적인 틀은 아래와 같음.

onMutate의 경우 mutation 작업 이전에 실행되는 함수이므로 optimistic update 에 쓰인다. 또한 onMutate 에서 반환된 값은 onSuccess, onSettled, onError 콜백의 context 인자에 담긴다.

  1. onMutate 실행: 사용자가 어떤 변경을 UI에서 수행하면 (예: 버튼 클릭으로 북마크 상태 변경), 그 변경은 onMutate 내에서 최초로 발생합니다. 이는 "옵티미스틱 업데이트"의 시작 부분으로, 실제 서버 요청이 성공할 것이라는 "옵티미스틱"한 가정하에 UI를 먼저 업데이트합니다.
  2. query를 cancel함: onMutate가 실행될 때, 해당 쿼리에 대한 현재 진행 중인 모든 요청을 취소합니다. 이렇게 하면, 예를 들어, 데이터 패치 중이더라도 사용자의 상호작용에 따른 즉각적인 반응이 가능해집니다.
  3. 이전에 캐싱된 데이터를 getQueryData로 가져옴: 옵티미스틱 업데이트가 항상 성공한다는 보장이 없기 때문에, 오류가 발생했을 때 원래 상태로 되돌리기 위해 이전 상태의 "스냅샷"을 가져옵니다.
  4. setQueryData에서 newData를 셋해줌: 캐시를 즉시 업데이트하여 UI에 변경을 반영합니다. 이는 실제 서버 요청이 아직 완료되지 않았더라도 UI를 업데이트하는 것입니다.
  5. onSettled를 사용: 서버 요청이 완료되면 (성공 또는 실패 여부와 상관없이) onSettled가 호출됩니다. 여기서는 쿼리를 무효화하여 최신 데이터로 다시 패치하도록 합니다. 실패한 경우 onError가 먼저 실행되어 이전 상태로 되돌린 후, onSettled가 호출됩니다.
profile
같은 문제에 헤매지 않기 위해 기록합니다.

0개의 댓글