동일한 Bean(Class)에서 @Transactional 동작 방식

동일한 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
@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
@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
@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
@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
@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하게 됩니다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.