@Transactional 상황별 commit, rollback 전략

EP·2022년 9월 30일
11

Kotlin + Spring

목록 보기
7/9
post-thumbnail

Overview


스프링을 사용하여 개발을 하면서 예외를 가장 예민하게 처리하는 기능 중 하나가 @Transactional입니다. @Transactional은 우리가 아는 데이터베이스의 트랜잭션과 같이 ACID의 특징을 가지면서 더 이상 쪼갤 수 없는 최소 단위의 작업입니다. 트랜잭션 경계안에서 진행된 작업은 commit을 통해 성공하거나 rollback을 통해 모두 취소되어야 합니다. 애플리케이션 수준에서 논리적인 단위로 트랜잭션을 묶습니다. 스프링에서는 이를 메서드 단위로 묶습니다. 이를 명시적으로 선언하기 위해 우리는 인터페이스, 클래스, 메서드 등의 @Transactional을 붙여주기만 하면 됩니다.

그런데 이놈의 @Transactional은 사용하기 편한 정도에 비해 문제를 발견하기가 쉽지 않습니다. 이는 @Transactional을 기본적으로 이해하려면 Spring AOP와 프록시 객체를 이해해야 하고 Transaction의 전파(propagation)와 격리(isolation)을 이해해야 합니다. 그리고 데이터베이스의 트랜잭션 특징과 @Transactional의 다양한 properties를 숙지해야 합니다. 이런 문제를 각 상황에 맞게 해결하려고 하면 그때 그때 원인을 찾아내기 어려울 뿐만 아니라 테스트하기가 어렵습니다.

저는 이번 글에서 다양한 @Transactional의 use case를 테스트 코드를 통해 살펴보겠습니다. 코드를 보고 에러가 날지 추론을 하고 예상한 결과를 실제 결과를 비교해보겠습니다.

테스트 환경


  • kotlin 1.6.21
  • spring boot 2.7.4
  • jpa
  • java 17
  • testcontainer 1.17.3
    • container image: mysql

테스트는 kotlin과 Spring, Spring Data JPA, Junit의 TestContainer 환경에서 진행 했습니다. 트랜잭션의 특징상 통합 테스트가 적절하다고 판단하여 @SpringBootTest를 사용했습니다. @Transacitonal은 DB Transaction의 영향을 받기 때문에 H2 등의 인메모리 데이터베이스가 아닌 프로덕션 환경과 똑같은 데이터베이스를 사용했습니다.

엔티티는 다음과 같이 2개의 엔티티를 정의했습니다.

@Entity
class Product(
    @Id
    val id: Long? = null,
    var name: String,
    val price: BigDecimal
)
@Entity
class Addition(
    @Id
    val id: Long? = null,
    val quantity: Long,
    @Column(length = 20)
    var name: String,
    val price: BigDecimal
)

그리고 우리는 두 가지의 Exception을 정의해줬습니다. 이 둘은 모두 RuntimeException을 상속받습니다.

class AdditionException : RuntimeException()
class ProductException : RuntimeException()

우리가 이 엔티티와 예외를 가지고 여러 경우를 테스트 해볼 예정입니다. Product와 Addition 각각의 트랜잭션이 모두 commit, rollback이 되는 상황과 트랜잭션을 분리하여 Product는 commit이 되고 Addition은 rollback이 되는 경우 등을 알아보겠습니다.

spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
logging.level.org.springframework.transaction.interceptor=trace

테스트는 hibernate, transaction 로깅을 통해 확인해보았습니다. 실제 case별 테스트 코드는 깃허브를 통해 확인할 수 있습니다.

우리가 예상하는 커밋과 롤백


Case 1.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository
) {

	@Transactional
	fun create(id: Long, name: String, price: BigDecimal) {
	    val product = Product(id = id, name = name, price = price)
	    productRepository.save(product)
	}
}

우선 가장 평범한 @Transactional의 사용입니다. 트랜잭션이 이 메서드를 호출하면서 트랜잭션이 시작되고 메서드가 끝나면서 트랜잭션이 commit이 됩니다.

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [com.ep.transactional_example.command.ProductProcessor.create]

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]

