XionWCFM의 로고 이미지

리액트 애플리케이션에서 API 중복 호출을 정확히 막는 방법

frontend
XionWCFM의 로고 이미지
유길종(XionWCFM)
2024.07.01. 14:32
프론트엔드 개발자는 여러가지 이유로 인해 프론트엔드 측에서 API 호출을 제한해야하는 경우가 있습니다. 예를 들면 회원가입, 비밀번호 찾기 등의 과정에서 전화번호 인증이 필요한 경우 인증 번호를 발송받는 API를 떠올릴 수 있을 것입니다.
이렇게 짧은 시간에 여러번 호출되면 안되거나, 중복으로 호출되면 안되는 경우를 처리하기 위해서 어떤 방법을 고려할 수 있을까요?
짧은시간에 많은 클릭이 있어도 한번만 요청을 보내도록 디바운싱을 걸어볼까요? 그런데 네트워크 상황이 좋지않아 디바운싱을 건 시간이 다 지나도록 응답이 안왔다면 어떨까요? 애초에 디바운싱 시간은 어떻게 정해야 옳은걸까요? 디바운싱으로 API 중복 호출을 잘 막을 수 있는게 맞을까요?
이렇게 생각해보면 사용자의 네트워크 상황을 예측하여 디바운싱 시간을 예언하는 것은 엔지니어링의 영역은 벗어난 듯 보여집니다. 그러면 어떻게 해결할 수 있을까요?

@tanstack/react-query의 isPending을 활용

프론트엔드 생태계에서 react-query는 이제 킬러도구 수준의 위상을 가지게 된 것 같습니다. 그리고 이러한 react-query useMutation 훅은 "isPending" 이라는 boolean 상태를 제공합니다. 요청이 진행중인 상황이라면 true 요청이 진행중이지 않다면 false인 상태입니다.
이를 활용하여 다음과 같은 코드를 작성할 수 있습니다. 예시를 한번 볼까요?
const TanstackExample = () => {
    const tanstack = useMutation({mutationFn:async() => {}})
    const handleClick = () => {
        if(tanstack.isPending) return
        tanstack.mutateAsync()
    }
    return <button onClick={handleClick}>이 버튼을 여러번 클릭하면?</button>
}
isPending 상태가 true라면 요청이 진행중인것이니 return 하고 그렇지않다면 요청을 보내기때문에 항상 요청이 한개만 진행될 것을 보장할 수 있는 것처럼 보입니다. 대체로 그렇습니다만 항상 그렇지는 않습니다. 이는 @tanstack/react-query가 isPending이라는 상태를 관리하는 방법을 참고해보면 쉽게 이해할 수 있습니다.
 
export function useMutation<
  TData = unknown,
  TError = DefaultError,
  TVariables = void,
  TContext = unknown,
