다음예제로 synchronized를 이용한 쿠폰 발급에 대한 동시성 처리를 테스트해보겠습니다.
쿠폰 발급 기능
쿠폰 감소 기능에만 초점을 두었기때문에 유저에 따라 쿠폰 제공 횟수나 기간 등의 요구사항은 배제하여 개발하였습니다.
쿠폰 엔티티
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID couponId;
//...
/** 비용 */
private int cost;
/** 쿠폰 발급 매수 */
private int numberOfCoupons;
/**
* 쿠폰 발급 시 쿠폰 갯수 감소처리
*
*/
public void minusCouponCount() {
if (this.numberOfCoupons < 0) {
throw new IllegalArgumentException("쿠폰 다운이 만료되었습니다.");
}
this.numberOfCoupons--;
}
Synchronized를 이용한 쿠폰 발급 기능
메소드에 synchronized를 선언해 thread-safe하도록 해주었습니다.
/**
* 쿠폰 발급
*
* @param couponId 쿠폰 아이디
*/
public synchronized CouponResponse issuanceCoupon(String couponId) {
Coupon coupon = couponRepository.findById(UUID.fromString(couponId))
.orElseThrow(() -> new NoSuchFieldError("쿠폰을 찾을 수 없습니다."));
coupon.minusCouponCount();
couponRepository.save(coupon);
return CouponResponse.from(coupon);
}
쿠폰 발급 테스트
int threadCount = 100;
UUID couponId = couponList.get(0)
.getCouponId();
// when
ExecutorService executor = Executors.newFixedThreadPool(5);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// then
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
couponService.issuanceCoupon(couponId.toString());
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
assertEquals(9900, compareCoupon.getNumberOfCoupons());
결과
synchronized를 이용해 값이 정상적으로 줄어드는 것을 확인할 수 있었습니다.
겪었던 문제 상황
쿠폰 발급 메소드에 @Transactional을 붙였을 경우 값이 정상적으로 줄어들지 않는 현상을 발견 할 수 있었습니다.
@Transactional을 사용하면 메소드가 종료된 이후에 commit을 요청하는데 이 사이에 다른 스레드가 메소드를 실행시키다보니 다른 스레드는 결국 변경되기 이 전의 값을 보게 됩니다. 그래서 결국 @Transactional을 붙이면 정상적인 값을 얻을 수 없게 됩니다.
synchronized 문제
synchronized는 동시성 처리를 위한 한 방법이지만 결코 좋은 방법은 아니라고 생각합니다.
제가 생각한 이유는 2가지입니다.
1) 서버가 여러 대인 경우에 대한 대응을 할 수 없다.
2) 결국 @Transactional을 붙이지 않아야 정상적으로 동작된다.
Scale-out한 상황에서는 해당 문제를 해결할 수 없을 뿐더러 @Transactional을 빼면 데이터 일관성 유지 방면에서 큰 문제가 될 것입니다. 이러한 문제를 해결하고자 synchronized가 아닌 다른 방법으로 해결하는 방법에 대해서 다음 글을 작성해보겠습니다.