동일한 Bean 내에서 @Transactional을 사용하는 경우 예상했던 것과 다르게 동작할 수 있습니다. 발생 원인과 해결 방법에 대해서 정리한 포스팅입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Service
class CouponService(
private val couponRepository: CouponRepository
) {

fun something(i: Int) {
println("something CurrentTransactionName: ${TransactionSynchronizationManager.getCurrentTransactionName()}")
this.save(i)
}

@Transactional
fun save(i: Int) {
println("save CurrentTransactionName: ${TransactionSynchronizationManager.getCurrentTransactionName()}")
(1..i).map {
if (it == 20) {
throw RuntimeException("$i ....")
}
couponRepository.save(Coupon(it.toBigDecimal()))
}
}
}

@RestController
@RequestMapping("/transaction")
class TransactionApi(
private val couponService: CouponService
) {

@GetMapping
fun transactional(@RequestParam i: Int) {
couponService.something(i)
}
}

위 코드는 Controller에서 something() -> save()을 차례대로 호출하는 코드입니다. save() 메서드에서는 특정 경우 RuntimeException을 발생시키고 있습니다. save() 메서드에 @Transactional 때문에 해당 반복문 전체에 트랜잭션이 묶이게 되고 예외가 발생하면 전체가 Rollback될 것이라고 예상됩니다.

1
curl --location --request GET 'http://localhost:8080/transaction?i=40'

위 와 같이 해당 컨트롤러를 호출하고 결과를 조회하면 아래와 같습니다.

