흔하디 흔한 클릭버튼
우리가 사용하는 대부분의 서비스는 클릭이다.
클릭하여 돈을 송금하거나, 클릭하여 필요한 것들을 결제하거나.. 대부분 그렇다..
하지만, 잘 생각해보면 개발자들은 이에 주의해야 할 점이 많다.
function Button() {
const handleClick = () => {
}
return <button onClick={handleClick}>버튼</button>
}
기본적인 버튼에 클릭이벤트를 다는 방법이다.
흔히 말하는 '광클' 시 해당 함수가 무한정 실행된다.
만약 처리를 안했다면, 돈을 송금할 때 여러번 중복 송금이 되거나, 물건 결제가 여러번 될 수도 있다.
Debounce 는 안전할까?
이를 막는 방법은 Debounce 로 많은 글 및 chatgpt 가 알려주고 있다.
import React, { useEffect, useRef } from "react";
import debounce from "lodash/debounce";
function DebounceButton({ onClick, wait }) {
const debouncedClickRef = useRef(() => {});
useEffect(() => {
debouncedClickRef.current = debounce(onClick, wait, {
leading: true,
trailing: false,
});
return () => {
debouncedClickRef.current.cancel();
};
}, [onClick, wait]);
const handleClick = () => {
debouncedClickRef.current();
};
return <button onClick={handleClick}>DebounceButton</button>;
}
export default DebounceButton;
디바운스로 만든 버튼이다.
첫번째 인자로 실행할 클릭이벤트, 두번째 인자로 디바운스 시간을 입력하게 끔 되어있다.
우리는 송금, 결제 등 Api 를 호출하게 되어있다.
Api 호출되는 시간을 정의할 수 있는가를 고민해봐야 한다.
한 2초쯤 걸리겠지? 한 1초쯤 걸리겠지? 이렇게 가정하는 것 부터가 위험하다.
그때의 서버의 상황, 데이터의 양 등에 따라 달라지는 부분이다.
import { render, fireEvent, waitFor, screen } from "@testing-library/react";
import { jest } from "@jest/globals";
import { act } from "react";
import DebounceButton from "./DebouceButton";
jest.useFakeTimers();
describe("<DebounceButton />", () => {
it("0초에 클릭하고, 0.7초에 클릭하여 버튼이 1회 호출되어야 한다.", async () => {
const handleClick = jest.fn();
render(<DebounceButton onClick={handleClick} wait={1000} />);
const button = screen.getByText("DebounceButton");
fireEvent.click(button);
act(() => {
jest.advanceTimersByTime(700);
});
fireEvent.click(button);
await waitFor(() => {
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
it("0초에 클릭하고, 1.1초에 클릭하여 버튼이 2회 호출되어야 한다.", async () => {
const handleClick = jest.fn();
render(<DebounceButton onClick={handleClick} wait={1000} />);
const button = screen.getByText("DebounceButton");
fireEvent.click(button);
act(() => {
jest.advanceTimersByTime(1100);
});
fireEvent.click(button);
await waitFor(() => {
expect(handleClick).toHaveBeenCalledTimes(2);
});
});
});
이건 debounce time을 1초로 설정했을 때의 debounce testing 이다.
우리가 원하는 결과일까?
Api 호출하는데 걸리는 시간이 1.1초라고 가정했을 때, 우리는 2번 중복호출 할 수 있게된다.
버튼을 Disable 시키면 되지 않냐?
import React, { useEffect, useRef } from "react";
import debounce from "lodash/debounce";
function DebounceButton({ onClick, wait }) {
const debouncedClickRef = useRef(() => {});
const statusRef = useRef(false);
useEffect(() => {
debouncedClickRef.current = debounce(onClick, wait, {
leading: true,
trailing: false,
});
return () => {
debouncedClickRef.current.cancel();
};
}, [onClick, wait]);
const handleClick = async () => {
if (statusRef.current) {
return;
}
statusRef.current = true;
await debouncedClickRef.current();
statusRef.current = false;
};
return (
<button disabled={statusRef.current} onClick={handleClick}>
DebounceButton
</button>
);
}
export default DebounceButton;
컴포넌트를 수정해봤다.
기대한 결과는 반영이 되었다. ( 2회 호출이 아닌 1회 호출 됨 )
하지만 ref 의 변화는 리렌더링이 되지 않는다.
버튼 UI 등의 변화를 위해선 useState를 사용해야겠다.
useState를 사용하면 해결될까?
React 는 성능을 위해, useState 는 비동기적으로 작동한다.
이는 쉽게 생각하면, 컴포넌트 마다 수많은 useState 가 있을 텐데, 하나하나의 state 변화에 모든 리렌더링을 시킨다면,
성능 저하가 심각할 것이다.
그렇기때문에, setState가 연속으로 발생한다면, 이들을 모아 batch 한다.
즉, 리액트의 노드 내에 변경되야 할 노드를 한번에 변경한다.
그렇다면 클릭 사이에, 만약 비동기적으로 처리될 양이 방대하여, 즉시 처리하지 못한다면?
그 사이에 중복호출 할 수 있는 방법이 남게된다.
그럼 프론트에서는 못막는거야?
필자의 생각은 프론트에서는 기본적인 중복호출에 대한 방지를 할 뿐,
안전한 방식은 서버에서 중복호출에 대한 방식을 처리하는 것을 구현하는 것이 제일 좋다고 생각한다.
서버 개발자들을 위한 따닥이슈 방지에 대한 좋은 포스팅이 있어 아래 공유한다.
주니어 서버 개발자가 유저향 서비스를 개발하며 마주쳤던 이슈와 해결 방안 | 카카오페이 기술
혜택 서비스를 개발하며 어떤 이슈가 발생했고, 어떻게 해결했는지 소개하는 글입니다.
tech.kakaopay.com
하지만 프론트에서 아무런 처리도 하지 않는다면, 서버에 과부하된 호출이 발생할 수 있으니, 서버에서 처리한다 하더라도
기본적으로 어떤 방식으로 최대한의 호율을 낼 수 있는지 위와 같은 경우를 통해 프로젝트마다 반영하면 좋겠다.
P.S 서버의 검증 없이 중복호출의 문제가 Client 단에서 발생하고 Client 에서 끝나는 문제라면 최소한의 따닥이슈가 발생하지 않도록 하면 된다.