@Transactional
override fun issueCouponToUser(couponId: Long, userId: Long): CouponResponse {
check(
!couponRepository.isCouponIssued(
couponId,
userId
)
) { throw IllegalStateException("User already issue coupon") }
val coupon = couponRepository.findCouponById(couponId) ?: throw ModelNotFoundException("coupon", couponId)
check(coupon.hasQuantity()) { throw IllegalStateException("Coupon has no quantity") }
val user = userRepository.findByIdOrNull(userId) ?: throw ModelNotFoundException("user", userId)
return couponRepository.issueCouponToUser(coupon, user)
.also { coupon.decreaseQuantity() }
.let { CouponResponse.toResponse(it) }
}
이번 테스트의 대상인 쿠폰 발급 메소드다.
나는 해당 쿠폰 발급을 적당히 유저와 재고를 설정해서 한번에 몰려온다는 느낌으로 작성했고 싱글 스레드라서 현재는 동시성 이슈는 발생하지 않는다.
@Test
@Transactional
fun `1000명의 유저가 500개 재고의 쿠폰에 동시에 발급 시도를 했을 때 발급된 쿠폰은 500개인지 확인`() {
// given
val testUserSize = 1000
val testQuantity = 500
val nameSet = hashSetOf<String>()
while (nameSet.size < testUserSize) {
nameSet.add(Arbitraries.strings().ofMinLength(5).ofMaxLength(10).sample())
}
val users = mutableListOf<User>()
nameSet.forEach {
users.add(userRepository.saveAndFlush(User(username = it, password = "test")))
}
val coupon = couponRepository.save(
Coupon(
name = "test",
expirationAt = LocalDateTime.of(2030, 1, 1, 0, 0),
totalQuantity = testQuantity,
currentQuantity = testQuantity,
discountPrice = 10000
)
)
// when
users.forEach {
try {
couponService.issueCouponToUser(coupon.id!!, it.id!!)
} catch (e: Exception) {
println(e.message)
}
}
// then
}
문제의 테스트 코드다 (@Transactional 이 추가되면서 해결됨)
그런데 단순히 API 콜로 테스트 할 때는 문제가 없던 재고의 수량 감소가 테스트 코드에선 이루어지지 않는 걸 확인하게 됐다. 서비스 메소드엔 정상적으로 @Transactional이 걸려 있는데 뭐가 문제일까?
우선 테스트 코드는 DB 테스트 이후 롤백을 위해 @Transactional 이 기본적으로 걸리는 것으로 알고 있었고 전파 수준도 단순히 REQUIRED를 사용한다고 공부했다.
그런데 조사해보니 내가 알던 내용은 @DataJpaTest
에 해당하는 내용으로 @SpringBootTest 는 Transactional을 갖고 있지 않고 각 메소드별로 알아서 Transactional의 경계를 설정하길 기대하고 있다.
그래서 명시적인 Transactional을 설정해주고 나서 재고 감소에 대한 Dirty checking이 정상적으로 이루어지는 것을 알 수 있었다.
그래서 기본적인 동작 안함에 대한 의문은 풀렸지만 이미 Transactional이 걸려있는 쿠폰 서비스 메소드에서 왜 Dirty checking이 동작하지 않는가는 아직 아리송하다.
Transactional의 경계가 문제가 되는 것 같은데 설정한 대로면 Service 로직이 끝날 때마다 Flush 처리되어야 하는 게 아닌가?
이는 호출자가 영속성 컨텍스트의 관리를 받고 있지 않거나 영속성 컨텍스트에 들어간 내용이 커밋되는 시점이 없는 것 둘 중 하나로 결론이 났다.
불확실한 지식이지만 영속성 컨텍스트의 정보가 없던 테스트 코드에서 호출하고 종료될 때까지 호출된 쿠폰 발급의 영속성 컨텍스트는 Commit 될 수 있는 시점이 존재하지 않았고 결국 forEach 내부에서 쿠폰은 계속 업데이트 쿼리가 발생하지 않았다고 예상해볼 수 있겠다.
@Transactional은 Spring AOP 방식으로 동작하는데 Spring Container가 관리하는 Bean에서만 동작한다. 정확히는 Bean의 프록시만 생성이 가능한 구조로 이루어져 있다.
그렇기에 테스트 코드에서 직접 생성자 주입 방식으로 생성한 ServiceImpl() 은 Bean으로 등록되지 않아 Spring container의 관리를 받지 않고 있고 Spring AOP 방식으로 작동하는 @Transactional 은 제대로 동작하지 않았으니 영속성 컨텍스트가 제대로 열리지 않아 1차 캐시의 도움을 받지 못해 Dirty checking이 일어나지 않은 것이다.
그래서 @SpringBootTest에서 모든 Transactional을 재차 제거하고 Service를 Bean으로 만들어주니 해결됐다. (모든 Transactional을 제거한 이유는 비동기 테스트중에 Rollback이 일어나 비동기 메소드에서 불러오는 Entity가 Rollback 처리 되기 때문이다.)