이전 글에선 트랜잭션 격리수준을 Serializable 로 설정하여 문제를 해결했습니다.
하지만, 실무에서 Serializable 을 사용하게 되면, write lock을 다수 걸게되고 그로인해 성능이 다수 저하되는 현상이 발생합니다.
이번에는 트랜잭션 격리수준은 Repeatable 로 설정하여 진행하겠습니다.
이전 방식과 동일하게, 비관적 락을 통해 문제를 해결해 보겠습니다. 아래 코드는
서비스단, 엔티티단, 리포지토리 단의 코드입니다.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void getCoupon(Long id) {
Coupon coupon = couponRepository.findByIdForUpdate(id).orElseThrow(RuntimeException::new);
if(coupon.getQuantity() <= 0) {
throw new RuntimeException();
}
coupon.getCoupon();
}
----------------------------------------------------------------------------
public class Coupon {
@Id
private Long id;
private int quantity;
public void getCoupon() {
this.quantity--;
}
}
------------------------------------------------------------------------------
public interface RepCouponRepository extends JpaRepository<Coupon,Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Coupon c where c.id = :id")
Optional<Coupon> findByIdForUpdate(Long id);
}
이전 방식과 마찬가지로, ExecutorService 를 통해 멀티 쓰레딩 환경을 적용하여 테스트를 진행했습니다.
@SpringBootTest
class RepCouponControllerTest {
@Autowired
private RepCouponController couponController;
@Autowired
private RepCouponRepository couponRepository;
Coupon coupon;
@BeforeEach
void setUp() {
coupon = new Coupon(5L, 50000);
couponRepository.save(coupon);
}
@AfterEach
void tearDown() {
couponRepository.deleteAll();
}
@Test
void getCoupon() {
int threadCount = 3;
int customerCount = 1000;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(customerCount);
for(int i = 0; i < customerCount; i++) {
executorService.execute(() -> {
try {
couponController.getCoupon(coupon.getId());
} finally {
latch.countDown();
}
});
}
try {
latch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
Coupon c = couponRepository.findById(coupon.getId()).get();
Assertions.assertThat(c.getQuantity()).isEqualTo(coupon.getQuantity() - customerCount);
}
}
비관적 락을 이용한 방식은 성공했습니다.
낙관적 락을 이용하여 문제를 해결하겠습니다.
Coupon 엔티티에 version 필드를 추가해주고, Lock 어노테이션을 삭제해줍니다.
public class Coupon {
@Id
private Long id;
private int quantity;
@Version
private Long version;
public void getCoupon() {
this.quantity--;
}
public Coupon(Long id, int quantity) {
this.id = id;
this.quantity = quantity;
}
}
------------------------------------------------
@Query("select c from Coupon c where c.id = :id")
Optional<Coupon> findByIdForUpdate(Long id);
그리고 다음과 같이 테스트를 진행해줍니다.
sCount 는 쿠폰 발급 성공한 횟수를,
fCount 는 쿠폰 발급에 실패한 횟수를 나타냅니다.
@Test
void getCoupon() {
int threadCount = 3;
int customerCount = 1000;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(customerCount);
AtomicInteger sCount = new AtomicInteger();
AtomicInteger fCount = new AtomicInteger();
for(int i = 0; i < customerCount; i++) {
executorService.execute(() -> {
try {
couponController.getCoupon(coupon.getId());
sCount.getAndIncrement();
} catch (Exception e) {
fCount.getAndIncrement();
} finally {
latch.countDown();
}
});
}
try {
latch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
Coupon c = couponRepository.findById(coupon.getId()).get();
Assertions.assertThat(c.getQuantity()).isEqualTo(coupon.getQuantity() - sCount.get());
}
요청한 고객 1000명에게 모두 발급되지는 않았습니다.
다만, DB의 쿠폰 개수는 발급에 성공한 횟수 만큼만 차감되고 있습니다.
이렇게 낙관적 락을 사용해줄 경우에는 동시성 문제가 발생했을때 클라이언트 단에서 재요청을 보내는 처리가 추가적으로 요구됩니다.
따라서, 만약 해당 서비스가 동시성 문제가 정말 자주 일어날것 같은 서비스라면 비관적 락을 사용하고, 만약 그렇지 않다면 낙관적 락을 사용하는 것이 유리한 전략으로 보입니다.