Hibernate: select product0_.id as id1_1_0_, product0_.name as name2_1_0_, product0_.price as price3_1_0_ from product product0_ where product0_.id=?

[main] o.s.t.i.TransactionInterceptor : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]

[main] o.s.t.i.TransactionInterceptor : Completing transaction for [com.ep.transactional_example.command.ProductProcessor.create]

Hibernate: insert into product (name, price, id) values (?, ?, ?)

로그를 보면 TransactionManager가 트랜잭션을 가져오고 비즈니스 로직이 끝난 후 트랜잭션을 complete 처리하는 것을 확인할 수 있습니다. 트랜잭션이 완료가 되고 나서 hibernate의 insert 쿼리가 발생하는 것을 알 수 있습니다.

Case 2.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository
) {

	@Transactional
  fun create(id: Long, name: String, price: BigDecimal) {
      val product = Product(id = id, name = name, price = price)
      productRepository.save(product)
      throw ProductException()
  }
}

이런, 트랜잭션 진행 중에 Runtime 예외가 발생했습니다. 이런 경우 해당 트랜잭션은 예상했던 대로 롤백이 됩니다.

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [com.ep.transactional_example.command.ProductProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor : Completing transaction for [com.ep.transactional_example.command.ProductProcessor.create] after exception: com.ep.transactional_example.exception.ProductException

마찬가지로 트랜잭션을 가져오고 complete 처리를 하지만 Exception 이후에 완료가 되었다는 로깅을 확인할 수 있습니다. 이 부분에서 롤백마크가 있는지 확인을 하고 커밋 혹은 롤백 처리를 하게 됩니다. 실제로 hibernate의 insert 쿼리가 발생하지 않은 것을 확인할 수 있습니다.

Case 3.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository
) {

	@Transactional
  fun create(id: Long, name: String, price: BigDecimal) {
      val product = Product(id = id, name = name, price = price)
      productRepository.save(product)
      try {
          throw ProductException()
      } catch (e: RuntimeException) {
          println("Runtime Exception catch")
      }
  }
}

그렇다면 이렇게 발생한 RuntimeException을 try-catch로 잡아준 경우는 어떻게 처리가 될까요? 이 경우 트랜잭션 범위 안에서 발생한 예외를 잡아주었기 때문에 트랜잭션이 정상적으로 완료가 되면서 commit이 됩니다.

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [com.ep.transactional_example.command.ProductProcessor.create]

...

Runtime Exception catch

[main] o.s.t.i.TransactionInterceptor : Completing transaction for [com.ep.transactional_example.command.ProductProcessor.create]

Hibernate: insert into product (name, price, id) values (?, ?, ?)

예외를 처리한 로깅(Runtime Exception catch)만 확인할 수 있고 실제로 예외가 발생한 로그가 남지 않았습니다.

트랜잭션 전파 속성


Case 4.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository,
    private val additionProcessor: AdditionProcessor
) {
	@Transactional
    fun create(createProduct: CreateProduct) {
        val product = Product(id = createProduct.id, name = createProduct.name, price = createProduct.price)
        productRepository.save(product)
        additionProcessor.create(createProduct.createAdditionList)
    }
}
@Component
class AdditionProcessor(
    private val additionRepository: AdditionRepository
) {

    @Transactional
    fun create(createAdditionList: List<CreateAddition>) {
        createAdditionList.forEach {
            val addition = Addition(id = it.id, quantity = it.quantity, name = it.name, price = it.price)
            additionRepository.save(addition)
        }
    }
}

