쿠폰 중복 사용 버그, 비관적 락 한 줄로 해결하기
문제 상황
주문 API를 만들고 테스트하던 중 이상한 현상을 발견했다.
동시에 여러 요청이 들어오면 같은 쿠폰이 2번 사용되는 버그가 발생했다.
분명히 쿠폰 사용 여부를 체크하는 로직이 있는데, 왜 이런 일이?
원인 분석
문제는 이랬다.
시간 →
스레드 A: 쿠폰 조회 (사용 가능!) → 사용 처리
스레드 B: 쿠폰 조회 (사용 가능!) → 사용 처리두 스레드가 거의 동시에 조회하면 둘 다 "사용 가능"으로 판단한다. 그리고 둘 다 사용 처리를 해버린다.
이게 바로 Lost Update 문제다.
해결: 비관적 락
해결은 간단했다. 조회할 때 락을 걸면 된다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
SELECT mc FROM MemberCoupon mc
JOIN FETCH mc.coupon c
WHERE mc.memberId = :memberId
AND mc.coupon.id = :couponId
AND mc.deletedAt IS NULL
""")
fun findByMemberIdAndCouponIdWithLock(memberId: String, couponId: Long): MemberCoupon?
@Lock(LockModeType.PESSIMISTIC_WRITE) 한 줄 추가했다.
이제 흐름이 이렇게 바뀐다:
시간 →
스레드 A: 쿠폰 조회 (락 획득) → 사용 처리 → 락 해제
스레드 B: 대기... → 조회 (이미 사용됨!) → 실패서비스 코드
fun getMemberCoupon(memberId: String, couponId: Long): MemberCoupon? {
couponRepository.findByIdOrThrow(couponId)
// 비관적 락으로 조회
return memberCouponRepository.findByMemberIdAndCouponIdWithLock(memberId, couponId)
?: throw CoreException(ErrorType.COUPON_NOT_FOUND, "쿠폰을 보유하고 있지 않습니다")
}
락이 걸린 메서드를 호출하면, 트랜잭션이 끝날 때까지 다른 스레드는 해당 row에 접근하지 못한다.
왜 비관적 락?
낙관적 락도 고려했지만, 쿠폰은 비관적 락이 맞다고 판단했다.
| 구분 | 비관적 락 | 낙관적 락 |
|---|---|---|
| 방식 | 조회 시 락 | 수정 시 버전 체크 |
| 충돌 시 | 대기 | 예외 → 재시도 |
| 적합한 경우 | 충돌 많음, 실패 비용 큼 | 충돌 적음 |
쿠폰 사용은:
- 한 번 실패하면 UX가 나빠짐 (결제 실패)
- 동시 요청 가능성 있음 (여러 기기에서 동시 주문)
- 재시도보다 대기가 나음
그래서 비관적 락을 선택했다.
정리
- 동시성 문제는 단일 스레드 테스트로는 발견 안 됨
- 비관적 락은
@Lock(LockModeType.PESSIMISTIC_WRITE)한 줄 - 쿠폰처럼 실패 비용이 큰 경우 비관적 락이 적합
'Loopers 2기' 카테고리의 다른 글
| 재고 차감, 검증은 몇 번 해야 할까? (0) | 2025.11.14 |
|---|---|
| Round 2 회고 (0) | 2025.11.07 |
| Round 1 회고 (0) | 2025.10.31 |