본문 바로가기

트러블슈팅

Button 무한 클릭 시 api 호출 막기

문제상황

버튼 클릭 시 네트워크 탭에서 확인해봤을때 불필요한 요청이 여러번 가고 있었던 상황이다.

해결과정

debounce / throttle 의 한계

debounce 는 정해진 시간동안 발생한 여러 이벤트 중, 앞(leading) 혹은 뒤(trailing)에 하나의 이벤트만 트리거 시키는 방법입니다.

Debounce를 react 에 적용한 코드입니다. debounce 는 import debounce from "lodash.debounce 를 가지고 왔습니다. Button 컴포넌트의 click handler에 debounce를 래핑한 컴포넌트 입니다. debounce 의 속성 중, leading 을 사용합니다. 사용자의 반응에 즉각적으로 이벤트를 발생시키기 위함입니다.

 

import debounce from "lodash.debounce";
import { useMemo } from "react";

export default function DebounceButton({
  waitMS,
  onClick,
}: {
  waitMS: number;
  onClick: () => void;
}) {
  const handleClick = useMemo(() => {
    return debounce(onClick, waitMS, { leading: true, trailing: false });
  }, []);

  return (
    <button type="button" onClick={handleClick}>
      DebounceButton
    </button>
  );
}

 

debounce 를 활용한 방식은 해피 케이스에는 문제가 없을 것입니다. 여기서 고민이 되는 부분은 waitMS 를 얼마로 설정할 것이냐 입니다. 상황에 따라 직관으로 정할 순 없는데.. API latency는 서버, DB 상황에 따라서 언제나 달라질 수 있습니다. 이를 고정한다는 것은 엄밀하지 않은 사고입니다.

 

debounce 를 사용하면, 2가지 케이스가 발생한다는 것을 알 수 있습니다.

 

1. api latency < debounce wait

 

1번 케이스를 보겠습니다. api가 빠르게 응답이 온다면, 보통의 경우에 큰 문제가 없습니다.

문제가 없는 경우는, API 가 성공했을 때입니다. API 가 성공해서 다음 유저 플로우를 타게 된다면, button disabled 시간(debounce wait - api latency)이 존재해도 문제가 되지 않습니다.

API 가 실패하면 어떻게 될까요? 일정 시간(debounce wait - api latency) 만큼, 사용자는 버튼을 다시 누르지 못 합니다. 그러므로 중복호출을 막기 위한 목적으로 함부로 debounce wait 를 길게 해서는 안 됩니다.

 

2. api latency > debounce wait

 

2번 케이스를 보겠습니다. api latency 가 더 길다면, 정말로 문제 입니다. debounce wait 가 끝난다면, 다시 clickable 한 상태가 되고, 서버에 중복호출을 할 수 있습니다. 이때는 서버에서 중복호출을 막고 있길 바래야될 것 입니다.

정리하자면, api latency 과 debounce wait 가 차이가 있기 때문에, debounce로 완벽한 중복호출을 막는 것은 본질적으로 불가능하다는 것입니다. (throttle 도 마찬가지 논리이므로 생략)

 

isLoading 상태

handler가 호출되면, isLoading 상태를 true로 만들고, promise 가 settled 되면, isLoading 을 다시 false로 되돌립니다.

function LoadingStateButton({ onClick }: { onClick: () => Promise<void> }) {
  const [isLoading, setIsLoading] = useState(false);

  return (
    <button
      type="button"
      onClick={async () => {
        if (isLoading) {
          return;
        }
        setIsLoading(true);
        await onClick();
        setIsLoading(false);
      }}
    >
      Button
    </button>
  );
}

 

위 코드가 문제가 없어보일 수 있습니다. 하지만 setState의 본질을 이해하면, 이는 문제가 됨을 짐작할 수 있습니다. react state는 성능 관리를 위해서, 일정 요청을 모아서 batch 로 상태를 업데이트 합니다. 즉, setState는 비동기 요청입니다.

위 코드를 예시로 설명하면, setIsLoading(true) 는 호출되는 시점에 바로 isLoading 상태를 수정하지 않습니다. 조건이 충족되었을 때 비동기로 update 합니다. 즉, 그 transition 기간 동안, click이 한번 더 발생한다면, 중복호출이 발생할 수 있습니다. 저는 실제로 이 방식을 활용하여 많은 중복호출 에러를 맞았습니다.

그러므로 isLoading 상태를 활용한 해결책도 근본적으로 중복호출을 막아주지 못 합니다.


useRef

react에서 즉각적인 변수값 변경을 위해서 사용하는 것은 useRef 입니다. Ref 값은 상태는 아니기 때문에 re-render는 발생시키지 않으면서, 참조값을 즉시 변경시킵니다.

 

function LoadingRefButton({ onClick }: { onClick: () => Promise<void> }) {
  const buttonRef = useRef(false);
  const reRender = useReRenderer();

  return (
    <button
      type="button"
      disabled={buttonRef.current}
      onClick={async () => {
        if (buttonRef.current) {
          return;
        }
        buttonRef.current = true;
        reRender();
        await onClick();
        buttonRef.current = false;
      }}
    >
      Button
    </button>
  );
}

 

isLoading 을 useRef로 선언하여, onClick 의 중복호출을 막고 있습니다!!!

 

이제는 React 19로 업데이트 되면서 ref를 사용하지 않고 useTransition를 사용해서 해봐도 좋을 것 같다 !