조금 코드가 길어졌지만 예상하기 쉬운 코드입니다. 외부 클래스에서 트랜잭션이 최초로 실행이 되었고 다른 트랜잭션 메서드를 호출했습니다. @Transactional의 propagation의 default 설정은 Propagation.REQUIRED 이므로 두 트랜잭션은 병합이 됩니다. 따라서 최초 트랜잭션이 시작한 메서드를 마칠 때 트랜잭션이 완료되면서 commit이 됩니다.

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [com.ep.transactional_example.command.ProductProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor: Getting transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor : Completing transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

[main] o.s.t.i.TransactionInterceptor: Completing transaction for [com.ep.transactional_example.command.ProductProcessor.create]

Hibernate: insert into product (name, price, id) values (?, ?, ?)
Hibernate: insert into addition (name, price, quantity, id) values (?, ?, ?, ?)

여기서 확인해야할 것은 ProductProcessorAdditionProcessor의 트랜잭션이 각각 가져와 지고 각각 complete 된 내용입니다. 트랜잭션이 병합은 되었지만 완료는 각각의 시점에서 완료됩니다. 전파의 의미는 트랜잭션이 commit, rollback이 되는 범위를 뜻하는 것이지 트랜잭션이 완전히 하나가 되는 것은 아님을 알 수 있습니다. ProductProcessor의 트랜잭션이 가장 처음에 시작이 되어 가장 마지막에 완료가 됩니다.

Case 5.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository,
    private val additionProcessor: AdditionProcessor
) {
	@Transactional
  fun create(createProduct: CreateProduct) {
      val product = Product(id = createProduct.id, name = createProduct.name, price = createProduct.price)
      productRepository.save(product)
      additionProcessor.createForRequiredExceptionCatch(createProduct.createAdditionList)
  }
}
@Component
class AdditionProcessor(
    private val additionRepository: AdditionRepository
) {

	@Transactional(propagation = Propagation.REQUIRED)
  fun create(createAdditionList: List<CreateAddition>) {
      createAdditionList.forEach {
          val addition = Addition(id = it.id, quantity = it.quantity, name = it.name, price = it.price)
          additionRepository.save(addition)
      }
      try {
          throw AdditionException()
      } catch (e: RuntimeException) {
          println("Runtime Catch")
      }
  }
}

이번에는 AdditionProcessor에서 additionRepository.save(addition)는 완료했지만 이후에 RuntimeException이 터졌고 try-catch로 에러를 잡아준 모습입니다. 이 역시도 두 Transaction이 정상적으로 완료되며 commit이 됩니다.

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [com.ep.transactional_example.command.ProductProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor: Getting transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

...

Runtime Catch

[main] o.s.t.i.TransactionInterceptor : Completing transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

[main] o.s.t.i.TransactionInterceptor: Completing transaction for [com.ep.transactional_example.command.ProductProcessor.create]

Hibernate: insert into product (name, price, id) values (?, ?, ?)
Hibernate: insert into addition (name, price, quantity, id) values (?, ?, ?, ?)

Case 6.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository
) {

	@Transactional
  fun createWithChildMethod(createProduct: CreateProduct) {
      val product = Product(id = createProduct.id, name = createProduct.name, price = createProduct.price)
      productRepository.save(product)
      try {
          additionProcessor.create(createProduct.createAddition)
      } catch (e: RuntimeException) {
          println("RuntimeException catch")
      }
  }
}
@Component
class AdditionProcessor(
    private val additionRepository: AdditionRepository
) {

fun create(createadditionList: List<CreateAddition>) {
      createadditionList.forEach {
          val addition = Addition(id = it.id, quantity = it.quantity, name = it.name, price = it.price)
          additionRepository.save(addition)
      }
      throw AdditionException()
  }
}

이번 코드는 @Transacitonal이 붙지 않은 외부 클래스를 호출한 코드입니다. @Transactional이 없으면 새로운 트랜잭션이 병합되는 것이 아닙니다. 따라서 이 경우에는 AdditionProcessor의 메서드를 호출한 지점에서 try-catch로 예외를 잡아줘도 에러가 발생하지 않습니다.

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [com.ep.transactional_example.command.ProductProcessor.create]

...

RuntimeException catch

[main] o.s.t.i.TransactionInterceptor: Completing transaction for [com.ep.transactional_example.command.ProductProcessor.create]

Hibernate: insert into product (name, price, id) values (?, ?, ?)
Hibernate: insert into addition (name, price, quantity, id) values (?, ?, ?, ?)

이전과는 달리 AdditionProcessor 의 트랜잭션은 생성되지 않았고 try-catch 로깅(RuntimeException catch)은 발생했습니다. 트랜잭션이 정상적으로 complete 처리가 되어 commit이 되었습니다.

