Tech/Sofeware Development

React에서 중복호출(aka. 따닥)을 막는 완벽한 방법

행복한 시지프 2024. 4. 14. 23:17

서언

안녕하세요. 최근에 퀄리티 높은 프론트엔드 제품을 만드는 것에 관심이 많은데요. 사소해보이는 디테일을 얼마나 능숙히 처리하느냐가 프론트엔드 개발자의 실력 척도 중 하나라고 생각했어요. 저는 여러 원칙들을 세우고 있지만, 오늘은 중복호출 (aka 따닥)을 방지하는 완벽한 방법을 탐구해볼 것입니다.

문제인식

서비스를 개발하다가, 중복호출이 발생해서 여러 문제가 발생하는 경우가 있습니다. 결제 요청이 2번 들어갈 수도 있고, 게시물 작성이 2번 될 수도 있고, 댓글이 2번 써질 수도 있습니다. 이로 인해 비즈니스적으로도 영향을 미칠 수도 있습니다. 작게는 서버 에러 수가 많아져서, noisy 해질 수 있죠. 이만큼 중요도가 높고, 프론트엔드 퀄리티에 큰 역할을 한다고 생각했는데요. 실제로 저도 명확한 해결책을 가지고 있지 않았고, 팀원들도 각자 다른 방식으로 해결을 하고 있었어요. 개중에는 결함이 있는 방식도 있었어요. 그래서 여러 방식을 탐구하고, 최적의 방식을 제안하기로 했어요.

 

오늘의 글은,

  1. 중복호출을 해결하려는 여러 시도들을 먼저 알아보고,
  2. 각각의 한계를 지적하고,
  3. 더 나은 해결책을 제안하며

마무리 해볼게요.

어떻게 테스트 할 것인가?

디버깅을 위해서는 먼저 테스트 환경을 갖추는 것이 중요합니다. 분석에 들어가기전에, 중복호출을 테스트 하는 방법을 찾아보겠습니다.

 

세 가지 테스트 방법이 있겠습니다.

  1. 손 테스트
  2. 브라우저에서 직접 click 함수 여러번 호출하기
  3. Cypress 테스트 코드

 

1. 손 테스트

이 글의 목적은 1ms 안에서도 절대 중복호출이 발생하지 않는 코드를 찾는 것이므로, 1번만을 사용하는 것은 쉽지 않습니다. 아무리 빨리 눌러도 수십 ms 는 될 것이기 때문입니다.

 

2. 브라우저에서 직접 click 함수 여러번 호출하기

아래 코드처럼, DOM 을 select 해서, 동시에 여러번 click 하게 만드는 것입니다. 브라우저에서 직접 테스트하므로, 아주 정확합니다.

const triggerClick = () => $0.click();
for (let i = 0; i < 3; i++) {
  triggerClick()
}

 

3. Cypress Component Testing 테스트 코드

매번 DOM을 select 해서 테스트하는 것이 귀찮을 뿐더러, 문서화가 되지 않습니다. 테스트 코드와 함께라면, 테스트 문서를 작성하면서 정확하게 테스트 할 수 있습니다. Cypress를 택한 것은, 브라우저 환경에서의 정확한 재현이 중요하기 때문입니다. testing-library로 해보았는데, 브라우저 모킹 환경이라 중복호출을 정확히 테스트하기 쉽지 않더군요. 이번 글은 Cypress Component Testing 과 함께 합니다.

debounce / throttle 의 한계

구글에 중복호출 방지 라고 검색을 하면, debounce/throttle 을 제안하는 글을 여러개 볼 수 있습니다. 저는 이것의 한계를 말씀드리고 싶습니다.

 

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

Debounce를 react 에 적용한 코드입니다. debounce 는 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 는 보통 1초 안에 끝나니까 1초로? 여유롭게 3초로? 이런 직관으로 정할 순 없겠죠. API latency는 서버, DB 상황에 따라서 언제나 달라질 수 있습니다. 이를 고정한다는 것은 엄밀하지 않은 사고입니다.

 

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

  1. api latency < debounce wait
  2. 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 가 끝난다면, 다시 clickable 한 상태가 되고, 서버에 중복호출을 할 수 있습니다. 이때는 서버에서 중복호출을 막고 있길 기도해야겠지요.

 

아래는 cypress 테스트 코드입니다.

it("api latency가 debounce wait 보다 길 때, handler가 2회 이상 호출될 수 있다.", async () => {
  let handlerCalledTimes = 0;
  const handleClick = async () => {
    handlerCalledTimes = handlerCalledTimes + 1;
    await delay(2000);
  };

  cy.mount(<DebounceButton waitMS={1000} onClick={handleClick} />);
  const button = cy.get("button");
  button.dblclick();

  cy.wait(1100);

  button.dblclick();
  cy.wrap(null).then(() => {
    expect(handlerCalledTimes).to.be.greaterThan(1);
  });
})

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

 

그럼 debounce, throttle 의 목적은 무엇일까요? 이들은 “중복호출”을 막기 위함이 아니라, “과도한 호출” 을 막기 위함입니다. 검색, 광클이 가능한 버튼 (게임 아이템 주기 등), 스크롤 이벤트 제어 등에 쓰입니다. 이를 중복 호출 방지에 쓰는 것은 적절하지 않습니다.

isLoading 상태를 활용한 분기의 한계

중복호출 방지를 검색해보면, 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);
      }}
    >
      LoadingButton
    </button>
  );
}

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

 

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

 

아래는 cypress 테스트 코드입니다.

