logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=
DEBUG
#JPA log
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.resource.transaction=DEBUG
#JPA SQL
logging.level.org.hibernate.SQL=DEBUG
만일 연속으로 트랜잭션을 실행한다면, 아래와 같이 커넥션을 재사용하기 때문에 커넥션 객체는 다르지만 내부에 같은 conn0
을 사용하고 있는 것을 볼 수 있다.
@Test
void double_commit() {
log.info("트랜잭션1 시작");
TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션1 커밋");
txManager.commit(tx1);
log.info("트랜잭션2 시작");
TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션2 커밋");
txManager.commit(tx2);
}
트랜잭션을 각각 사용한다면, 두 트랜잭션은 완전히 독립적으로 작동한다. 트랜잭션 1이 정상적으로 커밋되면, 트랜잭션 2도 정상적으로 커밋 될 것이다.
하지만 만약 트랜잭션이 이미 진행중인데, 여기에 추가로 트랜잭션을 수행하면 어떻게 될까? 즉 트랜잭션 내부에서 또 다른 트랜잭션을 수행하게 된다면 우리는 기존과 다른 별도의 트랜잭션을 진행해야 할 지, 혹은 기존을 그대로 이어 받아서 트랜잭션을 수행해야 할 지 정해야 한다.
이런 경우 어떻게 동작할지 결정하는 것을 바로 트랜잭션 전파(propagation)라고 한다.
처음 시작된 트랜잭션을 외부 트랜잭션, 외부 트랜잭션에 의해 호출된 다른 트랜잭션을 논리 트랜잭션이라 한다.
그리고 스프링의 경우 이 둘을 하나로 묶어 물리 트랜잭션을 생성한다. 내부는 외부 트랜잭션에 참여하는데, 내부가 외부 트랜잭션을 그대로 이어받게 되면서 외부 트랜잭션의 범위가 확장되고 이는 곧 두개의 외부와 내부 트랜잭션이 하나의 큰 물리 트랜잭션으로 묶이게 되는 것과 같기 때문이다.
물리 트랜잭션
: 실제 데이터베이스에 적용되는 트랜잭션이다. 실제 커넥션을 통해 커밋과 롤백을 수행한다.논리 트랜잭션
: 트랜잭션 매니저를 통해 트랜잭션을 수행하는 단위이다.외부 트랜잭션에서 내부 트랜잭션이 또 실행되면 여러 복잡한 상황이 생기기 때문에, 스프링은 다음과 같이 논리 트랜잭션의 개념 도입을 통해 대원칙을 생성하였다.
🔖 트랜잭션 전파 원칙
1. 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
2. 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.
@Test
void inner_commit() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionDefinition());
log.info("is new transaction={}", outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionDefinition());
log.info("is new transaction={}", inner.isNewTransaction());
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
새로 시작되었으므로 신규 트랜잭션(isNewTransaction=true
)이 된다.
내부 트랜잭션을 시작하는 시점에는 이미 외부 트랜잭션이 진행중인 상태이다. 이 경우 내부 트랜잭션은 외부 트랜잭션에 참여하게 된다.
주의할 점은, 바로 외부 트랜잭션만이 물리 트랜잭션을 시작하고, 커밋한다는 것이다. 내부 트랜잭션에서 커밋 동작을 수행하게 되면, 트랜잭션이 끝나며 커넥션 또한 종료되기 때문에 내부 트랜잭션 실제 DB 커넥션을 사용하면 안된다.
어떻게 이게 가능한지는, 바로 밑에서 동작 과정을 확인해보자.
외부 트랜잭션이 성공적으로 커밋되었다.
ThreadLocal
에 생성한 커넥션을 보관한다.doBegin()은 추상 메서드므로 오버라이딩 된 메서드가 실행이 됌.(HibernateTransactionManger / DataSourceTransactionManager 등)새로운 커넥션이면 저장하고 뒤에서 동기화 작업
TransactionStatus
에 담아서 반환하는데, 신규 트랜잭션 여부를 나타내는 isNewTransaction
를 통해 신규 트랜잭션 여부를 확인 가능하다. 즉 쉽게 말하면 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작하기 때문에 무조건 커밋을 호출한다고, 모두 커밋이 되지 않는다.
@Test
void inner_rollback() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionDefinition());
log.info("is new transaction={}", outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionDefinition());
log.info("is new transaction={}", inner.isNewTransaction());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner);
log.info("외부 트랜잭션 커밋");
Assertions.assertThatThrownBy(() -> txManager.commit(outer))
.isInstanceOf(UnexpectedRollbackException.class);
}
외부 트랜잭션은 커밋되었는데, 내부 트랜잭션이 롤백되는 경우를 알아보자.
지금까지 학습한 바로는 외부가 커밋되어야 하기 때문에 커밋이 일어나야 하지만, 대원칙을 살펴보면 하나라도 롤백이 수행되면 전체가 롤백되어야 한다는 것을 알 수 있다.
그렇다면 과연 스프링은 내부에서 롤백이 일어나면 전체를 롤백하게 하는 과정을 어떻게 가능하게 했을까? 우선 테스트에 대한 로그를 확인해보자.
외부 트랜잭션 시작
내부 트랜잭션 시작 및 롤백
내부 트랜잭션은 사실상, 직접적으로 DB에 아무런 작업도 못하는 상태이므로 롤백을 해야한다는 표시를 현재 존재하는 전체 트랜잭션(외부 트랜잭션)에 남겨둔다. 이 표시가 바로 이미지에 보이는 rollback-only
옵션이다.
외부 트랜잭션 커밋 요청
따라서, 이후 외부 트랜잭션에서는 커밋을 호출해도 전체 트랜잭션이 롤백 표시가 되어있기 때문에 롤백을 하게 된다. 그리고 UnexpectedRollbackException.class
예외가 발생한다.
요청 흐름은 위에서의 동작 과정과 같다.
rollbackOnly
옵션 값을 True
로 설정해둔다. rollbackOnly
설정 여부를 확인한다. 설정이 되어있다면 물리 트랜잭션을 롤백하고 되어있지 않다면 커밋하고 UnexpectedRollbackException
예외를 던진다. 참고로 커밋을 요청했는데 롤백이 된다는 것은 정상적인 흐름이 아니므로, 추가적으로 스프링에서 예외를 던짐으로써 기대하지 않은 롤백이 발생했다는 것을 확실히 알려주는 것이다.
Requires_New
란, 외부와 내부 트랜잭션을 완전히 별도로 관리하는 방법이다.
이처럼 물리 트랜잭션을 분리하려면, 내부 트랜잭션을 시작할 때 REQUIRES_NEW
옵션을 사용하면 된다. 외부 트랜잭션과 내부 트랜잭션이 별도의 물리 트랜잭션을 가져서, 서로 다른 데이터베이스 커넥션을 사용할 수 있게 된다. 따라서 서로의 커밋과 롤백에 영향을 주지 않고 독립적으로 동작한다.
위의 상황처럼 외부 트래잭션이 커밋 요청을 하고 내부 트랜잭션이 롤백이 될때, 예외가 발생하는 경우를 Requires_New
옵션 사용을 통해 방지할 수 있다.
@Test
void inner_rollback_requires_new() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionDefinition());
log.info("is new transaction={}", outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); // 기존 트랜잭션 무시하고 신규 트랜잭션 생성
TransactionStatus inner = txManager.getTransaction(definition);
log.info("is new transaction={}", inner.isNewTransaction());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
내부 트랜잭션이 완전히 새로운 신규 트랜잭션으로 생성되기 때문에, isNewTransaction
값이 true
가 되게 된다. 따라서 아래 테스트 로그에서 실제 DB 커넥션을 통해 롤백이 이루어진 것을 볼 수 있다.
REQUIRES_NEW
옵션을 확인하고, 기존 트랜잭션에 참여하지 않고 새로운 트랜잭션을 시작한다.con2
를 사용해서 실제 물리 롤백을 하고, 종료되거나 커넥션 풀에 반환된다. rollbackOnly
설정이 되어있지 않은 것과 신규 트랜잭션임을 확인하고 con1
를 사용해서 실제 물리 커밋을 한다. 하나의 HTTP 요청이 여러 DB 커넥션을 여러개 들고 있기 때문에, 트래픽이 많다면 DB 장애가 일어날 수 있다는 점 참고하자.
스프링은 다양한 트랜잭션 전파 옵션을 제공하며, 별도의 설정을 하지 않으면 기본 값은 REQUIRED
이다.
기존 트랜잭션이 없으면 생성하고, 있으면 기존 트랜잭션에 참여한다.
항상 새로운 트랜잭션을 생성한다.