왜 롤백이 안되지?


지금까지는 조금 지루했을 수 있습니다. 자 이제 조금 꼬여진 상황을 확인해보겠습니다.

Case 7.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository,
    private val additionProcessor: AdditionProcessor
) {

	fun create(id: Long, name: String, price: BigDecimal) {
        val product = Product(id = id, name = name, price = price)
        createWithInnerMethod(product)
    }

    @Transactional
    fun createWithInnerMethod(product: Product) {
        productRepository.save(product)
        throw ProductException()
    }
}

우리는 외부에서 create() 를 호출했습니다. 이 메서드는 엔티티를 생성한 뒤 @Transactional이 걸려있는 메서드를 호출했습니다. 그런데 내부 메서드인 createWithInnerMethod에서 Exception이 발생하고 있네요. 이런 경우는 어떻게 될까요?

결과는 ‘롤백이 되지 않는다는 점입니다.’ 이 이슈를 처음 겪는 사람은 당황할 텐데요. @Transactional의 범위는 정해져있으니 그 안에서 터져서 위의 다른 예시와 같이 롤백이 되어야할 거라고 예상했을 것입니다. 하지만 실제로 그렇게 작동하지 않습니다.

실제로 테스트 결과는 아래와 같습니다.

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]

Hibernate: select product0_.id as id1_1_0_, product0_.name as name2_1_0_, product0_.price as price3_1_0_ from product product0_ where product0_.id=?

[main] o.s.t.i.TransactionInterceptor           : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]

Hibernate: insert into product (name, price, id) values (?, ?, ?)

ProductProcessor의 새로운 트랜잭션은 생성되지 않았고 jpa repository의 트랜잭션만 발생했음을 확인할 수 있습니다.

이는 @Transactional이 스프링의 프록시 방식을 사용한 AOP로 구현이 되었기 입니다. 프록시 패턴은 객체를 상속받아 해당 메서드를 실행하기 전과 후에 미리 정의 된 코드를 먼저 실행시키는 방식으로 구현됩니다. 따라서 target 객체와 메서드가 정의되어있을 때 annotation 정보를 읽고 로직이 추가되는됩니다. 하지만 @Transactional이 정의되어 있지 않은 메서드를 target 했을 때, @Transactional이 적용되지 않았고 그 안에있는 다른 메서드를 호출할 때, 객체를 상속받아서 진행되는 프록시 패턴이 적용되지 않으므로 AOP가 적용되지 않습니다.

쉽게 말해 처음 진입한 메서드에서 다른 @Transactional 메서드를 호출해서 새로운 트랜잭션(전파 속성에 따라 다름)을 만들고 싶을 경우 다른 클래스의 있는 메서드를 호출해야 합니다. 즉 내부 메서드 호출은 @Transactional이 적용되지 않습니다.(self-invocation)

따라서 위의 예시는 @Transactional이 적용되지 않기 때문에 롤백이 되지 않습니다.

다만, SimpleJpaRepository 등의 repositiory.save(entity)는 내부적으로 @Transactional이 구현되어 있어서 해당 내용은 영속화 됩니다. 이 메서드는 외부에서 호출했기 때문에 프록시 패턴이 적용됩니다.

/*
 * (non-Javadoc)
 * @see org.springframework.data.repository.CrudRepository#save(java.lang.Object)
 */
@Transactional
@Override
public <S extends T> S save(S entity) {

	Assert.notNull(entity, "Entity must not be null.");

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

Case 8.

한 시름 넘겼습니다. 다음 사례를 보겠습니다.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository,
    private val additionProcessor: AdditionProcessor
) {

	@Transactional
  fun create(createProduct: CreateProduct) {
      val product = Product(id = createProduct.id, name = createProduct.name, price = createProduct.price)
      productRepository.save(product)
      try {
          additionProcessor.create(createProduct.createAdditionList)
      } catch (e: RuntimeException) {
          println("RuntimeException catch")
      }
  }
}
@Component
class AdditionProcessor(
    private val additionRepository: AdditionRepository
) {

	@Transactional(propagation = Propagation.REQUIRED)
  fun create(createAdditionList: List<CreateAddition>) {
      createAdditionList.forEach {
          val addition = Addition(id = it.id, quantity = it.quantity, name = it.name, price = it.price)
          additionRepository.save(addition)
      }
      throw AdditionException()
  }
}