결과는 전체를 롤백 되지 않고 19개가 commit 된 것을 확인할 수 있습니다. 그렇다는 것은 트랜잭션이 묶이지 않고 SimpleJpaRepository의 아래 save() 메서드를 통해서 단일 트랜잭션으로 진행된 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {

@Transactional
@Override
public <S extends T> S save(S entity) {

if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
}

즉, 위와 같은 경우에는 동일한 Bean(Class)에서 Spring AOP CGLIB이 동작하지 않습니다.

TransactionSynchronizationManager.getCurrentTransactionName() 메서드를 통해서 현재 트랜잭션을 확인해 보면 두 메서드 모두 null이라는 것은this.save() 메서드에 있는 @Transactional이 동작하지 않았다는 것입니다.

그렇다면 외부에서 Bean 호출시 @Transactional으로 시작하고 동일한 Bean(Class)에서 this.xxx()으로 호출시 @Transactional 동작을 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Service
class SimpleService(
private val couponRepository: CouponRepository,
private val paymentRepository: PaymentRepository,
private val orderRepository: OrderRepository
) {

@Transactional
fun saveOrder() {
println("saveOrder CurrentTransactionName: ${TransactionSynchronizationManager.getCurrentTransactionName()}")
orderRepository.save(Order(
amount = 10.toBigDecimal(),
orderer = Orderer(1L, "test@test.com")
))
this.savePayment()
this.saveCoupon()
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
fun savePayment() {
println("savePayment CurrentTransactionName: ${TransactionSynchronizationManager.getCurrentTransactionName()}")
paymentRepository.save(Payment(10.toBigDecimal()))
}

@Transactional
fun saveCoupon() {
println("saveCoupon CurrentTransactionName: ${TransactionSynchronizationManager.getCurrentTransactionName()}")
couponRepository.save(Coupon(10.toBigDecimal()))
throw RuntimeException()
}
}

코드의 흐름은 다음과 같습니다.

  1. order insert
  2. payment insert
  3. coupon insert 진행하다 RuntimeException() 발생

savePayment()메서드에서 @Transactional(propagation = Propagation.REQUIRES_NEW)설정을 했기 때문에 1, 3은 Rollback이 진행되고 2 payment는 성공적으로 commit이 진행될것이라고 판단될 수 있습니다. 하지만 결과는 모두 Rollback 진행됩니다.

TransactionSynchronizationManager.getCurrentTransactionName()을 통해서 현재 트랜잭션을 확인해보면 모두 동일하다는 것을 확인할 수 있습니다. 즉 전체 트랜잭션이 한 트랜잭션으로 묶이게 되어 RuntimeException 발생시 전체 Rollback이 진행된것입니다.

다시 정리하면 Bean 내부에서 this.xxxx()메서드 호출시에는 Proxy를 통해서 @Transactional설정이 동작하지 않는다는 것입니다.

원인#

Spring Document
In proxy mode (which is the default), only external method calls coming in through the proxy are intercepted. This means that self-invocation (in effect, a method within the target object calling another method of the target object) does not lead to an actual transaction at runtime even if the invoked method is marked with @Transactional. Also, the proxy must be fully initialized to provide the expected behavior, so you should not rely on this feature in your initialization code (that is, @PostConstruct).

스프링 문서에 따르면 Proxy Default Mode(스프링에서 사용하는 기본 Proxy를 의미하는 거 같음)는 외부 메서드 (외부 Bean, 즉 동일하지 않은 Bean)에서 호출하는 경우에만 프록시를 타고 Self(this.xxx())를 호출하는 경우 런타임에 @Transactional가 동작하지 않습니다.

즉, 위 그림처럼 CGBLIB Proxy를 통해서 save() 메서드가 Proxy 기반으로 @Transactional이 추가가 될 것을 기대했지만 호출하는 곳이 외부 Bean이 아닌 경우에는 Proxy가 인터셉트가 되지 않기 때문에 @Transactional이 동작하지 않게 되는 것입니다.

order, payment, coupon 코드에서 확인 했듯이 외부에서 Bean을 호출 하여 Proxy가 인터럽트 했더라도 동일한 Bean에서 this.xxxx()(Self 호출)에서는 Proxy가 동작하지 않게 됩니다.

해결 방법#

Self Injection, Spring AOP 대신 AspectJ 사용 등 몇 가지 방법을 검색을 통해서 확인했지만 개인적 견해로는 이 방법은 권장하고 싶지는 않습니다. AOP 라이브러리를 변경하는 것은 리스크가 너무 커 보였고, Self Injection 또한 직관적이지 않으며 @Autowired를 사용하는 것이 마음에 들지 않았습니다.

가장 간단한 해결 방법은 Service 클래스를 나누고 외부 Bean 호출을 통해서 Proxy가 올바르게 동작하게 하는 것입니다. 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
class CouponService(
private val saveService: SaveService
) {
fun something(i: Int) {
println("something CurrentTransactionName: ${TransactionSynchronizationManager.getCurrentTransactionName()}")
saveService.save(i)
}
}

@Service
class SaveService(
private val couponRepository: CouponRepository
) {
@Transactional
fun save(i: Int) {
println("save CurrentTransactionName: ${TransactionSynchronizationManager.getCurrentTransactionName()}")
(1..i).map {
if (it == 20) {
throw RuntimeException("$i ....")
}
couponRepository.save(Coupon(it.toBigDecimal()))
}
}
}

해당 코드를 다시 호출하면 아래와 같은 결과를 확인할 수 있습니다.

save() 메서드에서 트랜잭션이 생겼으며 해당 아래의 작업은 동일한 트랜잭션을 묶이게 됩니다. 즉 Proxy 기반으로 @Transactional이 동작했으며 예외가 발생하면 모두 Rollback을 진행하게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Service
class SimpleService(
private val orderRepository: OrderRepository,
private val paymentSaveService: PaymentSaveService,
private val couponSaveService: CouponSaveService
) {

@Transactional
fun saveOrder() {
println("saveOrder CurrentTransactionName: ${TransactionSynchronizationManager.getCurrentTransactionName()}")
orderRepository.save(Order(
amount = 10.toBigDecimal(),
orderer = Orderer(1L, "test@test.com")
))

paymentSaveService.savePayment()
couponSaveService.saveCoupon()
}
}

@Service
class PaymentSaveService(
private val paymentRepository: PaymentRepository
){
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun savePayment() {
println("savePayment CurrentTransactionName: ${TransactionSynchronizationManager.getCurrentTransactionName()}")
paymentRepository.save(Payment(10.toBigDecimal()))
}
}

@Service
class CouponSaveService(
private val couponRepository: CouponRepository
){
@Transactional
fun saveCoupon() {
println("saveCoupon CurrentTransactionName: ${TransactionSynchronizationManager.getCurrentTransactionName()}")
couponRepository.save(Coupon(10.toBigDecimal()))
throw RuntimeException()
}
}

savePayment() 메서드에서 현재 트랜잭션이 savePayment, saveOrder, saveCoupon은 트랜잭션이 saveOrder인것을 확인할 수 있습니다. 결과는 @Transactional(propagation = Propagation.REQUIRES_NEW)이 정상적으로 동작해서 savePayment()만 트랜잭션이 Commit하게 됩니다.