>(
  options: UseMutationOptions<TData, TError, TVariables, TContext>,
  queryClient?: QueryClient,
): UseMutationResult<TData, TError, TVariables, TContext> {
  const client = useQueryClient(queryClient)
 
  const [observer] = React.useState(
    () =>
      new MutationObserver<TData, TError, TVariables, TContext>(
        client,
        options,
      ),
  )
 
위 코드는 useMutation의 구현 중 일부입니다. 세부사항을 모두 가져오지는 않았으나 위의 코드만 보고도 어느정도 유추할 수 있듯이 @tanstack/react-query는 옵저버패턴을 통하여 MutationObserver라는 클래스의 인스턴스의 행동변화를 옵저빙하는 것을 통해 리액트의 상태와 클래스의 상태변화를 동기화합니다.
   this.#currentResult = {
      ...state,
      isPending: state.status === 'pending',
      //...중략
즉 리액트 쿼리의 isPending 상태는 곧 리액트의 useState와 같은 상태와 같은 형태로 동작하게 됩니다. 여기에서 useState와 같은 리액트의 상태 업데이트가 즉각적이지 않다라는 점은 매우 주목해볼만한 지점입니다. 리액트는 성능상의 이유로 상태 변화를 즉각적으로 반영하지 않으며 일정 주기동안 상태변경 요청을 모아둔뒤 한번에 처리하는 방식을 사용하고 있습니다.
이로인해 setState는 비동기함수인 것처럼 동작한다. , console.log를 찍었을 때 state가 자꾸 한발자국 느리다. 같은 이야기들은 리액트를 처음 접했을 때 자주 겪게되는 혼란이기도 합니다.
그런데 중요한 것은 isPending 역시 이 state가 실제 로직보다 한발자국 느린 현상에서 자유롭지 못하다는 것입니다. 예시 코드를 통해 해당 현상을 증명해봅시다.
import { useMutation } from "@tanstack/react-query";
 
export default function Home() {
  const tanstack = useMutation({
    mutationFn: async () => {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      console.log("몇번이나 실행될까요?");
      return "tanstack";
    },
  });
  const handleClick = () => {
    if (tanstack.isPending) return;
    tanstack.mutate();
  };
 
  const handleAbnormalClick = () => {
    [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].forEach(() => {
      document.getElementById("tanstack")?.click();
    });
  };
 
  return (
    <div>
      <div>
        <button className=" px-4 py-2 bg-purple-700 rounded-full text-white" id="tanstack" onClick={handleClick}>
          클릭 당할 버튼
        </button>
      </div>
 
      <div className=" mt-16">
        <button
          className=" px-4 py-2 bg-purple-700 rounded-full text-white"
          id="tanstack"
          onClick={handleAbnormalClick}
        >
          인위적인 수차례의 클릭
        </button>
      </div>
    </div>
  );
}
 
이 코드에서 인위적인 수차례의 클릭 버튼을 클릭하게 되면 console.log는 몇번 찍히게 될까요?
10번의 호출이 일어나는 예제
정답은 10입니다. 왜냐하면 isPending이라는 상태는 즉각적으로 반영되지 못하기 때문입니다.
그렇다면 이런 비정상적인 클릭행위를 막기위해서는 어떤 방법을 택해야할까요?

useRef를 이용하여 상태 업데이트 없이 apicall을 막기

"use client";
 
import { useMutation } from "@tanstack/react-query";
import { useRef } from "react";
 
export default function Home() {
  const isFlight = useRef(false);
  const tanstack = useMutation({
    mutationFn: async () => {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      console.log("몇번이나 실행될까요?");
      return "tanstack";
    },
  });
  const handleClick = async () => {
    if (isFlight.current) return;
    isFlight.current = true;
    await tanstack.mutateAsync();
    isFlight.current = false;
  };
 
  const handleAbnormalClick = () => {
    [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].forEach(() => {
      document.getElementById("tanstack")?.click();
    });
  };
 
  return (
    <div>
      <div>
        <button className=" px-4 py-2 bg-purple-700 rounded-full text-white" id="tanstack" onClick={handleClick}>
          클릭 당할 버튼
        </button>
      </div>
 
      <div className=" mt-16">
        <button
          className=" px-4 py-2 bg-purple-700 rounded-full text-white"
          id="tanstack"
          onClick={handleAbnormalClick}
        >
          인위적인 수차례의 클릭
        </button>
      </div>
    </div>
  );
}
 
useRef를 추가하고 mutate대신 mutateAsync를 호출하도록 변경하였습니다. 이렇게 ref를 이용하여 이렇게 mutate 중인 경우에는 여러번 클릭되어도 적절히 return 할 수 있도록 처리하는 것을 통해 한번만 호출되도록 보장할 수 있습니다.
1번의 호출이 일어나는 예제
이 예시는 가장 간단하면서도 훌륭히 동작한다는 점에서 박수를 보낼만 하지만 그와 동시에 이 문제에 대해 매우 중요한 아이디어 또한 제공해줍니다. 바로 Api 요청이 진행중인지에 대한 여부와 Promise가 진행중인지에 대한 여부는 대체로 일치한다라는 가정입니다.
즉 Promise가 진행중이라면 API 요청 또한 진행중이라는 것이죠. 궁금해서 찾아보니 Go 진영에서는 이것과 유사한 개념을 Single Flight 패턴이라는 이름으로 부르고 있는 것 같습니다. (저는 Go를 잘 알지 못합니다.)
이 아이디어를 토대로 동작하는 singleflight 코드를 작성할 수 있었습니다.

Promise의 진행 상태를 토대로 api 호출을 관리하자

type SingleFlightType = {
  isFlight: boolean;
  promise: Promise<any> | null;
};
 
type ExecuteType<TData, Args extends any[]> = {
  key: unknown[];
  fn: (...args: Args) => Promise<TData>;
};
 
type PickFlightKey = { key: unknown[] };
export class SingleFlight {
  private promises: Map<string, SingleFlightType> = new Map();
  execute<TData, Args extends any[]>(body: ExecuteType<TData, Args>): (...args: Args) => Promise<TData> {
    const { fn } = body;
    const key = this.createKey(body.key);
 
    return async (...args: Args): Promise<TData> => {
      if (!this.promises.has(key)) {
        this.promises.set(key, { promise: null, isFlight: true });
        const promise = fn(...args);
        this.promises.set(key, { promise, isFlight: true });
        try {
          return await promise;
        } finally {
          this.promises.delete(key);
        }
      }
      return this.promises.get(key)!.promise as Promise<TData>;
    };
  }
 
  isFlight(props: PickFlightKey): boolean {
    const key = this.createKey(props.key);
    return this.promises.get(key)?.isFlight ?? false;
  }
 
  private createKey(key: unknown[]) {
    return JSON.stringify(key);
  }
}
 
그러나 필요한 기능들을 추가하다보니 사용성이 많이 저하되는 것을 느꼈습니다. 예컨대 제가 만든 SingleFlight 클래스는 사용을 위해 인스턴스를 만들어주어야하고 또 적절하게 함수와 인수들을 구분해주기 위해 리액트쿼리처럼 별도로 키를 주입해주는 과정도 필요했습니다.
막상 구현을 보다보니 문득 toss에서 오픈소스로 공개한 코드 중 하나가 머릿속에 스쳐지나갔습니다. batctRequestOf라는 함수였어요
const noop = () => {};
 
type CallbackFunctionType = (...args: any[]) => any;
 
export const batchRequestsOf = <F extends CallbackFunctionType>(callback: F) => {
  const promiseByKey = new Map<string, Promise<ReturnType<F>>>();
  return ((...args: Parameters<F>) => {
    const key = JSON.stringify(args);
    if (promiseByKey.has(key)) {
      // biome-ignore lint/style/noNonNullAssertion: <explanation>
      return promiseByKey.get(key)!;
    }
    const promise = callback(...args);
    promise.then(() => {
      promiseByKey.delete(key);
    }, noop);
    promiseByKey.set(key, promise);
    return promise;
  }) as F;
};
 
이 코드는 본질적으로 생각해보면 제 SingleFlight 코드와 크게 다르지 않아요.
다만 하나의 함수에대해 하나의 스토어만 생성되기에 함수의 인자만 트래킹하여도 충분하니 별도의 키를 주입받을 필요가 없다는 점과 그로 인해 코드의 양 자체도 줄어드는 한편 동작은 동일하다는 점이 좋은 것 같습니다.
const tanstack = useMutation({
    mutationFn: batchRequestsOf(async () => {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      console.log("몇번이나 실행될까요?");
      return "tanstack";
    }),
  });
이렇게 mutation 함수에 래핑해주는 것 만으로도 한번의 호출이 보장되도록 변경할 수 있습니다.

API 응답이 빠른 경우도 고려하자

그러나 이런 promise를 기반으로한 request의 batch처리는 api 응답이 충분히 빠른 경우를 대처하지 못한다는 한계가 존재합니다. 즉 인간이 클릭하는 속도보다 api 응답이 빠른 경우에는 중복호출이 일어나는 것처럼 보인다는 문제입니다.
이러한 케이스에서는 디바운싱, 쓰로틀링에 대한 처리가 매우 유효하게 작용하기 때문에 하나의 기법만 적용하기보다는 서로 상호보완적인 관계라고 이해하는 것이 조금 더 적절할 것 같습니다.

마치며

이번에는 프론트엔드에서 발생할 수 있는 사용자 행동에 의한 API 호출 문제를 다루어보았습니다. 개인적으로는 해당 문제를 일정의 압박에 쫓기며 머리를 짜내 useRef를 통해서 구현했었던 경험이 있는데요 동작은 잘됐지만 코드복잡도가 크게 올라갔던 기억이 있어 다른 방법들을 탐구해보았습니다.
그럼 오늘은 이만 마치도록 하겠습니다. 읽어주셔서 감사합니다.
XionWCFM을 나타내는 캐릭터 이미지
유길종(XionWCFM)
무언가를 쉽게 설명 해낼 때 쾌감을 느끼는 사람입니다. 현재는 프론트엔드 개발자로 일하고 있습니다.