위의 사례는 ProductProcessor의 트랜잭션 메서드에서 AdditionProcessorcreate()메서드를 호출했습니다. 전파 속성은 Propagation.REQUIRED 이므로 두 트랜잭션은 병합이 될 것이고요. 다만 호출된 메서드에서 에러가 RuntimeException이 발생했습니다. 하지만 다행이도 호출부에서 이 예외를 예상했는지 try-catch로 잡아두었습니다.

그런데 이 메서드의 내용은 모두 롤백이 됩니다. 이해가 되지 않습니다. 두 트랜잭션은 분명 병합(merge)가 되어 최초 트랜잭션이 끝날 때 commit 혹은 rollback이 되어야하기 때문입니다. 뿐만 아니라 생소한 Exception이 발생됩니다.

Caused by: org.springframework.transaction.UnexpectedRollbackException: 
    Transaction silently rolled back because it has been marked as rollback-only

이미 롤백마크가 되어있는 트랜잭션이어서 예외가 발생한 모양입니다. 사실 전파속성(propagation) 때문에 실제 트랜잭션이 재사용되더라도 트랜잭션 메서드의 반환시점마다 트랜잭션의 완료처리(completion)를 합니다. 물론 커밋이나 롤백같은 최종완료처리는 최초 트랜잭션이 반환될 때 일어납니다.

따라서 호출한 트랜잭션 메서드에서 Exception이 발생했고 해당 트랜잭션이 완료처리가 되면서 기존 트랜잭션을 전역적으로 rollback-only로 마킹할 것인지 설정이 됩니다. 따라서 병합된 트랜잭션은 전역 롤백 처리가 되므로 최초 트랜잭션이 끝날 때 UnexpectedRollbackException이 발생하면서 롤백을 하게 됩니다. 자세한 내용은 우아한 형제 블로그 참조

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [com.ep.transactional_example.command.ProductProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor: Getting transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor : Completing transaction for [com.ep.transactional_example.command.AdditionProcessor.create] after exception: com.ep.transactional_example.exception.AdditionException

RuntimeException catch

[main] o.s.t.i.TransactionInterceptor: Completing transaction for [com.ep.transactional_example.command.ProductProcessor.create]

테스트 결과를 보면 AdditionProcessor의 트랜잭션이 먼저 complete 처리가 되었습니다. 이 시점에서 rollback 마크가 global 트랜잭션에 묻었고 그 이후에 try-catch 처리(RuntimeException catch)가 되었습니다. 그 이후에 최초의 트랜잭션이 complete 처리가 되었죠. 따라서 UnexpectedRollbackException 이 발생하였고 hibernate insert 쿼리가 발생하지 않게 되었습니다.

트랜잭션 분리


만약 한 로직안에서 실행되지만 두 가지이상의 트랜잭션으로 나누고 싶을 때는 어떻게 해야할까요? Propagation.REQUIRES_NEW를 통해서 이 문제를 해결할 수 있습니다.

Case 9.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository,
    private val additionProcessor: AdditionProcessor
) {

	@Transactional
  fun create(createProduct: CreateProduct) {
      val product = Product(id = createProduct.id, name = createProduct.name, price = createProduct.price)
      productRepository.save(product)
      try {
          additionProcessor.create(createProduct.createAdditionList)
      } catch (e: RuntimeException) {
          println("RuntimeException catch")
      }
  }
}
@Component
class AdditionProcessor(
    private val additionRepository: AdditionRepository
) {

	@Transactional(propagation = Propagation.REQUIRES_NEW)
  fun create(createAdditionList: List<CreateAddition>) {
      createAdditionList.forEach {
          val addition = Addition(id = it.id, quantity = it.quantity, name = it.name, price = it.price)
          additionRepository.save(addition)
      }
      throw AdditionException()
  }
}

