[react query] mutation 중복 호출 처리하기

pds·2023년 6월 3일
3

TIL

목록 보기
58/60

react-queryuseIsMutating hook을 활용해 전역으로 mutation 상태를 처리해보자


문제상황

짤로 요약

카드를 등록(POST)할 때 상황이다.

버튼을 클릭하면 카드 등록 API를 호출하고 성공하면 닫히며 목록에 생성한 카드가 식별되는 형태이다.

네트워크 속도가 항상 매우 빠른 상태로 유지되거나 서버가 항상 일정한 빠른 속도로 응답을 해줄 것이란 보장이 없기 때문에 위와 같은 상황에서는 의도하는 동작이 1회 수행되게끔 보장해줘야 하는 것 같다.


멱등성

사실 무언가 상호작용 했을 때 조회된다던가 수정하는 동작에서는 네트워크 지연 또는 서버 응답 문제로 여러번 호출되어도 결과가 같기 때문에 불필요한 호출을 하는 문제는 있더라도 보여지는 동작에서는 문제 없을 것이다.

하지만 POST와 같은 동작은 내용물이 같을 뿐 다른 고유 식별자를 가지는 새로운 자원이 호출한 만큼 생성되는 형태이기 때문에 서버에도 불필요한 자원이 중복으로 생성되고 사용자에게도 불편함을 주는 문제가 발생하는 것 같다.

따라서 여러 api를 호출하여 결과를 봐야 한다던가 응답속도가 느릴 확률이 높다면 최소한 멱등하지 않은 동작에 대해서는 의도한대로만 동작하도록 처리를 해주면 좋을 것 같다는 생각이 들었다.


useMutation

사실 위의 문제는 react-query를 사용하던 안하던 상관없이 발생할 수 있는 문제이다.

본 상황에서는 쿼리 무효화낙관적 업데이트 등 다양한 동작 수행과 에러처리, 테스트의 효율성 때문에 등록/수정/삭제 동작에는 모두 useMutation 훅을 사용하여 처리했었다.

따라서 react-query에서 제공하는 mutation 관련 상태 hook을 사용해 이 문제를 처리해보기로 했다.

호출하는 동안 버튼을 disabled로 만든다던가 여러 방법이 있겠지만 버튼은 api 호출과 관련된 동작만 수행할 뿐 아니라 다양한 방식으로 사용되기 때문에 건드리다가 문제가 발생할 가능성이 높아보였고

다른 부분에서도 재사용할 곳이 많을 것 같다는 생각이 들어 전체화면 로딩 스피너 형태로 업데이트 중 UI 처리를 구현해보기로 했다.


useIsMutating hook 사용하기

useIsMutating is an optional hook that returns the number of mutations that your application is fetching (useful for app-wide loading indicators).

리액트 쿼리에서는 useIsMutatinguseIsFetching 같은 편의 hook을 제공해준다.

useIsMutating은 현재 어플리케이션의 리액트 쿼리 인스턴스에서 발생하고 있는 mutation의 개수를 나타내주는 hook이다.

import { useIsMutating } from '@tanstack/react-query'
// How many mutations are fetching?
const isMutating = useIsMutating()
// How many mutations matching the posts prefix are fetching?
const isMutatingPosts = useIsMutating({ mutationKey: ['posts'] })

이 훅을 통해 기존의 useMutation 동작에 추가적인 코드를 작성하거나 설정하지 않아도 mutation 상태를 별도로 감지하고 관리할 수 있게 된다.

const GlobalLoadingSpinner = () => {
  const isMutating = useIsMutating();
  return (
    <GlobalLoadingSpinnerWrapper isVisible={!!isMutating}>
      <LoadSpinner width="100%" height="100%" />
    </GlobalLoadingSpinnerWrapper>
  );
};

글로벌 로딩 스피너 컴포넌트를 앱 전역에 걸쳐 동작하도록 구성하고 isMutating일 때 보여지게끔 하였다.


