중복 요청 방지하기 (feat. 디바운스)

LazyMG·2025년 4월 3일
0

개발 일기

목록 보기
1/4
post-thumbnail

중복 요청 방지의 필요성

세상에는 개발자의 의도대로 행동하는 사용자들도 있지만 그렇지 않은 사용자가 훨씬 많은 것이 사실입니다.

너무나 상식적인 과정도 기상천외한 방법으로 뚫고 가는 멋진 사용자들도 있죠. 또 나쁜 목적으로 여러 사이트들에서 다양한 활동을 하곤 합니다.

때문에 개발자들은 사용자들의 다양한 행동들을 미리 예측하며 서비스에 문제가 될 수 있는 행동들이 동작하지 않도록 방지해야 합니다.

프론트엔드 개발자들은 일차적으로 사용자들의 부적절한 행동이 서버에 영향을 끼칠 수 없도록 막아야 합니다.

이번에 여러 대표적인 부적절한 행동중복 요청을 막기 위해 겪었던 시행착오들을 정리해봤습니다.

제가 주로 고민했던 중복 요청 사례는 사용자가 서버로 요청을 보낼 수 있는 버튼을 여러 번 클릭하는 경우입니다.

  1. 회원가입 버튼을 여러 번 클릭한다면 계정이 여러 개 생길 수 있습니다.
  2. 로그인 버튼, 좋아요 및 팔로우 버튼을 여러 번 클릭한다면 서버에 불필요한 요청이 여러 번 들어갈 수 있습니다.

가장 먼저 시도했던 것은 회원가입 버튼이었습니다.

때마침 디바운스를 적용할 때만 기다렸기 때문에 회원가입 중복 요청 방지를 위해 디바운스를 적용해보려고 했습니다.

디바운스가 뭐길래?

디바운스는 짧은 시간 간격으로 이벤트가 연속해서 발생하면 이벤트 핸들러를 호출하지 않다가 일정 시간이 경과한 이후에 이벤트 핸들러가 한 번만 호출되도록 합니다. 즉, 디바운스는 짧은 시간 간격으로 발생하는 이벤트를 그룹화해서 마지막에 한 번만 이벤트 핸들러가 호출되도록 합니다.
(모던 자바스크립트 Deep Dive 804p)

디바운스는 대표적으로 검색창에서 활용되곤 합니다.

사용자의 입력에 따라 서버에서 검색 결과를 보여주는 것이죠. 네이버나 유튜브 등에서 검색을 한다면 내가 한 글자 한 글자 입력할 때마다 검색어 미리보기를 볼 수 있습니다. 이는 사용자의 입력 정보로 API 요청을 보내는 것으로 구현할 수 있습니다.

하지만 사용자의 입력이 매우 빠르다면? 엄청난 횟수의 API 요청이 발생할 수 있습니다. 만약 유료 API의 경우라면 의도치 않게 엄청난 과금을 맞이할 수 있습니다.

이 경우에 디바운스(debounce)를 사용한다면 도움이 될 수 있습니다. 디바운스를 통해 일정 간격 동안 발생한 사용자의 입력 이벤트를 그룹화하여 마지막에 한 번만 이벤트 핸들러, 즉 API 요청을 보낼 수 있습니다.

적절한 간격 설정으로 사용자 경험을 챙기고 알뜰하게 API 요청 횟수 관리도 할 수 있겠죠?

디바운스를 회원가입에 적용한 과거의 저는 테스트 후 많은 수의 중복 이메일 계정들을 맞이했습니다.

회원가입 문제의 원인 분석

사실 제가 디바운스를 적용한 곳은 회원가입 버튼이 아니라 폼의 onSubmit이었습니다. 이 부분이 문제의 원인이었는지는 아직까지 정확히 확인이 되지 않았습니다. 더 확실하게 확인해 본 뒤에 추가해보겠습니다.

문제의 원인을 고민해보다가 애초에 회원가입 중복 요청을 디바운스만으로 방지한다는 것이 무리였습니다.

import { debounce } from "lodash";

const SignIn = () => {  
  const handleSubmit = async() => {
    // 유효성 검사
    
    // API 요청
    
    // success
    //// 로그인 페이지로 이동
    // fail
    //// 에러 표시
  }
  
  const debouncedSubmit = debounce(handleSubmit, 500);
  
  return (
  	<form onSubmit={debouncedSubmit}>
    	<input type='email' placeholder='이메일'/>
    	<input type='password' placeholder='비밀번호'/>
    	<input type='password' placeholder='비밀번호 확인'/>
    	<button>회원가입</button>
    </form>
  )
}

