(React) 커머스 프로젝트 : 결제 버튼 중복 클릭 방지하기

호두파파·2022년 11월 21일
7

React

목록 보기
35/38


유저의 이상 행동을 예측하기란

버그를 유발하는 유저의 "이상행동"을 예측하면서 코드를 작성하기란 여간 쉬운 일이 아니다.
프로덕트를 제작하기 위한 일정도 빠듯하거니와 "설마 이런 행동을 하겠어?"라는 의심과 확신으로
예외 처리를 고려하지 않게되는 것이 일반적이기 때문이다.

1) "결제하기"버튼을 연타한다거나,
2) PG 창을 띄워놓은 다음 뒤로 가기 버튼을 누른다거나 등의 이상 행동을 하는 유저는 그리 많지 않을 것이다.

하지만, 우리의 프로덕트는 작은 에러조차 허용해서는 안될 것이며, 특히나 "결제" 관련 이슈는 커머스 프로젝트에서 중대한 이슈로 받아들여지기 때문에 반드시 처리해야할 문제였다.

🖱 결제하기 버튼 중복클릭 막기

작성되어 있는 코드의 로직은 다음과 같았다.

1) 유저가 "결제하기" 버튼을 누른다.
2) 버튼이 눌리면 onClick 이벤트로 상태 값에 저장된 정보를 바탕으로 checkout api로 요청을 보낸다. 
3) api의 res가 전달되면 data 값을 바탕으로 PG에 전달할 데이터 form을 만드는 함수가 실행된다.
4) form이 만들어지면, 이어서 PG로 form데이터를 전달하면서 PG창을 호출한다.

흐름으로 보아 문제가 없는 로직같지만, "결제하기"부터 "PG창 호출까지"의 1-2초 간격동안 유저가 버튼을 적게는 두 번부터 많게는 5-6번 정도 클릭할 수 있는 것이 문제였다. (당연히 클릭한 만큼, api 요청이 이루어진 것도 코미디 😪)

이 문제를 해결하기 위해 자료를 서칭해보니, 몇 가지 해결책이 존재했다.

첫 번째 시도 : ButtonRef의 current를 이용해서 버튼을 disabled 처리하기

ref의 current 객체는 리렌더링이 되더라도 상태가 초기화되지 않는다는 점을 이용해 버튼 상태를 조작할 수 있다.
버튼을 한 번 클릭한 이후로는 더 이상 클릭되지 않도록 current 객체에 diabled 값을 true로 지정해 처리를 해보았다.

  const buttonRef = useRef();
  // onClick 이벤트로 실행될 함수
  const handleCheckout = async () => {
    buttonRef.current.disabled = true;
	...
  }
    
  return (
    <>
       <Button
     	ref={buttonRef}
  	  	onClick={handleCheckout} 	
  	  >
   </>
    ...
  )   
}

👺 하지만, 이렇게 처리를 했을 경우에는 문제가 몇 가지 발생했는데..

만일 유저가 "결제하기"를 의도치 않고 실수로 버튼을 클릭했다거나, 결제에 필요한 데이터를 모두 입력하지 않고 "결제하기"버튼을 클릭했을 때 경고 메시지를 띄우는 등의 로직이 존재할 경우, 경고 메시지가 한 번 렌더링 된 이후에는 버튼이 정상적으로 작동하지 않는다.

결국, 유저가 버튼을 "1번만 클릭하게 만드는 것"에는 성공했지만,
유저의 편의성과 예측성 모두를 잃었기 때문에 결코 훌륭한 선택이었다고 할 수 없다. 다시 문제는 원점으로..

두 번째 시도 : 데이터 패칭 라이브러리의 상태값 이용하기

올 해 들어 서버 상태 관리 라이브러리인 React Query를 아주 잘 이용하고 있다. GET 요청에는 useQuery나 useQueries를, POST나 UPDATE 등의 요청에는 useMutation를 이용해 코드를 직관적으로 작성할 수 있게 되었는데, 제공하는 옵션이 아주 유용해서 다양한 상황에서 요령껏 잘 이용하고 있다.

나는 이번 이슈에 데이터를 패칭하는 중임을 나타내는 상태값 isLoading을 이용해 버튼 대신 프로그레스 바를 렌더링한 다음, PG창이 요청되는 찰나의 시간동안에도 유저가 버튼을 더이상 클릭할 수 없도록 패칭 성공을 나타내는 isSuccess 또한 이용할 것이다.

import { useMutation } from "react-query";

// 결제관련 hook에 사용될 함수
const checkoutForMe = () => {
    const mutation = useMutation(async () => {
      try {
        const res = await cartApi.checkoutForMe({
          type: "order",
          data: payload,
        });
        setState((state) => ({
          ...state,
          order: res.data.order,
        }));
        return res.data;
      } catch (err) {
        console.log("checkout err :>> ", err)
      }
    });
  return mutation;
};

...

  // 결제 관련 페이지

  const { data, mutate, isLoading, error, isSuccess } = checkoutForMe();

  // api로 요청을 보낼 함수
  const createOrder = async () => {
    try {
      mutate();
    } catch (err) {
      console.log("checkout err :>> ", err);
    }
  };

  // onClick 이벤트 함수
  const handleCheckout = () => {
    if (여러가지 예외처리 상황) {
      ...
    }
    createOrder();    
  }

  // api 조회 결과 200 코드를 받으면, 로딩 스피너를 보여주고 다음 폼으로 이동한다.
  if (isLoading) {
    return (
      <FlexBox>
          <CircularProgress />
      </FlexBox>
      );
  }

  return (
    ...
    <Button
      onClick={handleCheckout}
      disabled={isSuccess}
    >
    결제 하기
   </Button>
  )
}

위 코드처럼 처리한 결과 결제하기 버튼을 클릭하면 다음과 같이 작동한다.

클릭과 동시에 데이터 패칭 요청이 이뤄져서, loading 상태를 나타내는 프로그레스바가 렌더링되었다가, 패칭이 이뤄지면 결제하기 버튼을 disabled처리하기 때문에 유저는 더 이상 더블 클릭할 수 없게 되는 것이다!

이 외에도 이벤트 자체에 flag 변수를 두어 api 요청을 막는 등의 방법도 존재하지만, 시도 결과 loading 상태를 이용하는 것이 유저의 이상 행동을 방지하면서도 ui도 깔끔하게 처리해줘 가장 성공적인 결과물을 보여주었다.


개발은 QA 이후부터

최근 팀의 프로젝트로 한창동안 진행해온 프로덕트의 릴리즈를 코 앞에 두고 있다. 꽤 오랜 기간동안 개발했지만, 모종의 이유로 헐거운 기획서를 바탕으로 프로젝트를 진행해야 했고, 당연하게도 QA 단계에서 발견되는 갖가지 에러로 버그 처리에 혼신을 다하고 있다.

유저의 이상 행동을 예측하고 테스트를 진행해주시는 QA 여러분들 덕분에 경험치가 팍팍 쌓여간다는 점에서 운이 좋다고 해야할지 😂

내일 혹은 모레 쯤 "PG 창을 호출하고 뒤로 가기 버튼을 누르는 케이스"를 처리했던 경험을 작성해봐야겠다.
이 글이 나와 동일한 문제를 겪고 있는 누군가에게 큰 힘이 되었으면 하는 바라며 이만 글을 마친다.

profile
안녕하세요 주니어 프론트엔드 개발자 양윤성입니다.

0개의 댓글