동일한 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은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.