치춘짱베리굿나이스

tanstack-query Optimistic Update 적용하기 본문

ClientSide/라이브러리

tanstack-query Optimistic Update 적용하기

치춘 2023. 10. 22. 17:28

Optimistic Update

이전 게시물에서 쿼리 키를 싹 정리하였다

좋아요 버튼을 눌렀을 때 쿼리를 refetch 하고 나서야 화면에 변화가 생기는데, 이건 사용자 입장에서 엄청나게 느린 동작이 아닐 수 없다

Optimistic Update 를 적용하여 반응을 빠르게 만들어보자

사전 지식

Optimistic Update?

낙관적 업데이트 라는 뜻

요청을 보내고 응답을 기다리는 동안, 응답이 올 때까지 화면 UI 업데이트를 중단하지 않고 미리 결과를 예측해서 화면 UI를 미리 업데이트하는 기법이다

서버 응답이 당연히 성공할 것이라고 가정하고 화면 업데이트를 진행하기 때문에 낙관적 업데이트라고 불리는 것

예를 들어, 좋아요 버튼을 눌렀을 때

  1. 좋아요 post 요청이 서버로 보내진다
  2. 클라이언트에서는 “어차피 응답은 성공할 것이고 좋아요를 누른 것으로 처리되겠지?” 라고 예상하고, 미리 UI 를 업데이트 한다
  3. 좋아요 버튼이 눌린 것처럼 처리된다
  4. 서버에서는 응답을 열심히 처리하고 있다
  5. 서버에서 응답을 받는다
    • 만약 응답이 오류로 처리되었거나 좋아요 로직이 실패했을 경우, UI를 좋아요 버튼 누르기 전으로 다시 업데이트하거나 롤백한다

많은 웹 사이트 (인스타그램, 트위터 등) 에서 좋아요 버튼을 누르면 반응이 매우 빠르게 오는 것을 느꼈을 것이다

대부분 이 낙관적 업데이트를 적용해둔 것

왜 써염

즉각적인 반응성을 제공하기 위해 사용한다

좋아요 버튼이 바뀌려면 사용자는 응답이 돌아올 때까지 기다려야 하는데, 이 과정에서 사용자는 버그가 발생했거나 서버가 버벅인다고 느끼기 쉽다

좋아요 버튼을 눌렀을 땐 즉각적으로 눌렸음이 화면에 반영되어야 사용자에게 긍정적인 경험을 전달할 수 있다!

낙관적 업데이트 적용해보기

기존 좋아요 로직

import { QueryClient, useMutation } from '@tanstack/vue-query';

import articles from '@/services/articles';

export function usePostFavorite(queryClient: QueryClient, slug: string) {
  return useMutation({
    mutationFn: () => articles.postFavorite(slug),
  });
}

좋아요 버튼을 클릭했을 때, 실제로 서버에 좋아요 요청을 보내는 useMutation 로직이다

보다시피 useMutation 을 말그대로 데이터 요청하는 데에 사용만 한.. 그런 함수라고 할 수 있다

지금은 좋아요 버튼을 눌러도 즉시 반영이 안 되고, 새로고침을 해야 겨우 데이터의 변화를 확인할 수 있는 상황이다

onMutate 속성

useMutation({
    onMutate: (variables: TVariables) => Promise<TContext | void> | TContext | void
});

Mutation 함수가 실행되기 직전에 호출되는 함수이다

인자로는 useMutation 반환값인 mutate 함수 (Mutation 함수) 를 호출했을 때 넘겨준 인자가 그대로 들어온다

실제 쿼리 뮤테이션이 실행되기 전에 실행되는 함수이기 때문에, 낙관적 업데이트를 구현할 때 쿼리 조작을 여기서 수행할 수 있다

반환값인 TContext는 후속 동작 (이후에 호출되는 함수) 인 onError, onSettled에 전달할 수 있다

 

export function usePostFavorite(queryClient: QueryClient, slug: string) {
  return useMutation({
    mutationFn: () => articles.postFavorite(slug),
    onMutate: async () => {
            // mutate 시에 넘겨줄 인자가 없기 때문에 인자는 공란으로 비워둔다

            /* Mutate 수행 전에 쿼리를 조작하는 로직 */
    }
  });
}

useMutation 함수에 onMutate 를 추가하고, 필요한 로직을 생각해두자

onError 속성

useMutation({
    onError: (error: TError, variables: TVariables, context: TContext) => Promise<unknown> | unknown
});

뮤테이션이 실패하고 오류가 발생했을 때 호출되는 함수이다

뮤테이션이 실패했을 경우에 낙관적 업데이트를 해 두었던 것을 원상복구.. 할 때 사용한다

 

