쿠폰 한장에 담긴 복잡한 고민들
TL;DR
평소에 개념만 어느정도 이해한 락에 대해 비관적 락과 낙관적 락의 선택 기준, 락을 잡는 시점, 트랜잭션 경계 설계를 쿠폰 도메인 구현을 통해 직접 겪으며 정리했다.
들어가며
얼마 전 멘토님께 AI 활용 방식에 대해 고민 상담을 드렸다. 클로드 코드를 사용하며 개발을 하다보니 AI에 너무 의존하게 되고, 이런 과정에서 나만의 색깔이나 강점이 크게 작용하지 않을 수 있을 것 같을 것 같다는 고민이었다.
즉, 프롬프트만 넣을줄 안다면 결국 누가해도 비슷한 결과를 뽑을 수 있지 않을까 라는 고민이었다.
해당 질문에 대한 멘토님 피드백은 이러했다.

작업에 대한 명확한 방향성과 생각 정리가 중요하며 고민할 수 있는 부분을 최대로 끌어올려서 AI의 답변을 철저하게 검증 하는것, AI가 답을 주면 그걸 받아 적는 게 아니라 내가 먼저 방향을 잡고 AI는 그 과정을 보조하는 도구여야 한다는 것이었다.
이 답변을 본 후 CLAUDE.md를 이렇게 수정했다.

어떤 작업을 요청하면 알아서 끝낸 뒤 어떤식으로 했다라고 보고만 하는게 아닌 내가 할 수 있는 고민과 방향성을 잡을 수 있게 추가하였다. 그 결과, 이전과 비교하여 내가 결정하고 고민할 수 있는 부분들이 크게 늘어났다. 질문을 하여 고민거리들을 많이 던져줬고, 그에 맞는 트레이드오프 등을 제시했다.

또한, 내가 잘못 생각한 부분에 대해서도 적당한 예시를 들어 오답으로 가는 것을 최소화 해줬다.