it(`button 을 연속으로 2회 클릭하면, handler도 2회 호출된다.`, () => {
  let handlerCalledTimes = 0;
  const handleClick = async () => {
    handlerCalledTimes = handlerCalledTimes + 1;
    await delay(2000);
  };

  cy.mount(<LoadingStateButton onClick={handleClick} />);
  const button = cy.get("button");
  button.dblclick();

  cy.wrap(null).then(() => {
    expect(handlerCalledTimes).to.equal(2);
  });
})

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

Solution

그렇다면 대체 어떻게 해야 근본적으로 중복호출을 막을 수 있을까요? 상태처럼 비동기 업데이트가 아니라, 즉각적인 업데이트 방식이 필요합니다. react에서 즉각적인 변수값 변경을 위해서 사용하는 것은 useRef 입니다. Ref 값은 상태는 아니기 때문에 re-render는 발생시키지 않으면서, 참조값을 즉시 변경시킵니다. 그러므로 중복호출을 완벽히 가드할 수 있습니다.

 

예시 코드를 살펴보겠습니다. isLoading 을 useRef로 선언하여, onClick 의 중복호출을 막고 있습니다.

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

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

이것의 단점은 무엇일까요? 위에 disabled 를 isLoadingRef.current 값으로 넣었는데요. 이게 정상적으로 동작할까요? 상술했듯, ref 변수 변화는, 상태 변화가 아니기 때문에 re-render를 유발하지 않습니다. 그러므로, button 컴포넌트가 다시 그려지지 않기 때문에, disabled가 true가 된 button UI 를 반영하지 못 합니다.

 

그렇다면, 더 나은 해결책을 떠올려봅시다. 완벽한을 위한 아래 두가지입니다.

  1. re-render를 유발한다.
  2. 즉시적인 변수값 변경으로, 중복호출을 완전히 방어한다.

1번을 위해서는 상태를 도입해야만 하고, 2번을 위해서는 useRef를 사용하거나, 즉각적인 상태 변경이 필요합니다. Advanced Solution 2가지를 살펴보겠습니다.

Advanced Solution 1

ref 값과 상태를 모두 사용하여 해결하는 예시입니다. ref는 즉각적인 변수값 변경을 위해서 사용하고, 상태는 re-render 만을 위해 사용합니다.

function LoadingRefWithRenderButton({
  onClick,
}: {
  onClick: () => Promise<void>;
}) {
  const isLoadingRef = useRef(false);
  const reRender = useReRenderer();

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

function useReRenderer() {
  const [, setState] = useState({});
  return useCallback(() => setState({}), []);
}

이렇게 하면, disabled 상태를 반영하면서, 중복호출을 원천 차단할 수 있습니다.

 

아래는 cypress 테스트 코드입니다.

it(`button 을 2회 연속 클릭해도, handler는 1회 호출된다.`, () => {
  let handlerCalledTimes = 0;
  const handleClick = async () => {
    handlerCalledTimes = handlerCalledTimes + 1;
    await delay(2000);
  };

  cy.mount(<LoadingRefReRenderButton onClick={handleClick} />);
  const button = cy.get("button");
  button.dblclick();

  cy.wrap(null).then(() => {
    expect(handlerCalledTimes).to.equal(1);
  });
});

Advanced Solution 2

상태 변경을 비동기로 실행하지 않고, 동기적으로 실행하는 방법은 없을까요? 이를 위해서 react18 에서 flushSync API 가 나왔습니다.

 

flushSync lets you force React to flush any updates inside the provided callback synchronously. This ensures that the DOM is updated immediately.

 

 

간단하게 setIsLoading(true) 인 부분만 flushSync로 감싸줍니다. flushSync는 react 의 최적화된 성능관리를 거스르고 re-render 를 강제로 유발하는 API 이기 때문에, 권장되지 않습니다. 그러므로 사용을 최소화 하기 위해서 setIsLoading(false) 에는 flushSync를 사용하지 않습니다.

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

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

이 방법은 react18 이상에만 적용되는 방법이므로, 범용적인 라이브러리 사용하는 것은 권장되지 않습니다.

아래는 cypress 예시 코드입니다.

it(`button 을 2회 연속 클릭해도, handler는 1회 호출된다.`, () => {
  let handlerCalledTimes = 0;
  const handleClick = async () => {
    handlerCalledTimes = handlerCalledTimes + 1;
    await delay(1000);
    await delay(1000);
  };

  cy.mount(<LoadingSyncStateButton onClick={handleClick} />);
  const button = cy.get("button");
  button.dblclick();

  cy.wrap(null).then(() => {
    expect(handlerCalledTimes).to.equal(1);
  });
});

 

결론

오늘은 react에서 중복호출을 막기 위한 여러 방법들을 알아보았습니다. 흔힌 사용되는 방법의 한계를 지적하고, 새로운 방법을 제안해보았습니다. 이를 근본적으로 이해하기 위해, 결국 react에 대한 이해가 많이 필요했습니다. react에 대한 이해만 있다면, 결론 자체는 어렵지 않았던 것 같습니다. react의 상태와 ref에 대한 이해를 기반으로 동작을 유추하는 과정, 테스트를 통해 재현 환경을 구성하고 명확한 결론을 이끌어내는 과정이 의미있었습니다. 이번 글과 함께 한층 더 완성도 있는 서비스를 만들 수 있길 바랍니다.

 

관련 코드는 아래에서 확인하실 수 있습니다.

https://github.com/euijinkk/prevent-duplicate-call/tree/main