Loopers 2기

쿠폰 중복 사용 버그, 비관적 락 한 줄로 해결하기

고구마와 감자 2025. 11. 21. 17:49

쿠폰 중복 사용 버그, 비관적 락 한 줄로 해결하기

문제 상황

주문 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) 한 줄
  • 쿠폰처럼 실패 비용이 큰 경우 비관적 락이 적합