처음엔 일일히 물어보고 하는 것들이 조금 귀찮았지만 근데 그 질문에 답하다 보면 내가 사실 아무 생각 없이 던진 요청이었다는 걸 깨달을 때가 있다. 질문에 답을 하며 고민하는 과정 자체가 굉장히 중요하다 느꼈다.
다만 지금은 학습의 목적으로 이렇게 설정해놨지만, AI를 쓰다 보면 어느새 내가 AI의 조수가 되어 있는 경우가 생긴다. 내가 고민해야 할 설계를 AI가 제안하고, 나는 그걸 승인하는 역할만 하게 되는 것. 도구를 쓰는 건지, 도구에 끌려가는 건지 스스로 계속 점검해야 할 것 같다.
비관적 락과 낙관적 락
이번 주에는 쿠폰 도메인을 설계하는 과정에서 동시성 제어를 다뤘다. 동시성 처리에 대해서는 이전에 Redisson을 통해 조금 경험해 보긴했지만, DB에 직접적으로 락을 거는 방식은 처음이었다.
재고 차감, 쿠폰 사용, 좋아요. 이 세 가지에 어떤 락을 걸지 결정해야 했고 처음에 단순히 내 생각은 이랬다. "데이터 정합성이 중요할수록 낙관적 락을 써야 하지 않나? 더 신중하게 처리하는 느낌이니까"
그러나 완전히 반대였다.
낙관적 락은 충돌이 났을 때 롤백하고 재시도하는 방식이다. 즉 충돌을 허용하고 나중에 수습한다. 충돌이 드물고, 실패해도 재시도가 가능한 케이스에 적합하다.
비관적 락은 처음부터 다른 트랜잭션이 접근하지 못하게 막는다. 충돌 자체를 예방한다. 재고처럼 한번 틀리면 실제 손해가 생기는 케이스에 적합하다. 치명적일수록 비관적 락이라는 기준이 생기니까 선택이 전보다 간단해졌다.
| 대상 | 선택 | 이유 |
|---|---|---|
| 재고 차감 | 비관적 락 | 재고 부족으로 팔리면 실제 손해 |
| 쿠폰 사용 | 비관적 락 | 중복 사용 시 할인이 두 번 적용됨 |
| 좋아요 | 낙관적 락 | 틀려도 재시도 가능, 손해 없음 |
락을 언제 어디서 잡아야 하나
비관적 락을 쓰기로 했으면 다음 질문은 언제 잡느냐다.
처음엔 차감하는 시점에 락을 걸면 되지 않나 싶었다. 근데 이렇게 되면 조회(재고 확인)와 차감 사이에 구멍이 생긴다.
스레드 A: 재고 10개 확인 → (여기서 스레드 B가 10개 모두 차감) → 차감 시도 → 실패
즉, 조회 시점부터 락을 잡아야 한다. 확인과 차감이 하나의 원자적 흐름이 되어야 한다.
그래서 findByIdForUpdate()를 별도 메서드로 만들고 decreaseStock()에서 이걸 사용하도록 바꿨다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findByIdForUpdate(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
product.decreaseStock(quantity); // 재고 음수 방지는 도메인에서
}
트랜잭션 경계를 잘못 잡으면 락이 의미 없어진다. 이번에 실수한 게 하나 있는데 처음 구조는 이랬다.
// CouponService
@Transactional
public UserCoupon getValidatedUserCoupon(...) {
UserCoupon userCoupon = repo.findByIdForUpdate(id); // 락 획득
userCoupon.validate(...);
return userCoupon; // 트랜잭션 종료 → 락 해제
}
// OrderFacade
UserCoupon userCoupon = couponService.getValidatedUserCoupon(...);
userCoupon.use(); // 락이 이미 풀린 상태, 저장도 안 됨
비관적 락은 트랜잭션이 커밋될 때 해제된다. getValidatedUserCoupon()이 리턴되는 순간 락이 풀리고, 그 다음에 use()를 호출해봤자 다른 트랜잭션이 이미 끼어들 수 있다. 심지어 use()는 트랜잭션 밖에서 호출되니 DB에 저장도 안 된다.
락 획득 → 검증 → 상태 변경이 하나의 트랜잭션에서 이루어져야 한다.
@Transactional
public int validateAndUse(Long userCouponId, Long userId, int originalAmount) {
UserCoupon userCoupon = repo.findByIdForUpdate(userCouponId); // 락 획득
userCoupon.validate(userId);
coupon.validateMinOrderAmount(originalAmount);
userCoupon.use(); // 같은 트랜잭션 → 커밋 시 DB 반영
return coupon.calculateDiscount(originalAmount);
}
이렇게 하면 두 번째 요청은 첫 번째 트랜잭션이 커밋될 때까지 대기하고, 커밋 후 USED 상태를 보고 예외를 던진다. 쿠폰 중복 사용이 원천 차단된다.
쿠폰 템플릿과 발급된 쿠폰은 다른 개념이다. 쿠폰 도메인을 설계할 때도 고민이 있었다. 만료일을 어디에 두느냐. Coupon(템플릿)에 expiredAt을 고정하면 간단하다. 근데 이렇게 하면 문제가 생긴다.
Coupon.expiredAt = "2026-12-31"로 고정해두면, 12월 30일에 발급받은 사람은 하루밖에 못 쓴다. 발급 시점이 사람마다 다른데 만료일을 템플릿에 박아버리면 처리할 방법이 없다.
그래서 Coupon에는 validDays를 두고, UserCoupon.issue()에서 발급 시점 기준으로 만료일을 계산하도록 했다.
public static UserCoupon issue(Coupon coupon, Long userId) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime expiredAt = now.plusDays(coupon.getValidDays());
return new UserCoupon(coupon.getId(), userId, CouponStatus.AVAILABLE, now, expiredAt);
}
Coupon은 여러 명이 공유하는 정책 정보이고 UserCoupon은 개인에게 발급된 정보다. 만료일은 발급 순간에 개인별로 확정되는 것이니 UserCoupon이 소유하는 게 책임 분리 측면에서도 맞다.
트랜잭션 관점
쿠폰 도메인 설계 과정에서 쿠폰 검증 → 재고 차감 → 주문 생성이 하나의 트랜잭션으로 묶이는데 트랜잭션 범위가 너무 크다고 느껴졌다.(나중에 알았지만 멘토님은 큰편은 아니라고 하셨다...)
트랜잭션 안에 포함해야 하는 것은 하나라도 실패하면 주문 자체가 성립할 수 없는 크리티컬한 것들이다.
- 재고 차감
- 쿠폰 사용
- 주문 생성
- 결제 확정
얘네들은 함께 성공하거나 함께 실패해야 한다. 재고는 줄었는데 주문이 없거나 쿠폰은 사용됐는데 결제가 안 된 상태가 되면 안 된다.
또, 트랜잭션 밖으로 빼도 되는(상황에 따라 빼야 하는) 것은 실패해도 주문 자체는 유효하며 '크게' 문제가 되지 않는 것들이다.
- 마일리지 적립
- 주문 완료 알림 발송
- 이메일 발송
- 로그 적재
이런 것들은 실패해도 주문 자체가 취소될 이유가 없다. 오히려 트랜잭션 안에 묶으면 알림 API 호출 실패 하나가 주문 전체를 롤백시키는 상황이 생긴다. 데이터는 이미 저장됐으니 나중에 재처리하면 된다.
마치며
이번에 가장 크게 느낀 건 개념을 아는 것과 실제로 쓸 수 있다는 건 다르다는 거다. 낙관적 락, 비관적 락 둘 다 개념적으로는 알고 있었지만 어디에 뭘 쓸지 막상 결정하려니 명확한 기준이 없었다. 또한 쿠폰 도메인 자체가 깊게 들어가면 생각할거리가 굉장히 많았다. 쿠폰의 종류, 기간, 중복가능 유무 등 꽤나 복잡해진다. 또한 언제 변경이 생길지 모르므로 예상치 내에서 객체를 잘 분리하는 것 까지는 철저하게 하는게 중요한 것 같다.