export default SignIn;

위 코드처럼 회원가입은 사용자 버튼 클릭 -> 유효성 검사 -> 서버 요청 -> 계정 생성 순으로 진행됩니다. 서버 응답에 따라 페이지가 이동되거나 에러 문구가 표시됩니다.

여기서 문제는 디바운스가 적용되어도 서버 응답까지의 시간 동안 요청이 여러 번 들어간다면 중복 요청 방지가 되지 않았던 것이죠.

결론적으로, 서버로 요청이 들어간 직후부터 서버에서 응답이 올 때까지 사용자가 요청을 보낼 수 없도록 해야 했습니다.

상태를 추가하다

사용자가 요청을 보낼 수 없도록 하는 방법은 단순했습니다. 버튼을 클릭하지 못하게 하여 서버로의 요청을 막는 것이죠.

구체적인 방법은 로컬 상태(isLoading)를 정의하고 이에 따라 버튼을 disabled하게 하는 것, 또 API 요청 함수에 상태를 확인하여 로딩 상태이면 return을 하는 단순한 방법이었습니다.

import { useState } from "react";
import { debounce } from "lodash";

const SignIn = () => {
  const [isLoading, setIsLoading] = useState(false);
  
  const handleSubmit = async() => {
  	if(isLoading) return;
    
    // 유효성 검사
    
    setIsLoading(true);
    
    // API 요청
    
    setIsLoading(false);
    
    // success
    //// 로그인 페이지로 이동
    // fail
    //// 에러 표시
  }
  
  const debouncedSubmit = debounce(handleSubmit, 500);
  
  return (
  	<form onSubmit={debouncedSubmit}>
    	<input type='email' placeholder='이메일'/>
    	<input type='password' placeholder='비밀번호'/>
    	<input type='password' placeholder='비밀번호 확인'/>
    	<button disabled={isLoading}>{isLoading ? '처리 중' : '회원가입'}</button>
    </form>
  )
}

export default SignIn;

위처럼 로컬 상태를 사용하고 회원가입 테스트를 진행해봤습니다.

그런데 버튼에 disabled가 동작하지 않았습니다. 기껏 달아놓은 attribute가 동작하지 않고 열심히 클릭할 수 있도록 해주더라구요;; 상태 변경도 제대로 되지 않았는지 중복 요청이 들어가 같은 계정이 여러 개 생겨버렸습니다.

디바운스와 로컬 상태를 조합하여 꽤 잘 막았다고 생각했는데 생각처럼 동작하지 않아 허탈했습니다.

디바운스 빼기

제목에서처럼 문제는 디바운스에 있었습니다. useState의 상태 변경 타이밍과 디바운스의 동작 간격이 맞물리면서 상태 변경이 되지 않은 것이었습니다.

버튼을 여러 번 클릭하면서 submit 이벤트 또한 여러 번 발생하게 되면 디바운스가 일정 간격동안 발생하는 이벤트들을 묶게 되는데, 이때 마지막 이벤트만 처리되고 이전 이벤트들은 무시됩니다. 이 부분을 제대로 이해하지 못해서 발생한 문제였습니다.

따라서 submit 이벤트로 실행되는 handleSubmit 함수가 마지막 한 번에 실행되어 상태 변경이 늦어지게 됩니다. 이후에는 상태를 추가하기 전과 동일하게 서버 응답이 올 때까지 사용자는 여러 번 버튼을 클릭하여 요청을 보낼 수 있었습니다.

이 상황에서 제 해결책은 과감히 디바운스를 제거하는 것이었습니다. 상태 변경 타이밍을 헷갈리게 하는 디바운스를 제대로 다룰 수 없다고 판단했습니다. 자신이 없었어요...

디바운스를 제거하고 상태로만 사용자의 요청을 방지하니 기대했던 것처럼 동작했습니다!

import { useState } from "react";

const SignIn = () => {
  const [isLoading, setIsLoading] = useState(false);
  
  const handleSubmit = async() => {
  	if(isLoading) return;
    
    // 유효성 검사
    
    setIsLoading(true);
    
    // API 요청
    
    setIsLoading(false);
    
    // success
    //// 로그인 페이지로 이동
    // fail
    //// 에러 표시
  }
  
  return (
  	<form onSubmit={handleSubmit}>
    	<input type='email' placeholder='이메일'/>
    	<input type='password' placeholder='비밀번호'/>
    	<input type='password' placeholder='비밀번호 확인'/>
    	<button disabled={isLoading}>{isLoading ? '처리 중' : '회원가입'}</button>
    </form>
  )
}

