[UMC] 버튼 연타와 중복 요청, 어떻게 막을까?
프로젝트를 하다 보면 생각보다 자주 맞닥뜨리는 문제가 있다. 바로 버튼을 빠르게 여러 번 누르는 상황이다. 이번 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 스터디를 하면서 이런 고민을 해결하기 위한 방법을 서치해본 게 가장 큰 배움이었다. 앞으로 내가 만드는 서비스에서 비슷한 문제가 생기더라도, 이제는 당황하지 않고 차근차근 대응할 수 있을 것 같다.