배경
결제 승인 처리 중 유저가 보유한 쿠폰을 사용 처리하는 흐름이 있다. 쿠폰 엔티티에는 @Version 기반 낙관적 락이 적용되어 있고, OptimisticLockingFailureException 발생 시 재시도 없이 예외를 던져 결제를 실패시킨다.
@Transactional
fun use(ownedCouponId: Long) {
try {
val ownedCoupon = ownedCouponRepository.findByIdOrNull(ownedCouponId)
?: throw CoreException(ErrorType.OWNED_COUPON_INVALID)
ownedCoupon.use()
} catch (e: OptimisticLockingFailureException) {
log.error("[OWNED_COUPON_USAGE] 비정상적인 쿠폰 사용 - 동시성 충돌 ownedCouponId={}",
ownedCouponId, e)
throw CoreException(ErrorType.OWNED_COUPON_INVALID_USAGE)
}
}
이 코드를 보고 "낙관적 락 실패에 대한 재시도가 없는데 괜찮은가?"라는 의문이 들 수 있다.
재시도하지 않는 이유
핵심은 누가 이 리소스에 접근하는가에 있다.
OwnedCoupon은 특정 유저가 발급받은 쿠폰이다. userId + couponId에 unique 제약이 걸려 있고, 해당
유저만이 이 레코드를 변경할 수 있다. 같은 유저가 같은 쿠폰으로 동시에 두 건의 결제를 시도하는
상황은 정상적인 클라이언트 플로우에서 발생하지 않는다.
따라서 낙관적 락 충돌이 발생했다면:
- 클라이언트를 조작했거나
- API를 직접 호출하는 비정상 접근이거나
- 중복 요청을 방어하지 못한 클라이언트 버그이다
이 중 어떤 경우든 재시도로 성공시켜주는 것이 올바른 대응이 아니다. 오히려 재시도 로직은 쿠폰 중 사용이라는 금전적 손실로 이어질 수 있는 어뷰징에 대한 방어를 약화시킨다.
결제 도메인에서 "의심스러우면 거부"는 합리적인 전략이다. 실패한 결제는 유저가 다시 시도하면 되지만, 쿠폰이 중복 사용되면 사후 보상 처리가 필요하다.
log.error로 기록하고 있으므로 이 상황이 빈번해지면 모니터링에서 감지할 수 있고, 그때 원인을 추적하면 된다.
전제가 바뀌면 설계도 바뀌어야 한다
만약 가족 계정이 같은 쿠폰을 공유하는 요구사항이 추가된다면 이야기가 완전히 달라진다. 여러 유저 동일 레코드를 정상적으로 동시 변경하게 되므로, 낙관적 락 충돌이 더 이상 어뷰징 시그널이 아니라 정상 플로우에서의 경합이 된다.
이 경우 고려할 수 있는 접근은 세 가지다.
Atomic UPDATE
read-modify-write 패턴을 제거하고, 단일 UPDATE 쿼리로 원자적 처리한다.
UPDATE owned_coupon
SET used_count = used_count + 1,
state = CASE WHEN used_count + 1 >= max_use_count THEN 'USED' ELSE state END
WHERE id = :id
AND used_count < max_use_count
영향받은 row가 0이면 잔여 수량이 없는 것이다. 애플리케이션 레벨 락이 불필요하고, @Version도 제거할 수 있다. 락 대기 자체가 없으므로 경합이 빈번할수록 유리하다. 다만 JPA dirty checking 대신 @Modifying 쿼리가 필요하다.
비관적 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findByIdForUpdate(id: Long): OwnedCouponEntity?
먼저 도착한 요청이 row lock을 잡고, 나머지는 대기 후 순서대로 처리된다. 기존 엔티티의 use(), revert() 로직을 그대로 활용할 수 있다는 점이 장점이다. 경합이 가끔 발생하는 수준이라면 가장 적은 변경으로 해결할 수 있다.
인당 할당 (Per-User Quota)
공유 쿠폰의 총 수량을 가족 구성원별로 사전 분배한다. 각 유저가 자기 레코드만 수정하므로 경합 자체가 구조적으로 발생하지 않는다. 다만 할당 정책(균등 분배, 선착순 풀 등)이 필요하고 모델 복잡도가 올라가므로, 가족 간 사용량 제어가 명확한 비즈니스 요구사항일 때 도입을 검토한다.
SharedCoupon (familyId=1, totalQuota=10)
└─ UserCouponQuota (userId=101, allocated=3, used=1)
└─ UserCouponQuota (userId=102, allocated=3, used=0)
└─ UserCouponQuota (userId=103, allocated=4, used=2)
비교
| 기준 | Atomic UPDATE | 비관적 락 | 인당 할당 |
| 구현 난이도 | 낮음 | 낮음 | 높음 |
| 기존 코드 변경 범위 | 중간 | 작음 | 큼 |
| 동시성 처리 | 경합 없음 | 대기 후 성공 | 경합 없음 |
| 공정성 제어 | 선착순 | 선착순 | 정책 기반 |
| 실패율 | 0% (잔여 있으면) | 0% (잔여 있으면) | 0% |
- 경합이 "가끔" 수준이라면 → 비관적 락. 기존 엔티티 로직을 살리면서 가장 적은 변경으로 해결됩니다
- 경합이 "빈번"하다면 → Atomic UPDATE. 락 대기 자체가 없으므로 성능이 가장 좋음
- 가족 간 사용량 제어가 비즈니스 요구사항이라면 → 인당 할당. 단, 도입 비용이 크므로 요구사항이 명확할 때만.
결론
낙관적 락 실패에 대한 처리 전략은 "그 충돌이 정상인가, 비정상인가"로 결정된다.
| 시나리오 | 충돌의 의미 | 전략 |
| 유저 소유 쿠폰 (1인 사용) | 비정상 접근 | 실패 + 로깅 |
| 공유 쿠폰 (가족 등 다수 사용) | 정상 겸합 | Atomic UPDATE 또는 비관적 락 |
동시성 제어 메커니즘을 선택하기 전에, 해당 리소스에 대한 동시 접근이 정당한 시나리오인지를 먼저 판단하는 것이 올바른 순서다.