240704 실전 프로젝트 - Transactional의 경계

노재원·2024년 7월 4일
0

내일배움캠프

목록 보기
75/90

트러블 슈팅 - @SpringBootTest의 Transactional (공부는 됐지만 이유 틀렸음)

@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 내부에서 쿠폰은 계속 업데이트 쿼리가 발생하지 않았다고 예상해볼 수 있겠다.

최종해결 트러블슈팅 - Spring Container와 AOP

@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 처리 되기 때문이다.)

0개의 댓글