export default SignIn;

사용자가 회원가입 버튼을 클릭하면 상태가 변경되어 버튼이 disabled 되었습니다. 로그인 과정도 동일하게 디바운스를 사용하지 않고 중복 요청을 방지했습니다.

이렇게 디바운스 없이 중복 요청을 방지했는데요, 디바운스를 잘만 사용한다면 적절히 중복 요청을 방지할 수 있는 사례도 있었습니다.

디바운스 적용 사례

회원가입이나 로그인 버튼의 경우에는 사용자가 여러 번 클릭할 필요가 없는 부분이라고 생각했습니다. 한 번만 클릭하고 disabled로 하여 처리 중인 것을 보여준다면 된다고 생각했죠.

하지만 좋아요 버튼, 팔로우 버튼의 경우에는 한 번 클릭할 때마다 버튼이 disabled 되어 처리 중인 것을 보여주는 것은 사용자 경험에 좋지 않다고 판단했습니다. 그렇다고 해서 사용자의 모든 클릭마다 서버 요청을 보내는 것도 옳지 않죠. 이 경우에는 앞선 경우와는 다르게 접근했습니다.

먼저 사용자에게는 버튼을 클릭할 때마다 즉각적으로 상호작용을 확인할 수 있도록 했습니다. 버튼의 텍스트를 팔로우에서 언팔로우로 바꾸거나 하트 이모티콘이 비어져 있다가 채워지는 것처럼 말이죠. 하지만 버튼의 클릭 이벤트에는 디바운스를 달았습니다.

import { useState } from "react";
import { debounce } from "lodash";

const Artist = () => {
  const [isFollow, setIsFollow] = useState(false);
  
  const followArtist = async() => {
    setIsFollow(prev => !prev);
    
    debouncedFollow();
  };
  
  const updateFollowList = async() => {
  	// API 요청
    
    // success
    ////
    // fail
    //// 상태 원상복귀
    //// setIsFollow(prev => !prev);
  }
  
  const debouncedFollow = debounce(updateFollowList, 500);
  
  return (
    <div>
      <div>빈지노</div>
      <button onClick={followArtist}>{isFollow ? '언팔로우' : '팔로우'}</button>
    </div>
  )
};

export default Artist;

위 코드에서 중요한 부분은 싱태를 변경하는 코드와 API를 요청하는 코드를 분리했다는 것입니다. 앞서 봤듯이 디바운스와 상태 변경이 섞이게 되면 상태 변화를 예측하기가 어려워지기 때문입니다. 상태 업데이트는 빠르게 동작하도록 하고 API 요청이 여러 번 들어가지 않도록 방지하도록 했습니다.

서버 응답이 실패했을 경우의 상태 변경 코드는 문제가 될 수도 있을 것 같습니다. 서버로 요청하기 전 마지막 상태로 정확하게 복귀시켜야 합니다.

더 확인해봐야 할 것들

중복 요청을 방지하는 방법들을 고민하고 정리해보며 더 공부가 필요한 것들이 보였습니다.

  1. 폼의 submit 이벤트와 버튼의 click 이벤트 중에 디바운스를 적용하기 더 적절한 이벤트는 무엇일까?
  2. 상태만으로 사용자의 회원가입 중복 요청을 방지할 수 있을까?
  3. 비동기 요청을 취소할 수 있는 AbortController는 무엇이며 어떻게 동작할까? AbortController

위 내용들을 또 정리해서 기록해보겠습니다!

참고 링크

디바운스 공부에 도움이 되었던 링크들을 남깁니다.

https://www.freecodecamp.org/korean/news/debounce-dibaunseu-javascripteseo-hamsureul-jiyeonsikineun-bangbeob-js-es6-yeje/

https://velog.io/@seoyaon/Javascript-%EB%94%94%EB%B0%94%EC%9A%B4%EC%8B%B1debouncing%EA%B3%BC-%EC%93%B0%EB%A1%9C%ED%8B%80%EB%A7%81throttling

https://www.zerocho.com/category/JavaScript/post/59a8e9cb15ac0000182794fa

https://velog.io/@yujuck/Javascript-%EB%94%94%EB%B0%94%EC%9A%B4%EC%8A%A4%EC%99%80-%EC%93%B0%EB%A1%9C%ED%8B%80%EB%A7%81

profile
개발 기록

0개의 댓글