개요

사내에서 쿠폰 등록 이벤트를 만들어야하는 상황이 있었다.

쿠폰은 고유의 쿠폰번호가 있으며, 해당 쿠폰은 유저당 하나만 등록할 수 있었다.

(대표적으로 스타크래프트 cd키라고 생각하자).

 

고민했던 점

- 확장성: 쿠폰은 한 번만 등록할 수 있지만, 언제 선착순 최대 x명으로 변경될지 모른다

- 동시성: 동시에 x명의 유저들이 쿠폰을 등록한 경우

 

결론

- 확장성: 쿠폰의 최대 사용 개수랑, 현재 남은 쿠폰 물량으로 나눴다.

- 동시성: 현재 선착순 한 명에서 Redis를 사용하면 오버 엔지니어링이라고 판단됐다. 우선 optimistic lock을 이용하고,  기획이 확장되면 필드를 제거하고 Redis를 이용한 추가 설계를 고려할 수 있다.

 

간단한 설계 예시

실제로는 유저가 쿠폰을 등록하는 것이지만 동시성 제어 테스트가 목적이나 유저는 제외했습니다.

@Entity
@Getter
public class Coupon {

    protected Coupon() {
    }

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "coupon_id")
    private Long id;

    @Column(nullable = false)
    private String couponNo;

    @Embedded
    private Stock stock;

    @OneToMany(cascade = CascadeType.PERSIST, mappedBy = "coupon")
    private List<CouponHistory> couponHistories = new ArrayList<>();

    @Version
    private Long version;

    public void decreaseRemainAmount() {
        final int afterRemainAmount = stock.getRemainAmount() - 1;
        final int currentLimitAmount = stock.getLimitAmount();

        verifyStockAmount(afterRemainAmount);

        stock = new Stock(afterRemainAmount, currentLimitAmount);
    }

    private void verifyStockAmount(final int afterRemainAmount) {
        if (afterRemainAmount < 0) {
            throw new InvalidStockAmountException();
        }
    }
    
    // 생략
}

 

@Embeddable
@Getter
@EqualsAndHashCode
public class Stock {

    protected Stock() {
    }

    private Integer remainAmount;

    private Integer limitAmount;

    public Stock(final Integer remainAmount, final Integer limitAmount) {
        this.remainAmount = remainAmount;
        this.limitAmount = limitAmount;
    }
}

 

@Entity
public class CouponHistory {

    protected CouponHistory() {
    }

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "coupon_history_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "coupon_id")
    private Coupon coupon;

    public CouponHistory(final Coupon coupon) {
        this.coupon = coupon;
    }
}

 

@Service
@Transactional
@RequiredArgsConstructor
public class CouponService {

    private final CouponRepository couponRepository;

    public void register(final String couponNo) {
        final Coupon coupon = couponRepository.findByCouponNo(couponNo)
            .orElseThrow(NotExistsCouponNoException::new);

        coupon.decreaseRemainAmount(); // 수량 감소
        
        coupon.addHistory(new CouponHistory(coupon));
        // .. doSomthing
    }
}

 

JPA에서 @Version어노테이션 하나로 낙관적락을 편리하게 이용할 수 있습니다.

동작 과정은 엔티티의 값을 수정한 뒤 @Version 필드를 증가시킵니다. 

만일 값이 변경되었는데, 변경 전 Version값과 동일하지 않을 경우 ObjectOptimisticLockingFailureException 예외를 발생시킵니다.

 

 

테스트

정확하고 간편한 실험을 위해 ngrinder를 이용했습니다.

동시에 30명의 사용자가 동일한 쿠폰 번호를 등록한다고 가정해보겠습니다.

최대 1개만 사용가능하고, 현재 1개의 물량이 남은 쿠폰입니다.

 

테스트 결과

성공적으로 한 개의 물량만 등록된 것을 확인할 수 있습니다.