export function usePostFavorite(queryClient: QueryClient, slug: string) {
  return useMutation({
    mutationFn: () => articles.postFavorite(slug),
    onMutate: async () => {

    },
        onError: (error, variables, context) => {
            // onMutate 에서 반환한 반환값을 context 를 통해 받아오자
            // error 인자는 특별히 예외처리를 하고 싶을 때 사용하고,
            // variables 는 mutate 시에 넘겨주는 인자가 들어오지만 
            // 여기선 마찬가지로 인자가 없기 때문에 사용할 필요가 없다

            /* Mutate 실패 시에 쿼리를 원상복구하는 로직 */
        }
  });
}

onError 에는 조작한 쿼리를 다시 원래 상태로 돌려놓는 로직을 추가해볼 것이다

onSettled 속성

useMutation({
    onSettled: (data: TData, error: TError, variables: TVariables, context: TContext) 
        => Promise<unknown> | unknown
});

뮤테이션의 성공 / 실패 여부와 상관없이 반드시 호출되는 함수이다

뮤테이션이 성공했을 경우 TData가, 실패했을 경우 TError 가 인자로 들어오므로 해당 인자를 통해 구분할 수 있다

 

export function usePostFavorite(queryClient: QueryClient, slug: string) {
  return useMutation({
    mutationFn: () => articles.postFavorite(slug),
    onMutate: async () => {

    },
        onError: (error, variables, context) => {

        },
        onSettled: (data, error, variables, context) => { 
            /* Mutate 성공 / 실패 여부 관계없이 특정 쿼리를 새로 받아오는 로직 */
  });
}

Mutation이 수행되고 나서, 특정 쿼리 (예를 들면, 좋아요를 누른 게시물의 쿼리) 를 다시 받아오는 로직을 onSettled 에 넣어줄 것이다

그 이유는? Mutation을 통해 좋아요를 눌렀다는 사실을 Mutation 하면 서버 쪽 데이터와 클라이언트 쪽 데이터 (쿼리) 에 차이가 발생한다

이를 동기화해주기 위해 기존의 쿼리를 새로운 쿼리로 refresh 해주는 과정이고, 후술할 Query Invalidation 을 통해 구현할 것이다

cancelQueries

const queryClient = useQueryClient();

await queryClient.cancelQueries(queryKey);

현재 진행형인 쿼리를 수동으로 취소할 수 있다

자동적으로 refetch를 수행하는 것을 멈춰주기 때문에, Optimistic Update 적용 시에 유용하게 쓸 수 있다

Optimistic Update 적용 시에 많이 사용된다

  • Optimistic Update 에서는 임의로 쿼리 캐시를 조작해서 업데이트를 수행하므로, 서버에서 응답이 날아올 경우 쿼리 값에 변형이 생겨 낙관적 업데이트가 덮어씌워질 가능성이 있다
  • 낙관적 업데이트를 덮어씌우는 것을 방지하기 위해 쿼리를 수동으로 취소해서, 낙관적 업데이트를 통한 변동사항만 남겨두는 것

 

export function usePostFavorite(queryClient: QueryClient, slug: string) {
  return useMutation({
    mutationFn: () => articles.postFavorite(slug),
    onMutate: async () => {
            await queryClient.cancelQueries(articleKeys.article.slug(slug));
            // 뮤테이션을 중단함으로써 낙관적 업데이트가 덮어씌워지는 것을 방지한다
    },
        onError: (error, variables, context) => {

        },
        onSettled: (data, error, variables, context) => { 

  });
}

낙관적 업데이트를 수행하기 전, 미리 쿼리 뮤테이션을 중단시켜 낙관적 업데이트를 통해 변형한 쿼리 값이 덮어씌워지지 않도록 방지한다

getQueryData, setQueryData

const queryClient = useQueryClient();

const data = queryClient.getQueryData( queryKey );

queryClient.setQueryData(queryKey, (oldData) => newData);

각각 쿼리를 직접 가져오고 수정하는 메서드이다

  • getQueryData: 쿼리 키를 통해 쿼리의 캐시 데이터를 가져오기 위한 동기 함수이다
  • setQueryData: 수동으로 쿼리의 캐시 데이터를 수정하기 위한 동기 함수이다
    • 두 번째 인자로 새로운 데이터를 직접 집어넣거나, oldData 가 인자로 들어오는 함수를 통해 조작할 수 있다

 

export function usePostFavorite(queryClient: QueryClient, slug: string) {
  return useMutation({
    mutationFn: () => articles.postFavorite(slug),
    onMutate: async () => {
            await queryClient.cancelQueries(articleKeys.article.slug(slug));

            const prevData = queryClient.getQueryData(articleKeys.article.slug(slug));
            // 여기서 getQueryData는 onError에 넘겨줄 쿼리 데이터를 받아오는 데 쓰인다

            return { prevData };
            // prevData 를 context로서 반환, 이는 onError 과 onSettled 에서 사용 가능하다
    },
        onError: (error, variables, context) => {

        },
        onSettled: (data, error, variables, context) => { 

  });
}

쿼리 뮤테이션이 실패할 경우 onError 를 통해 쿼리 캐시를 원상복구 시켜주어야 하는데, 이때 사용할 원상복구 데이터 (이전 쿼리 데이터) 를 받아올 때 getQueryData 를 사용한다

 

export function usePostFavorite(queryClient: QueryClient, slug: string) {
  return useMutation({
    mutationFn: () => articles.postFavorite(slug),
    onMutate: async () => {
            await queryClient.cancelQueries(articleKeys.article.slug(slug));

            const prevData = queryClient.getQueryData(articleKeys.article.slug(slug));

            queryClient.setQueryData(articleKeys.article.slug(slug), (oldData: any) => ({
        ...oldData,
        favorited: true,
        favoritesCount: oldData.favoritesCount + 1,
      }));
            // Mutate 수행 전에 쿼리를 조작하는 로직

            return { prevData };
    },
        onError: (error, variables, context) => {
            if (context) 
                queryClient.setQueryData(articleKeys.article.slug(slug), context.prevData);
            // Mutate 실패 시에 쿼리를 원상복구하는 로직
        },
        onSettled: (data, error, variables, context) => { 

  });
}

실질적으로 쿼리를 조작하는 로직은 setQueryData 가 담당한다

oldData 를 활용하여 favorited, favoritesCount 속성만 값을 변경해 주었다

또한, onError 에서 쿼리를 원상복구할 때에도 setQueryData 를 활용한다

onMutate 에서 건네준 context 인 { prevData } 는 이전 쿼리 값을 임시저장한 것이므로, 이를 이용하면 쿼리를 원상복구할 수 있다

invalidateQueries

const queryClient = useQueryClient();

queryClient.invalidateQueries(queryKey);
queryClient.invalidateQueries(
    queryKey, 
    {
        refetchActive: true, // 활성 쿼리를 다시 가져온다
        refetchInactive: true, // 비활성화 쿼리를 다시 가져온다
    }
);

쿼리 데이터를 강제로 최신화하기 위해 쿼리 키에 해당하는 쿼리를 유효하지 않은 쿼리로 설정한다

Optimistic Update 적용 후, 데이터를 강제로 최신화하는 데 사용된다

쿼리는 특정 시간이 지나면 자동으로 무효화가 되는데, 이를 무시하고 임의로 무효화를 시켜버림으로써 새로운 데이터를 서버로부터 받아오는 것이다

서버의 데이터와 클라이언트의 데이터가 서로 다르다는 것이 확실할 경우, 동기화를 위해 사용한다

 

export function usePostFavorite(queryClient: QueryClient, slug: string) {
  return useMutation({
    mutationFn: () => articles.postFavorite(slug),
    onMutate: async () => {
            await queryClient.cancelQueries(articleKeys.article.slug(slug));

            const prevData = queryClient.getQueryData(articleKeys.article.slug(slug));

            queryClient.setQueryData(articleKeys.article.slug(slug), (oldData: any) => ({
        ...oldData,
        favorited: true,
        favoritesCount: oldData.favoritesCount + 1,
      }));

            return { prevData };
    },
        onError: (error, variables, context) => {
            if (context) 
                queryClient.setQueryData(articleKeys.article.slug(slug), context.prevData);
        },
        onSettled: (data, error, variables, context) => { 
            queryClient.invalidateQueries(articleKeys.lists.all);
      queryClient.invalidateQueries(articleKeys.article.slug(slug));
            // Mutate 성공 / 실패 여부 관계없이 특정 쿼리를 새로 받아오는 로직
  });
}

뮤테이션을 통해 서버에 있는 값을 변경했으므로, 서버 데이터와 클라이언트 쿼리 간 차이점이 존재한다

쿼리를 invalidate 함으로써 새로운 데이터를 받아 쿼리를 최신화할 수 있다

결과

onMutate 함수를 통해 쿼리 캐시 데이터를 수동으로 업데이트하는 방식으로 낙관적 업데이트를 구현하였다

빠릿빠릿하게 변화하는 좋아요 버튼을 확인할 수 있게 됐다..


참고 자료

https://medium.com/@kyledeguzmanx/what-are-optimistic-updates-483662c3e171

https://github.com/ssi02014/react-query-tutorial/tree/master

https://tanstack.com/query/v4/docs/react/reference/QueryClient#queryclientcancelqueries

Comments