근데 사실 특정 동작을 수행중일 때 전체 화면으로 로딩중 UI가 식별되는 것 그 자체가 올바른지는 잘 모르겠다.

사용자가 요청한 것이 정상적으로 수행중임을 알릴 수 있는 수단이 될 수는 있지만 너무 자주나오면 오히려 산만하고 집중력을 떨어뜨릴 수 있다고 생각이 되기 때문이다.


그리고 이렇게 명확하게 로딩 스피너가 필요없는 경우도 있다.


mutationKey 옵션으로 mutation 필터링

useIsMutating 훅의 첫번째 파라미터 객체로 mutationKey를 받을 수 있다.

기본 값은 undefined이며

해당 객체의 값에 등록된 키가 없다면(undefined) 전부, 있다면 해당 키가 포함된 mutation들만 useIsMutating으로 감지한다.

mutationKey

mutation의 옵셔널한 설정으로 추가할 수 있다.

내부적으로 키를 설정하지 않았을 때 어떻게 설정되서 동작했나 확인하고 싶었는데 아직까진 방법을 모르겠다.

queryClientMutationCache를 확인해봐도 따로 지정하지 않으면 mutationKey 속성이 없다.

A mutation key can be set to inherit defaults set with queryClient.setMutationDefaults or to identify the mutation in the devtools

문서에도 이 설명이 다라서 애매하나 기본 mutation key를 잘 설정해서 사용한다면 원하는 mutation만 필터링해서 useIsMutating에서 감지해 로딩 처리를 할 수 있을 것이다.

  const isMutating = useIsMutating({ mutationKey: ['key'] });

로딩 스피너 등장 지연 처리

위에서 언급했던대로 수정할 때 마다 로딩스피너가 식별된다면 굉장히 산만할 것이다.

특히 일반적으로 수정,삭제와 같은 api에 대한 응답이 100ms 내외로 완료되는데
이 짧은시간동안 로딩스피너를 보여준다면 오히려 이로 인해 불필요한 점멸 효과를 유발하여 사용자 경험에 좋지 않을 것이다.

const GlobalLoadingSpinner = () => {
  const { isGlobalLoading } = useGlobalLoading();
  const isMutating = useIsMutating({ mutationKey: ['default'], exact: true });
  const isLoading = !!(isMutating || isGlobalLoading);
  const [isDeferred, setIsDeferred] = useState(false);
  const debouncedSetDeferred = useDebounce(setIsDeferred, 200);
  useEffect(() => {
    if (isLoading) {
      debouncedSetDeferred(true);
      return () => {
        debouncedSetDeferred(false);
      };
    }
  }, [isLoading]);
  return (
    <GlobalLoadingSpinnerWrapper isDeferred={isDeferred} isVisible={isLoading}>
      <LoadSpinner width="100%" height="100%" />
    </GlobalLoadingSpinnerWrapper>
  );
};

디바운싱을 통해 200ms가 지나기 전까지는 글로벌 로딩스피너가 눈에 보이지 않도록 처리하였다.

네트워크: 제한없음네트워크: 느린 3G

opacity로 로딩스피너가 눈에 보이지 않게만 한거라 중복호출은 쉽게 방지할 수 있으면서도 응답속도가 빠를 경우에는 불필요하게 깜빡거리지 않게 처리할 수 있다.

응답속도가 디바운싱을 적용한 시간과 비교했을 때 애매하게 느릴 경우에는 여전히 일시적인 깜빡임이 발생하겠지만 항상 깜빡거리는 것 보다는 훨씬 나을 것이라고 생각했다.


react query mutation 외 global loading 처리 hook 만들기

react query mutation 외에도 특정 비동기 함수의 호출이 완료되기까지 처리가 필요한 부분이 있었던 것 같다.

presigned url을 얻어와 s3에 이미지를 업로드하는 비동기 함수가 있었는데
이 함수는 완료되기까지 시간이 일정하지도 않고 꽤 길 수 있었다.

