트랜잭션 전파 레벨에 따른 롤백 전파 범위
카테고리: Spring
들어가며
스프링에서는 @Transaction
어노테이션을 트랜잭션 범위를 설정하고, 예외가 발생하면 롤백이 발생합니다.
또한 @Transaction
어노테이션을 중첩 사용하여, 부모 트랜잭션과 자식 트랜잭션으로 트랜잭션 범위를 설정할 수 있습니다.
이때, 트랜잭션 전파 정책에 따라 자식 트랜잭션에서 예외
가 발생했을 때, 부모 트랜잭션 의 롤백 여부를 결정할 수 있습니다.
본 포스팅에서는 여러 케이스를 만들어, 테스트후 언제 부모 트랜잭션이 롤백되는지 확인하려합니다.
전체 코드는 github에서 확인할 수 있습니다.
상황 가정
3개의 Coupon
엔티티를 저장시도할 때, 1개의 저장이 실패하는 상황을 가정했습니다.
첫 번째 쿠폰 저장
|
V
두 번째 쿠폰 저장 <-- 에외 발생
|
V
세 번째 쿠폰 저장
UncheckedException (RuntimeExcetpion)
- (CASE 1) : 자식 트랜잭션이 REQUIRES 전파레벨일 때, 자식 try-catch
- (CASE 2) : 자식 트랜잭션이 REQUIRES 전파레벨일 때, 부모 try-catch
- (CASE 3) : 자식 트랜잭션이 REQUIRES_NEW 전파레벨일 때, 자식 try-catch
- (CASE 4) : 자식 트랜잭션이 REQUIRES_NEW 전파레벨일 때, 부모 try-catch
CheckedException
- (CASE 5) : 자식 트랜잭션이 REQUIRES 전파레벨일 때, 부모 try-catch
스포
CASE | 부모 트랜잭션 롤백 여부 |
---|---|
1 | X |
2 | O |
3 | X |
4 | X |
5 | X |
(CASE 1) 자식 트랜잭션이 REQUIRES 전파레벨일 때, 자식 try-catch
@Service
@RequiredArgsConstructor
public class CouponsServiceCase1 {
public final SaveCouponServiceCase1 saveCouponServiceCase1;
@Transactional
public void case1(List<Coupon> coupons) { // 3개의 쿠폰이 파라미터로 들어옴
saveCouponServiceCase1.success(coupons.get(0));
saveCouponServiceCase1.fail(coupons.get(1));
saveCouponServiceCase1.success(coupons.get(2));
}
}
===
@Service
@RequiredArgsConstructor
public class SaveCouponServiceCase1 {
private final CouponRepository couponRepository;
private static final Logger log = LoggerFactory.getLogger(CouponsServiceCase2.class);
@Transactional
public void success(Coupon coupon) {
couponRepository.save(coupon);
}
@Transactional
public void fail(Coupon coupon) {
try {
throw new RuntimeException("RuntimeException inside");
} catch (Exception e) {
log.warn("SaveCouponServiceCase1 caught exception at inner. ex {}", e.getMessage());
}
}
}
자식 트랜잭션에서 예외를 잡는 케이스입니다. 이때 저장되는 Coupon
은 2개입니다. 예외를 해당 메서드에서 잡았기 때문에 롤백 전파가 되지 않습니다.
자식 트랜잭션에서 예외가 발생했기 때문에, 롤백 전파가 되어야 한다 라는 오해를 하시면 안되는게, 예외를 자식 메서드에서 케치 하기 때문에, 트랜잭션 매니저는 예외가 발생했다는 사실 조차 모릅니다. 왜냐하면 트랜잭션 매니저는 AOP 기반으로 동작하기 때문입니다.
(CASE 2) 자식 트랜잭션이 REQUIRES 전파레벨일 때, 부모 try-catch
@Service
@RequiredArgsConstructor
public class CouponsServiceCase2 {
public final SaveCouponServiceCase2 saveCouponServiceCase2;
@Transactional
public void case2(List<Coupon> coupons) { // 3개의 쿠폰이 파라미터로 들어옴
try {
saveCouponServiceCase2.success(coupons.get(0));
saveCouponServiceCase2.fail(coupons.get(1));
saveCouponServiceCase2.success(coupons.get(2));
} catch (Exception e) {
log.warn("CouponsServiceCase2 caught exception at outer. ex {}", e.getMessage());
}
}
}
===
@Service
@RequiredArgsConstructor
public class SaveCouponServiceCase2 {
private final CouponRepository couponRepository;
@Transactional
public void success(Coupon coupon) {
couponRepository.save(coupon);
}
@Transactional
public void fail(Coupon coupon) {
throw new RuntimeException("RuntimeException inside");
}
}
CASE 2의 결과로 몇 개의 Coupon이 저장되었을까요? 정답은 0개입니다.
Coupon
이 하나도 저장되지 않은 이유는 자식 트랜잭션이 실패함에 따라 아래의 메시지와 함께 부모 트랜잭션이 롤백되었기 때문입니다.
...UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
아래의 로그를 보면 왜 부모 트랜잭션이 실패했는지 알 수 있습니다.
...case2.CouponServiceCase2Test : Started CouponServiceCase2Test in 8.536 seconds (process running for 10.8)
...jpa.JpaTransactionManager <1>: Creating new transaction with name [com.ayuconpon.coupon.service.case2.CouponsServiceCase2.case2]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
...jpa.JpaTransactionManager | : Opened new EntityManager [SessionImpl(134136660<open>)] for JPA transaction
...internal.TransactionImpl | : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
...internal.TransactionImpl | : begin
...jpa.JpaTransactionManager | : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@1828eff]
...TransactionInterceptor <2>: Getting transaction for [com.ayuconpon.coupon.service.case2.CouponsServiceCase2.case2]
...jpa.JpaTransactionManager | : Found thread-bound EntityManager [SessionImpl(134136660<open>)] for JPA transaction
...jpa.JpaTransactionManager | : Participating in existing transaction
...TransactionInterceptor <3>: Getting transaction for [com.ayuconpon.coupon.service.case2.SaveCouponServiceCase2.success]
...jpa.JpaTransactionManager | : Found thread-bound EntityManager [SessionImpl(134136660<open>)] for JPA transaction
...jpa.JpaTransactionManager | : Participating in existing transaction
...TransactionInterceptor <4>: Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
...TransactionInterceptor | : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
...TransactionInterceptor : Completing transaction for [com.ayuconpon.coupon.service.case2.SaveCouponServiceCase2.success]
...jpa.JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(134136660<open>)] for JPA transaction
...jpa.JpaTransactionManager : Participating in existing transaction
...TransactionInterceptor <5>: Getting transaction for [com.ayuconpon.coupon.service.case2.SaveCouponServiceCase2.fail]
...TransactionInterceptor | : Completing transaction for [com.ayuconpon.coupon.service.case2.SaveCouponServiceCase2.fail] after exception: java.lang.RuntimeException: RuntimeException inside
...jpa.JpaTransactionManager <6>: Participating transaction failed - marking existing transaction as rollback-only
...jpa.JpaTransactionManager | : Setting JPA transaction on EntityManager [SessionImpl(134136660<open>)] rollback-only
...LocalTransactionCoordinatorImpl <7>: JDBC transaction marked for rollback-only (exception provided for stack trace)
...case2.CouponsServiceCase2 <8>: CouponsServiceCase2 caught exception at outer. ex RuntimeException inside
...TransactionInterceptor <9>: Completing transaction for [com.ayuconpon.coupon.service.case2.CouponsServiceCase2.case2]
...jpa.JpaTransactionManager | : Initiating transaction commit
...jpa.JpaTransactionManager | : Committing JPA transaction on EntityManager [SessionImpl(134136660<open>)]
...internal.TransactionImpl | : committing
...LocalTransactionCoordinatorImpl <10>: On commit, transaction was marked for roll-back only, rolling back
...JpaTransactionManager : Closing JPA EntityManager [SessionImpl(134136660<open>)] after transaction
- <1> :
CouponsServiceCase2.case2
메서드가 호출되었습니다. 이때 트랜잭션이 존재하지 않으니 새로운 트랜잭션을 만듭니다. - <2> :
CouponsServiceCase2.case2
메서드를 위한 트랜잭션을 얻습니다. 트랜잭션 전파 정책은 기본값이니, 새로운 트랜잭션이 아닌 이미 존재하는 트랜잭션에 참여합니다. - <3> :
SaveCouponServiceCase2.success
메서드를 위한 트랜잭션을 얻습니다. 마찬가지로 호출한 메서드의 트랜잭션에 참여합니다. - <4> :
SimpleJpaRepository.save
메서드를 위한 트랜잭션을 얻습니다. 마찬가지로 호출한 메서드의 트랜잭션에 참여합니다. - <5> :
SaveCouponServiceCase2.fail
메서드를 위한 트랜잭션을 얻습니다. 마찬가지로 호출한 메서드의 트랜잭션에 참여합니다. - <5> : 하지만, 메서드안에서 예외가 발생하고 트랜잭션을 끝냅니다.
- <6>-<7> : <5>에서 트랜잭션이 실패했기 때문에, 트랜잭션은
rollback-only
마크 처리가 됩니다. - <8> : 메서드 내부에서 발생한 예외를 최초의 트랜잭션을 시작한 메서드가 케치합니다.
- <9> :
CouponsServiceCase2.case2
에서 예외를 케치했고, 이후 트랜잭션을 커밋을 시도합니다. - <10>: 하지만 이미 이미
rollback-only
마킹된 트랜잭션이기 때문에 롤백이 됩니다.
위의 로그를 통해, 트랜잭션 기본 정책은 참여 중인 트랜잭션이 실패하면 전역으로 롤백 임을 알 수 있습니다.
(CASE 3) 자식 트랜잭션이 REQUIRES_NEW 전파레벨일 때, 자식 try-catch
@Service
@RequiredArgsConstructor
public class CouponsServiceCase3 {
public final SaveCouponServiceCase3 saveCouponServiceCase3;
@Transactional
public void case3(List<Coupon> coupons) { // 3개의 쿠폰이 파라미터로 들어옴
saveCouponServiceCase3.success(coupons.get(0));
saveCouponServiceCase3.fail(coupons.get(1));
saveCouponServiceCase3.success(coupons.get(2));
}
}
===
@Service
@RequiredArgsConstructor
public class SaveCouponServiceCase3 {
private final CouponRepository couponRepository;
private static final Logger log = LoggerFactory.getLogger(SaveCouponServiceCase3.class);
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void success(Coupon coupon) {
couponRepository.save(coupon);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void fail(Coupon coupon) {
try {
throw new RuntimeException("RuntimeException inside");
} catch (Exception e) {
log.warn("SaveCouponServiceCase3 caught exception at inner. ex {}", e.getMessage());
}
}
}
(CASE 1)과 마찬가지로, 예외를 자식 트랜잭션에서 잡았기 때문에, 저장되는 Coupon
은 2개 입니다.
(CASE 4)자식 트랜잭션이 REQUIRES_NEW 전파레벨일 때, 부모 try-catch
트랜잭션 전파 정책을 REQUIRES_NEW
로 설정한 상태입니다. 아래의 코드는 몇개의 Coupon
이 저장될까요? CASE 2
과 마찬가지로 0개의 Coupon
이 저장될까요?
@Service
@RequiredArgsConstructor
public class CouponsServiceCase4 {
public final SaveCouponServiceCase4 saveCouponServiceCase4;
private static final Logger log = LoggerFactory.getLogger(SaveCouponServiceCase4.class);
@Transactional
public void case4(List<Coupon> coupons) { // 3개의 쿠폰이 파라미터로 들어옴
try {
saveCouponServiceCase4.success(coupons.get(0));
saveCouponServiceCase4.fail(coupons.get(1));
saveCouponServiceCase4.success(coupons.get(2));
} catch (Exception e) {
log.warn("CouponsServiceCase4 caught exception at outer. ex {}", e.getMessage());
}
}
}
===
@Service
@RequiredArgsConstructor
public class SaveCouponServiceCase4 {
private final CouponRepository couponRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void success(Coupon coupon) {
couponRepository.save(coupon);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void fail(Coupon coupon) {
throw new RuntimeException("RuntimeException inside");
}
}
정답은 1개의 Coupon
이 저장됩니다.
그 이유는 각각의 트랜잭션이 독립적인 트랜잭션
이기 때문인데요. 아래의 로그를 통해 그 사실을 알 수 있습니다.
...jpa.JpaTransactionManager : Creating new transaction with name [com.ayuconpon.coupon.service.case4.CouponsServiceCase4.case4]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
...jpa.JpaTransactionManager <1>: Opened new EntityManager [SessionImpl(1561240239<open>)] for JPA transaction
...internal.TransactionImpl | : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
...internal.TransactionImpl | : begin
...jpa.JpaTransactionManager | : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@7fec354]
...TransactionInterceptor | : Getting transaction for [com.ayuconpon.coupon.service.case4.CouponsServiceCase4.case4]
...jpa.JpaTransactionManager <2>: Found thread-bound EntityManager [SessionImpl(1561240239<open>)] for JPA transaction
...jpa.JpaTransactionManager | : Suspending current transaction, creating new transaction with name [com.ayuconpon.coupon.service.case4.SaveCouponServiceCase4.success]
...jpa.JpaTransactionManager <3>: Opened new EntityManager [SessionImpl(492701217<open>)] for JPA transaction
...internal.TransactionImpl | : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
...internal.TransactionImpl | : begin
...jpa.JpaTransactionManager | : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@aee3d6e]
...TransactionInterceptor | : Getting transaction for [com.ayuconpon.coupon.service.case4.SaveCouponServiceCase4.success]
...jpa.JpaTransactionManager | : Found thread-bound EntityManager [SessionImpl(492701217<open>)] for JPA transaction
...jpa.JpaTransactionManager | : Participating in existing transaction
...TransactionInterceptor | : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
...TransactionInterceptor | : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
...TransactionInterceptor | : Completing transaction for [com.ayuconpon.coupon.service.case4.SaveCouponServiceCase4.success]
...jpa.JpaTransactionManager | : Initiating transaction commit
...jpa.JpaTransactionManager | : Committing JPA transaction on EntityManager [SessionImpl(492701217<open>)]
...internal.TransactionImpl | : committing
...jpa.JpaTransactionManager | : Closing JPA EntityManager [SessionImpl(492701217<open>)] after transaction
...jpa.JpaTransactionManager <4>: Resuming suspended transaction after completion of inner transaction
...jpa.JpaTransactionManager <5>: Found thread-bound EntityManager [SessionImpl(1561240239<open>)] for JPA transaction
...jpa.JpaTransactionManager | : Suspending current transaction, creating new transaction with name [com.ayuconpon.coupon.service.case4.SaveCouponServiceCase4.fail]
...jpa.JpaTransactionManager <6>: Opened new EntityManager [SessionImpl(1124119884<open>)] for JPA transaction
...internal.TransactionImpl | : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
...internal.TransactionImpl | : begin
...jpa.JpaTransactionManager | : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@67f5066e]
...TransactionInterceptor | : Getting transaction for [com.ayuconpon.coupon.service.case4.SaveCouponServiceCase4.fail]
...TransactionInterceptor | : Completing transaction for [com.ayuconpon.coupon.service.case4.SaveCouponServiceCase4.fail] after exception: java.lang.RuntimeException: RuntimeException inside
...jpa.JpaTransactionManager | : Initiating transaction rollback
...jpa.JpaTransactionManager | : Rolling back JPA transaction on EntityManager [SessionImpl(1124119884<open>)]
...internal.TransactionImpl | : rolling back
...jpa.JpaTransactionManager | : Closing JPA EntityManager [SessionImpl(1124119884<open>)] after transaction
...jpa.JpaTransactionManager <7>: Resuming suspended transaction after completion of inner transaction
...case4.SaveCouponServiceCase4 | : CouponsServiceCase4 caught exception at outer. ex RuntimeException inside
...TransactionInterceptor | : Completing transaction for [com.ayuconpon.coupon.service.case4.CouponsServiceCase4.case4]
...jpa.JpaTransactionManager | : Initiating transaction commit
...jpa.JpaTransactionManager | : Committing JPA transaction on EntityManager [SessionImpl(1561240239<open>)]
...internal.TransactionImpl | : committing
...jpa.JpaTransactionManager : Closing JPA EntityManager [SessionImpl(1561240239<open>)] after transaction
전체적인 흐름은 CASE 2
와 비슷합니다. 새로운 트랜잭션을 만든다는 점이 다른데요.
- <1> : 트랜잭션을 만들고,
CouponsServiceCase4.case4
를 시작합니다. - <2> : 트랜잭션 전파 정책이
Requried_new
인 메서드(success())를 만들었기 때문에, 현재의 트랜잭션을 잠시 보류합니다. - <3> : 트랜잭션을 새롭게 만들고, 이후 로직을 진행합니다.
- <4> : 자식 트랜잭션이 끝났기 때문에 <2>에서 보류했던 트랜잭션을 다시 시작합니다.
- <5> : 또 다시 트랜잭션 전파 정책이
Requried_new
인 메서드(fail())를 만들었기 때문에, 현재의 트랜잭션을 잠시 보류합니다. - <6> : 새로운 트랜잭션을 만든 후 로직을 진행합니다.
- <6> :
SaveCouponServiceCase4.fail
에서 예외가 발생했기 때문에, 롤백됩니다. - <7> : 자식 트랜잭션이 롤백되었지만, 자식 트랜잭션과 부모 트랜잭션은 별개의 트랜잭션이기 때문에 커밋됩니다.
(CASE 5) 자식 트랜잭션이 REQUIRES 전파레벨일 때, 부모 try-catch (CheckedException)
CASE 2
에서 내부에서 UncheckedExecption
예외가 발생하고, 부모에서 예외처리를 했을 때, 전역 롤백되는 것을 확인했습니다.
그렇다면 내부에서 발생했던 예외가 CkeckedExeption
일 경우 어떻게 될까요? 저장되는 Coupon
은 1개 입니다!
...jpa.JpaTransactionManager : Creating new transaction with name [com.ayuconpon.coupon.service.case5.CouponsServiceCase5.case5]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
...jpa.JpaTransactionManager <1>: Opened new EntityManager [SessionImpl(1289857868<open>)] for JPA transaction
...internal.TransactionImpl | : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
...internal.TransactionImpl | : begin
...jpa.JpaTransactionManager | : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@3c62bae5]
...TransactionInterceptor | : Getting transaction for [com.ayuconpon.coupon.service.case5.CouponsServiceCase5.case5]
...jpa.JpaTransactionManager <2> : Found thread-bound EntityManager [SessionImpl(1289857868<open>)] for JPA transaction
...jpa.JpaTransactionManager | : Participating in existing transaction
...TransactionInterceptor | : Getting transaction for [com.ayuconpon.coupon.service.case5.SaveCouponServiceCase5.success]
...jpa.JpaTransactionManager | : Found thread-bound EntityManager [SessionImpl(1289857868<open>)] for JPA transaction
...jpa.JpaTransactionManager | : Participating in existing transaction
...TransactionInterceptor | : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
...TransactionInterceptor | : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
...TransactionInterceptor | : Completing transaction for [com.ayuconpon.coupon.service.case5.SaveCouponServiceCase5.success]
...jpa.JpaTransactionManager <3>: Found thread-bound EntityManager [SessionImpl(1289857868<open>)] for JPA transaction
...jpa.JpaTransactionManager | : Participating in existing transaction
...TransactionInterceptor | : Getting transaction for [com.ayuconpon.coupon.service.case5.SaveCouponServiceCase5.fail]
...TransactionInterceptor <*>: Completing transaction for [com.ayuconpon.coupon.service.case5.SaveCouponServiceCase5.fail] after exception: java.io.IOException: IOException inside
...rvice.case5.CouponsServiceCase5| : CouponsServiceCase2 caught exception at outer. ex IOException inside
...TransactionInterceptor | : Completing transaction for [com.ayuconpon.coupon.service.case5.CouponsServiceCase5.case5]
...jpa.JpaTransactionManager | : Initiating transaction commit
...jpa.JpaTransactionManager | : Committing JPA transaction on EntityManager [SessionImpl(1289857868<open>)]
...internal.TransactionImpl <4> : committing
...jpa.JpaTransactionManager | : Closing JPA EntityManager [SessionImpl(1289857868<open>)] after transaction
- <1> : 트랜잭션을 만들고,
CouponsServiceCase5.case5
를 시작합니다. - <2> :
CASE 2
에서와 똑같이, 기존의 트랜잭션에 참여합니다. - <3> :
CASE 2
와 달라지는 부분입니다. - <*> : 발생한 예외가
IOException
이기 때문에, 트랜잭션은rollback-only
마킹 처리가 되지 않습니다. - <4> : 따라서, 부모 트랜잭션이 커밋되는 것을 확인할 수 있습니다.
왜 Unchecked Exception은 롤백하면서, Checked Exception은 롤백하지 않는 걸까?
우아한 기술 블로그의 이재용 개발자님은 아래와 같이 예상하고 계십니다.
- CheckException : 예상한 에러 -> 롤백 안함
- Unchecked Exception : 예상치 못한 에러 -> 롤백 함
이상 어느 경우 부모 트랜잭션이 롤백하는지에 대해 알아보았습니다. 끝까지 봐주셔서 감사합니다!
로깅 레벨
logging.level.root=off
logging.level.com.ayuconpon.coupon.service=DEBUG
logging.level.org.hibernate.engine.transaction=DEBUG
logging.level.org.springframework.transaction=TRACE
logging.level.org.springframework.orm=TRACE
logging.level.org.hibernate.resource.transaction.backend.jdbc.internal=DEBUG
참고자료
응? 이게 왜 롤백되는거지?
댓글 남기기