코드를 보면 마지막에 호출된 곳에서 예외가 발생하고 밖에서는 잡아줬습니다. 이 경우 이전의 Propagation.REQUIRED 속성에서는 오류가 난 걸 볼 수 있었습니다. 하지만 Propagation.REQUIRES_NEW로 트랜잭션을 분리하면 트랜잭션의 완료 연산이 나뉘게 됩니다.

위 코드의 경우 에러가 발생하면서 트랜잭션이 완료되었지만 분리되어있는 상황이라 트랜잭션 롤백 마크가 최초 트랜잭션에 묻지 않았습니다. 또한 호출부에서는 try-catch를 잡아주기 때문에 해당 트랜잭션 내에 예외가 발생하지 않았고 Product 관련 Transaction은 commit이 되고 Addtion 코드는 rollback이 되는 트랜잭션 분리를 할 수 있습니다.

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [com.ep.transactional_example.command.ProductProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor: Getting transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor : Completing transaction for [com.ep.transactional_example.command.AdditionProcessor.create] after exception: com.ep.transactional_example.exception.AdditionException

RuntimeException catch

[main] o.s.t.i.TransactionInterceptor: Completing transaction for [com.ep.transactional_example.command.ProductProcessor.create]

Hibernate: insert into product (name, price, id) values (?, ?, ?)

테스트 내용은 Case8과 상당히 유사합니다. 하지만 실제로 product에 대한 쿼리가 발생했습니다. 이는 트랜잭션이 분리가 되어있었기 때문입니다.

트랜잭션과 예외


트랜잭션은 예외를 통해 롤백을 처리하므로 다양한 옵션이 주어집니다.

Case 10.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository,
    private val additionProcessor: AdditionProcessor
) {

	@Transactional
  fun create(createProduct: CreateProduct) {
      val product = Product(id = createProduct.id, name = createProduct.name, price = createProduct.price)
      productRepository.save(product)
      additionProcessor.create(createProduct.createAdditionList)
      throw IOException()
  }
}

잘 처리하다가 마지막에 IOException이 터져버렸습니다. 그러면 자연스럽게 롤백이 될거라 생각했지만 그렇지 않습니다. IOException는 CheckedException이기 때문입니다. 스프링 트랜잭션에서 CheckedException은 롤백이 되지 않는 대상입니다. 예제에서는 코틀린을 사용하고 있어 checked exception을 thorws하지 않아도 됩니다. 코틀린과 Checked Exception에 대한 자세한 내용은 해당 포스팅을 참고하면 됩니다.

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [com.ep.transactional_example.command.ProductProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor: Getting transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor : Completing transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

[main] o.s.t.i.TransactionInterceptor: Completing transaction for [com.ep.transactional_example.command.ProductProcessor.create] after exception: java.io.IOException

Hibernate: insert into product (name, price, id) values (?, ?, ?)
Hibernate: insert into addition (name, price, quantity, id) values (?, ?, ?, ?)

내용을 보면 실제로 exception이 발생하고 로깅이 남았지만 롤백이되지 않습니다. TransactionAspectSupport.completeTransactionAfterThrowing() 구현부에서 사용되는 rollbackOn() 메서드에서

@Override
public boolean rollbackOn(Throwable ex) {
    return (ex instanceof RuntimeException || ex instanceof Error);
}

를 보면 Runtime 에러에만 rollback을 해주는 내용을 확인할 수 있습니다.

Case 11.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository,
    private val additionProcessor: AdditionProcessor
) {

	@Transactional(rollbackFor = [IOException::class])
  fun create(createProduct: CreateProduct) {
      val product = Product(id = createProduct.id, name = createProduct.name, price = createProduct.price)
      productRepository.save(product)
      additionProcessor.create(createProduct.createAdditionList)
      throw IOException()
  }
}