이 함수 자체에서 loading 전역 상태를 컨트롤하기에는 애매했던게 어떤 곳에서는 글로벌 로딩 스피너가 필요했지만 또다른 곳에서는 필요없었다.

따라서 전역 로딩 상태를 변경하면서 비동기 함수를 실행시켜주는 hook을 만들어 원하는 상황에서 호출할 수 있게 하고, 기존 mutation 로딩 스피너에 붙였다.

여기서 로딩 global state 관리는 recoil을 사용했다.

useGlobalLoading.ts

import { useRecoilState } from 'recoil';

import { isGlobalLoadingState } from '@/atoms/common';

const useGlobalLoading = () => {
  const [isGlobalLoading, setIsGlobalLoading] = useRecoilState(isGlobalLoadingState);

  const asyncCallbackLoader = async <R>(callback: () => Promise<R>) => {
    setIsGlobalLoading(true);
    try {
      const response = await callback();
      return response;
    } catch (e) {
      throw e;
    } finally {
      setIsGlobalLoading(false);
    }
  };
  return { isGlobalLoading, asyncCallbackLoader };
};

export default useGlobalLoading;

특정 비동기 함수 호출 전 loading 상태를 true로 바꿔주고 호출이 완료되었을 때 false로 직접 바꿔줘야하는 동작을 wrapper 함수를 만들어 callback을 인자로 받아 해당 hook에서 처리하게 했다.

const { asyncCallbackLoader } = useGlobalLoading();
// ...
const requestedThumbnail = await asyncCallbackLoader<string | undefined>(() => getUploadedThumbnail());

이런식으로 기존 동작에 영향을 주지 않고 똑같이 처리하면서도 로딩 상태를 처리할 수 있게 되었다.

로딩 스피너 컴포넌트에 추가해 상태를 사용할 수 있게 해주면 된다.

const GlobalLoadingSpinner = () => {
  const { isGlobalLoading } = useGlobalLoading();
  const isMutating = useIsMutating({ mutationKey: ['default'], exact: true, });
  return (
    <GlobalLoadingSpinnerWrapper isVisible={!!(isMutating || isGlobalLoading)}>
      <LoadSpinner width="100%" height="100%" />
    </GlobalLoadingSpinnerWrapper>
  );
};

테스트 코드

jest.mock('recoil', () => ({
  useRecoilState: jest.fn(),
}));

jest.mock('@/atoms/common', () => ({
  isGlobalLoadingState: jest.fn(),
}));

describe('useGlobalLoading', () => {
  beforeEach(() => {
    (useRecoilState as jest.Mock).mockReturnValue([false, setIsGlobalLoading]);
  });

  afterAll(() => {
    jest.clearAllMocks();
  });

  const setIsGlobalLoading = jest.fn();

  const fakeAsyncFunction = jest.fn(() => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve('Success');
      }, 200);
    });
  });
  it('주어지는 비동기 함수 호출 성공 시 loading 상태가 변경된다.', async () => {
    const { result } = renderHook(() => useGlobalLoading());
    expect(result.current.isGlobalLoading).toBeFalsy();
    await result.current.asyncCallbackLoader(() => fakeAsyncFunction());
    expect(setIsGlobalLoading).toHaveBeenCalledWith(true);
    expect(setIsGlobalLoading).toHaveBeenLastCalledWith(false);
  });

  it('주어지는 비동기 함수 호출 에러 발생시 loading 상태가 변경된다.', async () => {
    const { result } = renderHook(() => useGlobalLoading());
    fakeAsyncFunction.mockRejectedValue(new Error('Async error'));
    expect(result.current.isGlobalLoading).toBeFalsy();
    await expect(async () => {
      await result.current.asyncCallbackLoader(() => fakeAsyncFunction());
    }).rejects.toThrowError(new Error('Async error'));
    expect(setIsGlobalLoading).toHaveBeenCalledWith(true);
    expect(setIsGlobalLoading).toHaveBeenLastCalledWith(false);
  });
});

References

profile
강해지고 싶은 주니어 프론트엔드 개발자

0개의 댓글