[UMC] 버튼 연타와 중복 요청, 어떻게 막을까?

Backend·2025-09-17·by 1000hyehyang

프로젝트를 하다 보면 생각보다 자주 맞닥뜨리는 문제가 있다. 바로 버튼을 빠르게 여러 번 누르는 상황이다. 이번 UMC 0주차 두 번째 시니어 과제도 “미션 도전!” 버튼의 중복 요청 문제를 다룬다.

분명 기획자는 사용자가 버튼을 ‘한 번’ 누른다고 가정했지만, 실제 사용자 행동은 그렇지 않다. 생각보다 많은 사람이 습관적으로 두 번, 세 번 연속으로 클릭한다. 혹은 네트워크가 느리면 “눌린 게 맞나?” 싶어서 다시 누른다. (특히 성격이 급한 한국인들은 더욱 그러지 않을까? 물론 내 이야기다.) 그 순간 서버에는 똑같은 요청이 여러 개 날아간다.

결과는? 동일한 미션이 두 번 등록되거나, 포인트가 중복 지급되거나, 심지어 DB에 에러 로그가 쌓인다. 작은 문제 같아 보여도, 실서비스라면 바로 버그 리포트로 이어질 수 있는 이슈다.

 

프론트엔드에서의 대응

프론트엔드에서 할 수 있는 가장 기본적인 대응은 버튼 비활성화다.

const [loading, setLoading] = useState(false);

const handleClick = async () => {
  if (loading) return;
  setLoading(true);
  try {
    await doMission();
  } finally {
    setLoading(false);
  }
};

<Button onClick={handleClick} disabled={loading}>
  {loading ? "처리 중..." : "미션 도전!"}
</Button>

버튼을 누르는 순간 disabled 상태로 전환하고 로딩 스피너를 보여주면, 사용자는 “눌렀다”는 피드백을 받는다. 자연스럽게 여러 번 누를 이유가 사라진다.

하지만 이건 어디까지나 첫 번째 방어선이다. 악의적인 사용자가 직접 API를 연속 호출한다면 프론트 방어만으로는 부족하다.

 

서버에서의 대응

1. DB Unique 제약조건

가장 기본적이고 강력한 방법은 DB 레벨에서 유일성 보장을 두는 것이다.

CONSTRAINT uq_user_mission UNIQUE (user_id, mission_id)

→ 같은 유저가 같은 미션에 대해 두 번 insert하려 하면 DB 자체에서 거부함.

Spring Boot + JPA라면 다음처럼 엔티티에 제약조건을 달 수 있다.

@Entity
@Table(
    name = "user_mission",
    uniqueConstraints = {
        @UniqueConstraint(columnNames = {"user_id", "mission_id"})
    }
)
public class UserMission {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long userId;
    private Long missionId;
}

이렇게 하면 같은 유저가 같은 미션에 대해 insert하려는 순간 DB가 막아준다.

서비스 코드에서는 DataIntegrityViolationException을 잡아 예외 메시지를 사용자 친화적으로 바꿔주는 게 중요하다.

2. 트랜잭션 + 락

Race condition을 잡고 싶다면 트랜잭션과 락을 활용한다.

public interface UserMissionRepository extends JpaRepository<UserMission, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT um FROM UserMission um WHERE um.userId = :userId AND um.missionId = :missionId")
    Optional<UserMission> findByUserIdAndMissionIdForUpdate(@Param("userId") Long userId,
                                                           @Param("missionId") Long missionId);
}

서비스 레이어에서 이렇게 사용한다.

@Transactional
public void participateMission(Long userId, Long missionId) {
    Optional<UserMission> existing = userMissionRepository.findByUserIdAndMissionIdForUpdate(userId, missionId);
    if (existing.isPresent()) {
        throw new AlreadyParticipatedException("이미 참여한 미션입니다.");
    }
    userMissionRepository.save(new UserMission(userId, missionId));
}

이러면 동시에 두 요청이 들어와도 하나는 대기하게 되고, 결국 중복 insert를 막을 수 있다.

3. Redis 분산 락

서버가 여러 대라면 DB 락만으로는 부족하다. Redis 분산 락을 쓰는 게 안전하다.

public void participateMission(Long userId, Long missionId) {
    String key = "mission:" + userId + ":" + missionId;
    RLock lock = redissonClient.getLock(key);

    try {
        if (lock.tryLock(3, 5, TimeUnit.SECONDS)) {
            if (userMissionRepository.existsByUserIdAndMissionId(userId, missionId)) {
                throw new AlreadyParticipatedException("이미 참여한 미션입니다.");
            }
            userMissionRepository.save(new UserMission(userId, missionId));
        } else {
            throw new RuntimeException("잠금 획득 실패");
        }
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        lock.unlock();
    }
}

Redisson 같은 라이브러리를 쓰면 분산 환경에서도 안정적인 락을 쉽게 구현할 수 있다.

4. Idempotent 설계

가장 우아한 방법은 멱등성 보장이다.

같은 요청을 여러 번 보내도 결과가 항상 똑같다

예를 들어 API를 다음처럼 설계한다.

POST /missions/{missionId}/participation

이미 참여한 상태라면 새로 insert하지 않고 그대로 성공 응답을 준다.

@Transactional
public void participateMission(Long userId, Long missionId) {
    if (userMissionRepository.existsByUserIdAndMissionId(userId, missionId)) {
        return; // 이미 참여했으면 그대로 종료
    }
    userMissionRepository.save(new UserMission(userId, missionId));
}

이렇게 하면 동일 요청이 몇 번 들어와도 최종 결과는 항상 동일하다.

  

실무에서 고려해야 할 것들

여기서 한 가지 더 고민해야 한다. 바로 사용자 경험(UX)과 성능의 균형이다.

단순한 학습 프로젝트에서는 DB Unique 제약조건만으로도 충분하다. 가장 구현이 간단하면서도 안전하다.

하지만 트래픽이 늘어나고 동시 요청이 많아지면 락 경합 때문에 성능이 떨어질 수 있다. 이때는 멱등성 API 설계가 훨씬 자연스럽다.

대규모 분산 환경에서는 Redis 분산 락까지 고려해야 한다. 하지만 작은 서비스에서는 오히려 오버엔지니어링일 수 있다.

즉, “어떤 방법이 가장 좋은가?”라는 질문에는 정답이 없다. 서비스의 규모, 트래픽 패턴, 팀의 역량에 따라 달라진다.

 

회고

이번 미션을 통해 단순히 “버튼을 두 번 눌렀다”라는 작은 문제에서 출발했지만, 결국은 동시성 제어, 트랜잭션, 멱등성, 분산 락 같은 주제로 확장되었다.

프론트엔드에서는 단순히 버튼을 비활성화하는 것만으로도 효과가 있었고, 서버에서는 DB 제약조건으로 기본기를 다졌다. 더 나아가 Redis 락이나 멱등성 설계까지 살펴보면서, “규모가 커지면 결국 이런 것들을 고민해야 한다”는 걸 체감했다.

Spring Boot 스터디를 하면서 이런 고민을 해결하기 위한 방법을 서치해본 게 가장 큰 배움이었다. 앞으로 내가 만드는 서비스에서 비슷한 문제가 생기더라도, 이제는 당황하지 않고 차근차근 대응할 수 있을 것 같다.

댓글 로딩 중...