이렇게 CheckedException이 롤백 처리가 되지 않으면 상황상 문제가 발생할 수 있습니다. 따라서 roobackFor라는 속성을 통해 CheckedException을 정의해서 해당 에러가 발생하면 트랜잭션의 모든 내용이 롤백됩니다.

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [com.ep.transactional_example.command.ProductProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor: Getting transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor : Completing transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

[main] o.s.t.i.TransactionInterceptor: Completing transaction for [com.ep.transactional_example.command.ProductProcessor.create] after exception: java.io.IOException

hibernate의 쿼리가 발생하지 않습니다.

Case 12.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository,
    private val additionProcessor: AdditionProcessor
) {

	@Transactional(noRollbackFor = [ProductException::class])
	fun create(createProduct: CreateProduct) {
	    val product = Product(id = createProduct.id, name = createProduct.name, price = createProduct.price)
	    productRepository.save(product)
	    additionProcessor.create(createProduct.createAdditionList)
	    throw ProductException()
	}
}
@Component
class AdditionProcessor(
    private val additionRepository: AdditionRepository
) {

	@Transactional(propagation = Propagation.REQUIRED)
  fun create(createAdditionList: List<CreateAddition>) {
      createAdditionList.forEach {
          val addition = Addition(id = it.id, quantity = it.quantity, name = it.name, price = it.price)
          additionRepository.save(addition)
      }
  }
}

반대로 예외가 발생해도 rollback을 하지 않고 commit을 시키는 경우가 있습니다. 이런 경우 noRollbackFor로 예외를 설정하면 롤백이 되지 않습니다.

