해당 코드를 한 번 봐보자.
@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는 분명히 다른 트랜잭션이다.
트랜잭션이 각각 수행되면서 사용되는 DB 커넥션도 각각 다르다.
트랜잭션을 각자 관리하기 때문에, 전체 트랜잭션을 묶을 수 없다.
이게 무슨 소리냐면,
예를 들어 트랜잭션 1과 트랜잭션 2 중 하나라도 롤백이 되면 다 롤백을 하고 싶어!! 라고 생각하더라도..
현재 상황에서는 각각 독립적이기 때문에 안된다..
트랜잭션1을 커밋하면 트랜잭션 1만 커밋되고, 트랜잭션2에서 롤백이 발생하면 트랜잭션2에서만 롤백이 일어날 것이다.
그렇다면, 트랜잭션을 각각 사용하는 것이 아니라, 트랜잭션이 이미 진행중인데 여기에 추가로 트랜잭션을 수행하면 어떻게 될까??
- 기존 트랜잭션과 별도의 트랜잭션을 진행해야할까?
- 기존 트랜잭션을 그대로 이어받아서 트랜잭션을 수행할까?
해당 질문에 답을 제공하는 것이 바로 트랜잭션 전파(propagation) 개념이다. 이제 하나씩 알아가보자.
우선 스프링이 제공하는 기본 트랜잭션 옵션은 REQUIRED 이다.
외부 트랜잭션이 수행중이고, 아직 끝나지 않았는데 내부 트랜잭션이 수행되었다.
스프링의 기본 옵션은 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 트랜잭션으로 만들어준다.
즉, 내부 트랜잭션이 외부 트랜잭션에 참여하는 것이다.
스프링은 이해를 돕기 위해 논리 트랜잭션과 물리 트랜잭션이라는 개념을 나눈다.
논리 트랜잭션들은 하나의 물리 트랜잭션으로 묶인다.
물리 트랜잭션은 실제 DB에 적용되는 트랜잭션을 말한다. (setAutoCommit(false)하고 커밋 롤백...)
논리 트랜잭션과 물리 트랜잭션의 단순한 원칙이 있다.
- 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
- 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.
@Test
void inner_commit() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("outer.isNewTransaction()={}", outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("inner.isNewTransaction()={}", inner.isNewTransaction());
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
우선 언급했다시피, 기본 옵션은 내부 트랜잭션은 외부 트랜잭션을 그대로 받아서 사용한다 했다.
그렇기에, 내부 트랜잭션의 isNewTransaction() 함수는 false가 나올 것이다!
여기서 주의 깊게 봐야할 지점은 여기다.
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
커밋을 분명히 2번했다.
트랜잭션은 하나의 커넥션에 커밋은 한 번만 호출할 수 있다.
그렇다면 내부 트랜잭션에서 커밋해버렸으니, 외부 트랜잭션은 커밋이 안일어나는거 아닌가? 라고 생각할 수 있다.
하지만 정상적으로 커밋이 일어난다. 왜 그런지 알아보자.
우선 해당코드의 실행 결과의 일부를 가져와봤다.
외부 트랜잭션 시작
Creating new transaction with name [null]:
PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Switching JDBC Connection to manual commit
outer.isNewTransaction()=true
내부 트랜잭션 시작
Participating in existing transaction
inner.isNewTransaction()=false
내부 트랜잭션 커밋
외부 트랜잭션 커밋
Initiating transaction commit
Committing JDBC transaction on Connection
내부 트랜잭션을 시작할 때, Participating in existing transaction 이라는 메세지를 확인할 수 있다.
실행 결과를 보면 외부 트랜잭션이 커밋되고 나서야 initiating transaction commit이 발생한다.
반대로 내부 트랜잭션을 커밋할 때는 아무런 로그를 확인할 수 없다.
만약 내부 트랜잭션이 실제 물리 트랜잭션을 커밋하면 트랜잭션이 끝나버리기 때문에, 트랜잭션을 처음 시작한 외부 트랜잭션까지 이어갈 수 없을 것이다.
그렇기에 내부 트랜잭션은 DB 커넥션을 통한 물리트랜잭션을 커밋하면 안된다.
따라서, 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 한다.
좋다.
그렇다면 이번엔 내부 트랜잭션에서는 커밋이 일어났는데 외부 트랜잭션이 롤백되는 상황을 알아보자.
아까 원칙에서 논리 트랜잭션이 하나라도 롤백되면 전체 물리 트랜잭션은 롤백이 된다 했다.
한 번 확인해보자.
@Test
void outer_rollback() {
// 외부 트랜잭션 시작
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
// 내부 트랜잭션 시작
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
// 내부 트랜잭션 커밋
txManager.commit(inner);
// 외부 트랜잭션 롤백
txManager.rollback(outer);
}
외부 트랜잭션 시작
Creating new transaction with name [null]:
PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection for JDBC transaction
Switching JDBC Connection to manual commit
내부 트랜잭션 시작
Participating in existing transaction
내부 트랜잭션 커밋
외부 트랜잭션 롤백
Initiating transaction rollback
Rolling back JDBC transaction on Connection
외부 트랜잭션이 물리 트랜잭션을 시작하고 롤백하는 것을 확인할 수 있다.
결과적으로 외부 트랜잭션에서 시작한 물리 트랜잭션의 범위가 내부 트랜잭션까지 사용된다.
외부 트랜잭션이 롤백되면서 전체 내용이 모두 롤백되었다.
이번에는 내부 트랜잭션은 롤백이 되는데, 외부 트랜잭션은 커밋되는 상황을 알아보자.
우선 언급한 원칙에 따라, 당연히 모든 트랜잭션이 롤백처리가 될 것이다.
하지만 또 생각해보면, 외부 트랜잭션만 물리 트랜잭션에 영향을 주기 때문에 물리 트랜잭션이 커밋이 될 것 같다.
전체를 롤백해야 하는데 이 문제를 어떻게 해결할까?
@Test
void inner_rollback() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner);
log.info("외부 트랜잭션 커밋");
assertThatThrownBy(() -> txManager.commit(outer))
.isInstanceOf(UnexpectedRollbackException.class);
}
실행 결과를 보면 외부 트랜잭션을 커밋할 때 UnexpectedRollbackException이 발생하는 것을 알 수 있다.
해당 exception을 잘 기억하자.
외부 트랜잭션 시작
Creating new transaction with name [null]:
PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection for JDBC transaction
Switching JDBC Connection to manual commit
내부 트랜잭션 시작
Participating in existing transaction
내부 트랜잭션 롤백
Participating transaction failed - marking existing transaction as rollbackonly
Setting JDBC transaction rollback-only
외부 트랜잭션 커밋
Global transaction is marked as rollback-only but transactional code requested
commit
Initiating transaction rollback
Rolling back JDBC transaction on Connection
내부 트랜잭션 롤백쪽을 유의깊게 보자.
내부 트랜잭션 롤백이 일어나면 실제 물리 트랜잭션을 롤백하지 않는다.
대신에 marking existing transaction as rollbackonly, 즉 롤백 전용으로 표시한다.
그러고 나서 외부 트랜잭션이 커밋을 시도하면 전체 트랜잭션이 롤백 전용으로 표시되어 있기 때문에 롤백이 실행된다.
그리고 외부 트랜잭션 입장에서는 롤백을 하려고 했던 의도가 아니었으므로 UnexpectedRollbackException이 호출된 것이다.
정리를 한번 해보자.
- 논리 트랜잭션이 하나라도 롤백이 일어나면, 물리 트랜잭션은 롤백된다.
- 내부 논리 트랜잭션이 롤백되면 롤백 전용 마크를 표시한다.
- 외부 트랜잭션은 커밋할 때 롤백 전용 마크를 확인한다. 마크 표시가 되어있다면, 물리 트랜잭션을 롤백하고 UnexpectedRollbackException을 던진다.
하지만 때로는 외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서, 커밋과 롤백을 각각 별도로 해야하는 경우도 있다.
이럴 때는 트랜잭션 전파 옵션을 활용하면 된다.
내부 트랜잭션에 'REQUIRES_NEW' 옵션을 붙이면 된다.
이렇게 하면 각각 별도의 물리 트랜잭션을 가지며, DB 커넥션을 별도로 사용한다.
참고로 실무에서는 대부분 지금까지 알아본 기본 옵션인 REQUIRED를 사용한다.
'스프링 강의 필기' 카테고리의 다른 글
DB 2편 - 스프링 트랜잭션 이해 (1) | 2023.11.22 |
---|---|
DB 강의 1편 - 트랜잭션 문제 해결(2) (1) | 2023.11.09 |
DB 강의 1편 - 트랜잭션 문제 해결(1) (0) | 2023.11.08 |