[main] o.s.t.i.TransactionInterceptor : Getting transaction for [com.ep.transactional_example.command.ProductProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor: Getting transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

...

[main] o.s.t.i.TransactionInterceptor : Completing transaction for [com.ep.transactional_example.command.AdditionProcessor.create]

[main] o.s.t.i.TransactionInterceptor: Completing transaction for [com.ep.transactional_example.command.ProductProcessor.create] after exception: com.ep.transactional_example.exception.ProductException

Hibernate: insert into product (name, price, id) values (?, ?, ?)
Hibernate: insert into addition (name, price, quantity, id) values (?, ?, ?, ?)

hibernate 쿼리가 발생합니다.

얘는 왜 롤백이 되는거야?


Case 13.

마지막 케이스입니다. 우리는 Case 8의 상황(호출부에서 호출되는 메서드의 예외를 잡을 때 롤백마크가 묻어 예외가 발생)을 생각해서 Case 5(호출되는 메서드에서 예외를 잡는)의 형태로 예외를 잡아내기 위해 아래와 같은 방식으로 작성했습니다.

@Component
class ProductProcessor(
    private val productRepository: ProductRepository,
    private val additionProcessor: AdditionProcessor
) {

	@Transactional
  fun updateName(productId: Long, productName: String, additionId: Long, additionName: String) {
      val product = productRepository.read(productId)
      product.updateName(productName)
      additionProcessor.updateName(additionId, additionName)
  }
}
@Component
class AdditionProcessor(
    private val additionRepository: AdditionRepository
) {

	@Transactional(propagation = Propagation.REQUIRES_NEW)
  fun updateName(additionId: Long, additionName: String) {
      try {
          val addition = additionRepository.read(additionId)
          addition.updateName(additionName)
      } catch (e: RuntimeException) {
          println("Runtime Catch")
      }
  }
}

호출되는 부분의 모든 구현부를 try-catch로 RuntimeException을 잡았습니다. 또한 Propagation.REQUIRES_NEW의 전파 속성이어서 롤백이 되지 않기를 기대했습니다. 그런데 동작중에 DataIntegrityViolationException 에러가 발생했습니다. column의 길이보다 긴 문자열이 들어가서 생긴 DataAccessException이었습니다. AdditionProcessor에서 try-catch로 잡아주지못한 Exception이 발생했으므로 모든 트랜잭션이 rollback이 되는 상황입니다.

이는 @Transactional의 구현 내용을 보면 됩니다. @Transactional을 붙인 메서드는 스프링이 트랜잭션 메니징 코드를 AOP를 통해 둘러쌉니다. 수도(pseudo-code)로 보면 아래와 같습니다.

createTransactionIfNecessary();
try {
    callMethod();
    commitTransactionAfterReturning();
} catch (exception) {
    completeTransactionAfterThrowing();
    throw exception;
}

여기서 callMethod()가 우리의 updateName()라고 보시면 됩니다. 하지만 실제로 DataAccessException이 발생한 내용은 callMethod() 이후겠죠. 결국 @Transactional이 달린 메서드 구현부에서 try-catch를 잡아줄 수 없는 상황이 발생합니다. 따라서 호출부에서 이런 상황을 예상하고 DataIntegrityViolationException 를 catch해줘야 합니다.

[main] o.s.t.i.TransactionInterceptor: Getting transaction for [com.ep.transactional_example.command.ProductProcessor.updateName]

[main] o.s.t.i.TransactionInterceptor: Getting transaction for [com.ep.transactional_example.command.AdditionProcessor.updateName]

[main] o.s.t.i.TransactionInterceptor           : Completing transaction for [com.ep.transactional_example.command.AdditionProcessor.updateName]
Hibernate: update addition set name=?, price=?, quantity=? where id=?

[main] o.h.engine.jdbc.spi.SqlExceptionHelper: SQL Error: 1406, SQLState: 22001
[main] o.h.engine.jdbc.spi.SqlExceptionHelper: Data truncation: Data too long for column 'name' at row 1
[main] o.h.e.j.b.internal.AbstractBatchImpl: HHH000010: On release of batch it still contained JDBC statements

[main] o.s.t.i.TransactionInterceptor: Completing transaction for [com.ep.transactional_example.command.ProductProcessor.updateName] after exception: org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.exception.DataException: could not execute statement

테스트 결과를 보면 2가지 트랜잭션이 실행되었습니다. 그리고 AdditionProcessor의 트랜잭션은 정상적으로 쿼리가 발생했습니다. 하지만 DB를 다녀오면서 DataAccessException이 발생했고 내부 try-catch로 잡아주지 못하는 Exception이 발생했습니다. 또한 이렇게 발생한 DataIntegrityViolationException이 호출부로 전파되고 이것을 try-catch하지 못해 롤백이 되었습니다.

Conclusion


물론 개념을 깊게 알고 언제나 적용하면 좋겠지만, 간혹 이러한 케이스를 한 번에 기억해내기 어려울 때가 있습니다. 이 글에서는 상황별로 @Transactional을 어떻게 적용하면 좋을지 간단한 예제를 통해 알아보았습니다. 원리나 혹은 깊은 내용을 찾고 싶으신 분은 제가 참조한 포스팅 글 목록을 보고 공부하시면 좋을 것 같습니다.

Reference


Transaction marked as rollback

응? 이게 왜 롤백되는거지? | 우아한형제들 기술블로그

Spring Transaction 사용 시 주의할 점

Exception & Transaction rollback 정리

[Spring Boot] @Transactional 어노테이션의 롤백 테스트

스프링 테스트 케이스에서의 @Transactional 유의점

[Spring] @Transactional propagation 동작 방식 기초

Should my tests be @Transactional?

[Spring]@Transactional과 JUnit Test

JPA 사용시 테스트 코드에서 @Transactional 주의하기

  1. TransactionalTestExecutionListener
  2. Automatic Rollback of Transactions in Spring Tests

JPA 사용시 테스트 코드에서 @Transactional 주의하기

SpringBoot - Transaction (트랜잭션)

profile
Hello!

8개의 댓글

comment-user-thumbnail
2023년 1월 31일

트랜젝션 상황별 정리가 잘 되어 있네요

답글 달기
comment-user-thumbnail
2023년 12월 4일

아주 멋진 글입니다. 트랜잭셔널 관련 검색을 해보아도 문제를 해결하지 못하고 테스트결과랑 블로그의 내용이 안맞아서 고생하던 도중 이 블로그를 발견했습니다. 정말 감사합니다

1개의 답글
comment-user-thumbnail
2023년 12월 19일

도움이 많이 되었습니다. 감사합니다

1개의 답글
comment-user-thumbnail
2024년 1월 9일

레퍼런스로 적으신 우형 블로그글을 보면 case3의 경우 동작이 다를것같은데 별도로 RuntimeException 처리 전략을 설정하셨을까요?

1개의 답글