@Slf4j
@SpringBootTest
public class BasicTxTest {
@Autowired
PlatformTransactionManager txManager;
@TestConfiguration
static class Config{
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource){
return new DataSourceTransactionManager(dataSource);
}
}
@Test
void commit(){
log.info("트랜잭션 시작");
TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션 커밋 시작");
txManager.commit(status);
log.info("트랜잭션 커밋 완료");
}
@Test
void rollback(){
log.info("트랜잭션 시작");
TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션 롤백 시작");
txManager.rollback(status);
log.info("트랜잭션 롤백 완료");
}
}
일단 스프링은 PlatformTransactionManager를 통해서 트랜잭션을 수행하는데,
원래는 DataSource를 자동으로 가져와서 PlatformTransactionManager의 구현체를 자동으로 등록하는데,
여기서는 PlatformTransactionManager의 구현체를 JDBC DB 접근법인 DataSourceTransactionManager를 등록하였고 해당 구현체에는 DB커넥션을 위한
DataSource가 필요하므로 DataSource를 넘겨주었다.
DataSource에 포함된 정보
JDBC URL: 데이터베이스의 위치를 나타내는 URL입니다.
사용자명 및 비밀번호: 데이터베이스 접근을 위한 인증 정보입니다.
드라이버 클래스 이름: 데이터베이스와의 연결을 관리하는 드라이버 클래스의 이름입니다.
기타 설정: 연결 풀 크기, 타임아웃 등 기타 연결 관련 설정 정보가 포함될 수 있습니다.
해당 테스트 실행시 로그를 확인 할 수 있다.
ringtx.propagation.BasicTxTest : 트랜잭션 시작
DataSourceTransactionManager : Creating new transaction with name [null]
DataSourceTransactionManager : Acquired Connection [conn0] for JDBC
transaction
DataSourceTransactionManager : Switching JDBC Connection [conn0] to manual
commit
ringtx.propagation.BasicTxTest : 트랜잭션 커밋 시작
DataSourceTransactionManager : Initiating transaction commit
DataSourceTransactionManager : Committing JDBC transaction on Connection
[conn0]
DataSourceTransactionManager : Releasing JDBC Connection [conn0] after
transaction
ringtx.propagation.BasicTxTest : 트랜잭션 커밋 완료
@Test
void double_commit(){
log.info("트랜잭션1 시작");
TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션1 커밋 시작");
txManager.commit(tx1);
log.info("트랜잭션1 커밋 완료");
log.info("트랜잭션2 시작");
TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션2 커밋 시작");
txManager.commit(tx2);
log.info("트랜잭션2 커밋 완료");
}
해당 코드는 tx1 트랜잭션을 시작후 완전히 커밋을 하고 tx2 트랜잭션을 수행하는것이다.
트랜잭션1 시작
2024-07-08T12:51:44.819+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2024-07-08T12:51:44.822+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Acquired Connection [HikariProxyConnection@1282401410 wrapping conn0: url=jdbc:h2:mem:6bc194e7-e9b1-4430-8119-7c38240d8887 user=SA] for JDBC transaction
2024-07-08T12:51:44.825+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Switching JDBC Connection [HikariProxyConnection@1282401410 wrapping conn0: url=jdbc:h2:mem:6bc194e7-e9b1-4430-8119-7c38240d8887 user=SA] to manual commit
2024-07-08T12:51:44.826+09:00 INFO 18556 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 트랜잭션1 커밋 시작
2024-07-08T12:51:44.827+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Initiating transaction commit
2024-07-08T12:51:44.828+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Committing JDBC transaction on Connection [HikariProxyConnection@1282401410 wrapping conn0: url=jdbc:h2:mem:6bc194e7-e9b1-4430-8119-7c38240d8887 user=SA]
2024-07-08T12:51:44.829+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Releasing JDBC Connection [HikariProxyConnection@1282401410 wrapping conn0: url=jdbc:h2:mem:6bc194e7-e9b1-4430-8119-7c38240d8887 user=SA] after transaction
2024-07-08T12:51:44.830+09:00 INFO 18556 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 트랜잭션1 커밋 완료
2024-07-08T12:51:44.830+09:00 INFO 18556 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 트랜잭션2 시작
2024-07-08T12:51:44.830+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2024-07-08T12:51:44.830+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Acquired Connection [HikariProxyConnection@1175319617 wrapping conn0: url=jdbc:h2:mem:6bc194e7-e9b1-4430-8119-7c38240d8887 user=SA] for JDBC transaction
2024-07-08T12:51:44.830+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Switching JDBC Connection [HikariProxyConnection@1175319617 wrapping conn0: url=jdbc:h2:mem:6bc194e7-e9b1-4430-8119-7c38240d8887 user=SA] to manual commit
2024-07-08T12:51:44.830+09:00 INFO 18556 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 트랜잭션2 커밋 시작
2024-07-08T12:51:44.831+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Initiating transaction commit
2024-07-08T12:51:44.831+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Committing JDBC transaction on Connection [HikariProxyConnection@1175319617 wrapping conn0: url=jdbc:h2:mem:6bc194e7-e9b1-4430-8119-7c38240d8887 user=SA]
2024-07-08T12:51:44.831+09:00 DEBUG 18556 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Releasing JDBC Connection [HikariProxyConnection@1175319617 wrapping conn0: url=jdbc:h2:mem:6bc194e7-e9b1-4430-8119-7c38240d8887 user=SA] after transaction
2024-07-08T12:51:44.831+09:00 INFO 18556 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 트랜잭션2 커밋 완료
tx1 트랜잭션 시작한 후에 conn0커넥션 획득 -> 커밋후 conn0번 반환
tx2 트랜잭션 시작후 conn0커넥션 획득 -> 커밋 후 conn0번 바환
트랜잭션 1,2가 같은 conn0번 커넥션을 사용중. 그이유는 트랜잭션1이 conn0 커넥션을 사용하고 커넥션 풀에 반납까지 완료 했으므로, 이후에 트랜잭션2가 conn0를 커넥션 풀에서 획득한 것.
그러므로 서로 다른 커넥션이다.
그렇다면, 둘을 구분할 수 있는방법
커넥션 풀에서 커넥션 획득하면 실제 커넥션을 반환하는게 아니라, 내부 관리를 위해서 히카리 프록시 커넥션이라는 객체를 생성해서 conn0으로 감싸서 반환한다.
그리고 이 프록시 내부에 실제 커넥션이 포함되어있다.
커넥션을 다루는 프록시 객체의 주소가 트랜잭션 1,2가 다른 것을 볼 수 있다.
결과적으로 conn0을 통해 커넥션이 재사용되는것은 맞지만, 1282401410,1175319617을 통해 각각 커넥션 풀에서 커넥션을 조회한 것을 확인 할 수 있다.
앞에서 한것처럼 트랜잭션을 각각 사용하는것이 아닌, 트랜잭션이 이미 진행중인데, 여기에 추가로 트랜잭션을 수행하면 어떻게 될까?
이런경우 어떻게 동작할지 결정하는 것을 트랜잭션 전파라고 한다.
만약 트랜잭션을 수행하고 있는도중에(아직 커밋이나 롤백을 하지 않은경우) 또다른 트랜잭션을 내부에서 수행한다면,
스프링은 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 트랜잭션으로 만들어준다.
내부 트랜잭션이 외부 트랜잭션에 참여하는것이다.
이게 기본동작이고, 옵션을 통해 다른 동작 방식도 선택할 수 있다.
그러면, 논리 트랜잭션과 물리 트랜잭션을 나눌수 있다.
커밋과 롤백 조건
@Test
void inner_commit(){
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("out.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=true이다.
내부 트랜잭션을 시작하는 시점에는 이미 외부 트랜잭션이 진행중이므로, 이 경우 내부 트랜잭션은 외부 트랜잭션에 참여한다. isNewTransaction=false
결국 내부트랜잭션이 외부 트랜잭션에 참여하므로, 하나의 물리 트랜잭션으로 묶이는데,
하나의 커넥션에는 커밋,롤백이 한번만 호출할 수 있는데 내부 트랜잭션, 외부 트랜잭션 커밋을 두번 어떻게 하는것일까?
로그를 보면 알 수 있다.
외부 트랜잭션 시작
2024-07-08T13:42:31.907+09:00 DEBUG 23528 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2024-07-08T13:42:31.910+09:00 DEBUG 23528 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Acquired Connection [HikariProxyConnection@1329489396 wrapping conn0: url=jdbc:h2:mem:67bedfbb-8d32-41da-8f62-905f9415a73a user=SA] for JDBC transaction
2024-07-08T13:42:31.913+09:00 DEBUG 23528 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Switching JDBC Connection [HikariProxyConnection@1329489396 wrapping conn0: url=jdbc:h2:mem:67bedfbb-8d32-41da-8f62-905f9415a73a user=SA] to manual commit
2024-07-08T13:42:31.914+09:00 INFO 23528 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : out.isNewTransaction()=true
2024-07-08T13:42:31.915+09:00 INFO 23528 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 내부 트랜잭션 시작
2024-07-08T13:42:31.915+09:00 DEBUG 23528 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Participating in existing transaction
2024-07-08T13:42:31.915+09:00 INFO 23528 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : inner.isNewTransaction()=false
2024-07-08T13:42:31.915+09:00 INFO 23528 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 내부 트랜잭션 커밋
2024-07-08T13:42:31.915+09:00 INFO 23528 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 외부 트랜잭션 커밋
2024-07-08T13:42:31.915+09:00 DEBUG 23528 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Initiating transaction commit
2024-07-08T13:42:31.916+09:00 DEBUG 23528 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Committing JDBC transaction on Connection [HikariProxyConnection@1329489396 wrapping conn0: url=jdbc:h2:mem:67bedfbb-8d32-41da-8f62-905f9415a73a user=SA]
2024-07-08T13:42:31.918+09:00 DEBUG 23528 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Releasing JDBC Connection [HikariProxyConnection@1329489396 wrapping conn0: url=jdbc:h2:mem:67bedfbb-8d32-41da-8f62-905f9415a73a user=SA] after transaction
외부 트랜잭션 시작에는 Creating new transaction으로.. 트랜잭션을 만든다.
그러나 내부 트랜잭션을 시작할때는 Creating이 아니라 Participating으로 로그를 출력하는것을 볼 수 있다.(내부 트랜잭션이 기존에 존재하는 외부 트랜잭션에 참여)
그리고 내부 트랜잭션 커밋할때는 실제로 커밋을 하지 않고 넘어가는 것을 볼 수 있다.
고로, 외부트랜잭션을 시작하거나 커밋할때는 DB커넥션을 통한 물리 트랜잭션 시작, DB커넥션을 통해 커밋하는것을 확인 할 수 있지만, 내부트랜잭션을 시작하거나 커밋할때는 DB 커넥션을 통해 커밋하는 로그가 없다.
만약 내부 트랜잭션이 실제 물리 트랜잭션을 커밋하면, 트랜잭션이 끝나버리기 때문에, 트랜잭션을 처음 시작한 외부 트랜잭션까지 이어갈 수 없다. 따라서 내부트랜잭션은 DB 커넥션을 통한 물리 트랜잭션을 커밋하면 안된다.
고로, 여러 트랜잭션이 사용되는경우 처음 트랜잭션을 시작한 외부트랜잭션이 실제 물리 트랜잭션을 관리 하도록한다.
내부 트랜잭션
응답흐름
핵심은 트랜잭션 매니저에 커밋을 호출한다고 해서 항상 실제 커넥션에 물리 커밋이 발생하지 않는다는 것이다.
신규 트랜잭션(외부 트랜잭션)인 경우에만 실제 커넥션을 사용해서 물리 커밋과 롤백을 수행한다.
내부 트랜잭션 커밋, 외부 트랜잭션이 롤백되는 상황에 대해 알아보자.
@Test
void outer_rollback(){
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("out.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.rollback(outer);
}
외부 트랜잭션이 실제 물리 트랜잭션을 시작하고 있다.
앞서 배운것 처럼 내부 트랜잭션은 직접 물리 트랜잭션에 관여하지 않는다.
즉 내부 트랜잭션에서 commit을 호출하여도 실제 커밋을 호출하지 ㅇ낳는다.
외부가 실제 트랜잭션인데 외부 트랜잭션이 현재 롤백을 치므로, 전체가 롤백된다.
T10:16:41.708+09:00 INFO 23192 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 외부 트랜잭션 시작
2024-07-09T10:16:41.711+09:00 DEBUG 23192 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2024-07-09T10:16:41.715+09:00 DEBUG 23192 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Acquired Connection [HikariProxyConnection@2099052183 wrapping conn0: url=jdbc:h2:mem:d4191ae1-553c-4476-97e5-b1b8582a2a35 user=SA] for JDBC transaction
2024-07-09T10:16:41.720+09:00 DEBUG 23192 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Switching JDBC Connection [HikariProxyConnection@2099052183 wrapping conn0: url=jdbc:h2:mem:d4191ae1-553c-4476-97e5-b1b8582a2a35 user=SA] to manual commit
2024-07-09T10:16:41.721+09:00 INFO 23192 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : out.isNewTransaction()=true
2024-07-09T10:16:41.721+09:00 INFO 23192 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 내부 트랜잭션 시작
2024-07-09T10:16:41.721+09:00 DEBUG 23192 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Participating in existing transaction
2024-07-09T10:16:41.721+09:00 INFO 23192 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : inner.isNewTransaction()=false
2024-07-09T10:16:41.722+09:00 INFO 23192 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 내부 트랜잭션 커밋
2024-07-09T10:16:41.722+09:00 INFO 23192 --- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 외부 트랜잭션 롤백
2024-07-09T10:16:41.722+09:00 DEBUG 23192 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Initiating transaction rollback
2024-07-09T10:16:41.723+09:00 DEBUG 23192 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Rolling back JDBC transaction on Connection [HikariProxyConnection@2099052183 wrapping conn0: url=jdbc:h2:mem:d4191ae1-553c-4476-97e5-b1b8582a2a35 user=SA]
2024-07-09T10:16:41.724+09:00 DEBUG 23192 --- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Releasing JDBC Connection [HikariProxyConnection@2099052183 wrapping conn0: url=jdbc:h2:mem:d4191ae1-553c-4476-97e5-b1b8582a2a35 user=SA] after trans
실재로 내부 트랜잭션에서는 commit을 날리지 않고,
외부 트랜잭션에서만 롤백할때 initiating transaction rollback 로그가 남은것을 볼 수 있다.
이번엔 내부가 롤백하고 외부 트랜잭션이 커밋하는 경우를 살펴보겠다.
이 경우도 마찬가지로, 하나라도 논리 트랜잭션에 롤백이 존재하면 전체가 롤백되어야한다.
그런데, 실제 물리 트랜잭션이 DB 커넥션에 롤백 또는 커밋을 요청하는데 외부 트랜잭션이 커밋해버리면 앞에서 한것처럼 커밋되어버리는건 아닐까?
@Test
void inner_rollback(){
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("out.isNewTransaction()={}",outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("inner.isNewTransaction()={}",inner.isNewTransaction());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
외부 트랜잭션이 시작되고, 실제 물리 트랜잭션이 시작된다.
내부트랜잭션시작시, 트랜잭션을 만드는게 아니라, 기존 트랜잭션에 참여한다. -> Participating in existing transaction
그리고 롤백을 하면 transaction에다가 rollback-only를 마킹한다.
그러면 외부 트랜잭션(물리트랜잭션)에서 commit또는 롤백할때 rollback-only마킹이 되어있으므로 반드시 rollback을 해줘야한다.
만약 이와 같이, commit을 하면 rollback-only마킹이 있는데 커밋한것이므로,
UnexpectedRollbackException이 발생한다.
응답 흐름을 보면, 내부 트랜잭션에서 롤백 요청시 실제 롤백을 호출하지 않는다.
그러므로 트랜잭션 동기화 매니저에 rollbackOnly = true라는 표시를 한다.
그리고 외부트랜잭션에서 커밋을 요청하면 트랜잭션 동기화 매니저에서 롤백 전용 마킹이 있는지 확인한다.
롤백 전용 표시가 있으면 물리 트랜잭션을 커밋하는게 아니라 롤백한다.
실제 DB에는 롤백이 반영되게 된다.
트랜잭션 매니저에 커밋을 호출한 개발자 입장에서 롤백이 호출되었다는것은 큰 문제이다.
스프링의 경우, UnexpectedRollbackException 런타임 예외를 던진다. 그래서 커밋을 시도했지만 기대하지 않은 롤백이 발생했다고 명확히 알려준다.
이번에는 외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 사용하는 방법에 대해 알아보자
이번에는 외부트랜잭션과 내부 트랜잭션을 완전히 분리해서 각각 별도의 물리 트랜잭션을 사용하므로 각각 별도로 커밋,롤백을 실행하게 된다.
물리 트랜잭션을 분리하려면 내부 트랜잭션 시작시 Requeires_new 옵션을 사용
@Test
void inner_rollback_requires_new(){
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("out.isNewTransaction()={}",outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus inner = txManager.getTransaction(definition);
log.info("inner.isNewTransaction()={}",inner.isNewTransaction());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
내부 트랜잭션 시작시 전파 옵션인 propagationBehavior에 PROPAGATION_REQUIRES_NEW 옵션을 주었다.
이 전파옵션은 내부 트랜잭션 시작시 기존트랜잭션에 참여하는것이 아닌, 새로운 물리 트랜잭션을 만들어서 시작한다.
외부 트랜잭션 시작
내부 트랜잭션 시작
current transaction을 일시 정지 시킴
그리고 새로운 커넥션 conn1을 획득함(외부 트랜잭션에 참여하는게 아니라 전파옵션을 requires_new옵션이므로 완전히 새로운 신규 트랜잭션 생성)
isNewTransaction이 true임
내부 트랜잭션 롤백함
내부 트랜잭션은 신규 트랜잭션이므로 실제 물리 트랜잭션에서 롤백함
항상 물리트랜잭션,신규트랜잭션일때만 커밋 롤백가능
내부 트랜잭션은 conn1을 사용하므로 conn1에 물리 롤백 수행
외부 트랜잭션을 다시 resuming함
그리고 외부 트랜잭션 또한 신규 트랜잭션이므로 실제 물리트랜잭션을 커밋함
외부 트랜잭션은 conn0을 사용하므로 conn0에 물리 커밋 수행
requires_new옵션으로 내부트랜잭션에서 새로운 트랜잭션을 시작한다.
내부 트랜잭션은 신규 트랜잭션이므로 실제 물리 트랜잭션에서 롤백침
외부 트랜잭션 또한 신규 트랜잭션이므로 실제 물리트랜잭션에서 커밋침, rollbackOnly설정 없으므로 커밋가능
정리
REQUIRED
가장 많이하는 기본설정, 기존 트랜잭션이 없으면 생성, 있으면 참여
REQUIRES_NEW
항상 새로운 트랜잭션을 생성
SUPPORT
기존트랜잭션이 없으면, 없는대로 진행, 있으면 참여
NOT_SUPPORT
기존 트랜잭션이 없으면, 트랜잭션 없는대로 진행, 트랜잭션 있으면 기존 트랜잭션 보류후 트랜잭션 없이 진행
MANDATORY
트랜잭션이 반드시 있어야한다. 기존 트랜잭션이 없으면 예외발생
NEVER
트랜잭션을 사용하지 않는다. 기존 트랜잭션이 있으면 예외 발생
비즈니스 요구사항
두가지 경우를 생각해보자.
Member
@Entity
@Getter
@Setter
public class Member {
@Id
@GeneratedValue
private Long id;
private String username;
public Member(){
}
public Member(String username){
this.username = username;
}
}
MemberRepository
@Slf4j
@Repository
@RequiredArgsConstructor
public class MemberRepository {
private final EntityManager em;
@Transactional
public void save(Member member){
log.info("member 저장");
em.persist(member);
}
public Optional<Member> find(String username){
return em.createQuery("select m from Member m where m.username=:username",Member.class)
.setParameter("username",username)
.getResultList().stream().findAny();
}
}
리포지토리에서 회원저장할때 Transactional을 사용한다.
Log
@Entity
@Getter
@Setter
public class Log {
@Id
@GeneratedValue
private Long id;
private String message;
public Log(){
}
public Log(String message){
this.message = message;
}
}
LogRepository
@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {
private final EntityManager em;
@Transactional
public void save(Log logMessage) {
log.info("log 저장");
em.persist(logMessage);
if(logMessage.getMessage().contains("로그예외")){
log.info("log 저장시 예외 발생");
throw new RuntimeException("예외 발생");
}
}
public Optional<Log> find(String message){
return em.createQuery("select l from Log l where l.message=:message",Log.class)
.setParameter("message",message)
.getResultList().stream().findAny();
}
}
로그리포지토리에서 따로, log를 저장할때 Transactional을 건것을 확인 할 수 있다.
MemberService
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final LogRepository logRepository;
public void joinV1(String username){
Member member = new Member(username);
Log logMessage = new Log(username);
log.info("member Repository 호출 시작");
memberRepository.save(member);
log.info("member Repository 호출 종료");
log.info("logRepository 호출 시작");
logRepository.save(logMessage);
log.info("logRepository 호출 종료");
}
public void joinV2(String username){
Member member = new Member(username);
Log logMessage = new Log(username);
log.info("memberRepository 호출 시작 ");
memberRepository.save(member);
log.info("memberRepository 호출 종료");
log.info("logRepository 호출 시작");
try{
logRepository.save(logMessage);
}catch (RuntimeException e){
log.info("log 저장에 실패했습니다. logMessage={}",logMessage.getMessage());
log.info("정상 흐름 반환");
}
log.info("logRepository 호출 종료");
}
}
joinV1에서는 각 리포지토리 save메서드 호출하고 있다. joinV2에서는 logRepository.save메서드 호출시 "로그예외"라는 문자열을 가지고 있으면, RuntimeException이 발생하므로, catch부분에서 정상흐름으로 처리해주는것을 볼 수 있다.
RuntimeException 발생시 트랜잭션 AOP는 트랜잭션 매니저에 롤백을 호출하게된다.
@Slf4j
@SpringBootTest
class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Autowired
LogRepository logRepository;
@Test
void outerTxoff_success() {
//given
String username = "outerTxoff_success";
//when
memberService.joinV1(username);
//then: 저장 정상적으로 완료
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isPresent());
}
@Test
void outerTxOff_fail(){
//given
String username = "로그예외_outerTxOff_fail";
//when
assertThatThrownBy(()-> memberService.joinV1(username)).isInstanceOf(RuntimeException.class);
//then
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
}
outerTxoff_success
정상적으로 member와 log 둘다 저장되는 로직
MemberRepository와 LogRepository에 각각 따로 Transactional이 붙어있으므로 둘다 AOP Proxy가 만들어 지게 된다.
MemberService에서 memberRepository.save()메서드 호출시, MemberRepository에 @Transactional애노테이션이 존재 -> 트랜잭션 AOP작동한다.
트랜잭션 매니저에 트랜잭션을 요청하면 데이터 소스를 통해 커넥션 con1을 획득
->해당 커넥션을 autocommit false로 변경해서 트랜잭션 시작.
->트랜잭션 동기화 매니저를 통해 트랜잭션을 시작한 커넥션을 보관
->트랜잭션 매니저의 호출결과로 status반환, 신규 트랜잭션 여부 참
->MemberRepository는 con1을 사용하여 회원을 저장하고, 정상응답 반환했으므로 트랜잭션 AOP는 트랜잭션 매니저에 커밋을 요청한다.
->트랜잭션 매니저는 con1을 통해 물리 트랜잭션을 커밋한다. 물론, 해당 커넥션은 신규트랜잭션이고, rollbackOnly여부도 모두 체크한 상태이다.
결국 핵심은 두 Repository에 각각 Transactional이 있으므로 서로 다른 트랜잭션을 만들어서 로직을 수행한다는 것이다.
outerTxOff_fail
여기서는 MemberRepository에서는 커밋되고, LogRepository에서는 롤백되는상태이다.
현재 트랜잭션이 둘다 따로 각각 존재하므로, 서로 다른 커넥션으로 커밋 또는 롤백을한다.
LogRepsitory 응답로직
이 경우 회원은 저장되지만, 회원 이력 로그는 롤백된다. 정합성 문제가 발생한다.
둘을 하나의 트랜잭션으로 묶어서 처리해보자.
회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션으로 묶는 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 적용하는것이다.
MemberService-joinV1() 트랜잭셔널 추가
@Transactional
public void joinV1(String username){
Member member = new Me
...
}
MemberRepository와 LogRepository에는 Transactional제거
public class LogRepository {
private final EntityManager em;
//@Transactional
public void save(Log logMessage) {
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
public class MemberRepository {
private final EntityManager em;
//@Transactional
public void save(Member member){
MemberService를 시작할때부터 종료 할때까지 모든 로직을 하나의 트랜잭션을 사용하게 된다.
당연히 MemberService가 MemberRepository와 LogRepository를 호출하므로 이 로직들은 같은 커넥션을 사용하게 된다.
그러면 자연스럽게 동일한 트랜잭션 범위에 포함되게 된다.
MemberService만 트랜잭션을 처리하기 때문에, 앞에서 배운 논리,물리 트랜잭션, 외부,내부 트랜잭션, rollbackOnly,트랜잭션 전파등을 고민할 필요가 없다.
그러나 각각 트랜잭션이 필요한 상황이 생기면 어떻게 해야할까?
MemberService.save(Member)로 Member와 Log를 저장하는게 아니라,
MemberRepository.save(Member) 이것만 하고 싶은거다.
그런데, 우리는 이미 MeberRepository.save에서는 Transactional을 지웠으므로, 트랜잭션 적용이 안된다.
그러면, MemberRepository.save단일만 사용하고 싶으면, 메서드를 새로운걸 하나 또만들어야한다.
public class MemberRepository {
private final EntityManager em;
//@Transactional MemberService에서 호출
public void save(Member member){
log.info("member 저장");
em.persist(member);
}
@Transactional //MemberRepositoy에서 직접 호출
public void saveDirect(Member member){
log.info("member 저장");
em.persist(member);
}
이러면 너무 복잡하게 된다. 그래서 우리는 트랜잭션 전파를 사용할 것이다.
트랜잭션 전파 사용
트랜잭션 전파를 사용하기위해 LogRepository,MemberRepositoy의 save메서드의 Transactional 애노테이션을 주석 해제 해준다.
MemberService를 호출하면서 MemberService에 Transactiona이 있으므로 신규 트랜잭션 생성후, 물리 트랜잭션도 시작한다.
MemberRepository를 호출하면, 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
LogRepository도 마찬가지로 기존 트랜잭션에 참여한다.
MemberRepository,LogRepository둘다, 신규트랜잭션이 아니므로, 로직 수행후 트랜잭션 매니저에 커밋을 요구한다. 그러나 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않는다.
MemberService의 로직호출이 끝나고 정상 응답하면, 이때 트랜잭션 AOP는 정상응답이므로 트랜잭션 매니저에 커밋을 요청하고, 이경우 신규 트랜잭션이므로 물리 커밋을 호출한다.
@Test
void outerTxOn_success() {
//given
String username = "outerTxOn_success";
//when
memberService.joinV1(username);
//then: 모든 데이터가 정상 저장된다.
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isPresent());
}
둘다 isPresent() true임
롤백에 대해서 알아보자.
@Test
void outerTxOn_fail() {
//given
String username = "로그예외_outerTxOn_fail";
//when
assertThatThrownBy(() -> memberService.joinV1(username))
.isInstanceOf(RuntimeException.class);
//then: 모든 데이터가 롤백된다.
assertTrue(memberRepository.find(username).isEmpty());
assertTrue(logRepository.find(username).isEmpty());
}
정리
MemberRepository와 LogRepository를 하나의 트랜잭션으로 묶었으므로 하나가 rollback이 터졌을때 모두가 함께 롤백된다.(rollbackOnly마킹도 새김).
이 마킹이 있으면 무조건 물리 트랜잭션에서 롤백을 쳐야하므로 데이터 정합성에 문제가 발생되지 않는다.
일단 트랜잭션에 언체크 예외, 런타임 예외가 발생하면 롤백된다.
그러면,
이전에 로그 리포지토리에 save할때 RuntimeException을 던지게 되면, MemberService에서 해당 예외를 잡아서 처리하면 정상로직이니까, commit하지 않을까? 싶을수 있다.
참고: 트랜잭션에서 언체크예외가 발생되서 롤백되지 않는한, 그냥 메서드가 끝나면 커밋한다.
그게 MemberService의 joinV2 메서드이다.
@Transactional
public void joinV2(String username){
Member member = new Member(username);
Log logMessage = new Log(username);
log.info("memberRepository 호출 시작 ");
memberRepository.save(member);
log.info("memberRepository 호출 종료");
log.info("logRepository 호출 시작");
try{
logRepository.save(logMessage);
}catch (RuntimeException e){
log.info("log 저장에 실패했습니다. logMessage={}",logMessage.getMessage());
log.info("정상 흐름 반환");
}
log.info("logRepository 호출 종료");
}
try catch를 통해서
RuntimeException을 catch하고 정상흐름 로직으로 처리한다 치면, joinV2가 있는 MemberService에서는 LogRepository가 MemberService로 던진 RuntimeException을 외부로 던지지 않는다.
그러면 정상흐름이니까, 메서드 끝나면 커밋쳐야되는거아니냐?
싶을 수 있다.
@Test
void recoverException_fail(){
//given
String username = "로그예외_recoverException_fail";
//when
assertThatThrownBy(() -> memberService.joinV2(username))
.isInstanceOf(UnexpectedRollbackException.class);
//then: 모든 데이터가 롤백된다.
assertTrue(memberRepository.find(username).isEmpty());
assertTrue(logRepository.find(username).isEmpty());
}
그러나 memberService.joinV2는 정상흐름으로 commit시에 UnexpectedRollbackException.class가 나오고
당연히 memberRepository,logRepository에 정상적으로 저장되지 않는다.
그이유가 무엇일까? 바로 rollbackOnly 옵션때문이다.
우선 클라이언트가 MemberService의 joinV2를 호출하면, 새로운 커넥션을 만들어서 동기화 매니저가 관리하게된다.
MemberRepository에서 save메서드는 정상흐름 요청이고 트랜잭션 매니저에게 commit요청을 하지만, 신규 트랜잭션이 아니므로 커밋 호출을 하지 않는다.
LogRepository에서 save메서드는 예외가 발생하므로, RuntimeException을 던진다.
언체크 예외이므로 rollbackOnly를 설정하고 롤백 요청을 한다.
LogRepository에서 던진 rollback이 MemberService까지 올라오는데, 여기서 try catch로 잡아서 처리하니까 MemberService는 정상흐름 로직이 된다. 그러면 해당 joinV2메서드가 정상적으로 끝나고 트랜잭션 AOP는 커밋을 호출한다.
커밋을 호출할때 신규 MemberService의 트랜잭션은 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야한다. 이때, rollbackOnly를 체크하는데 체크되어있으므로 rollback을 해야하는데 커밋을 하므로 UnexpectedRollbackException이 발생하게 된다.
그렇다면 LogRepository에서는 로그를 남기는데 실패하더라도, 회원가입은 유지되게 할 수 있는 방법은 없을까?
바로 LogRepository의 save메서드를 실행할때는 REQUIRES_NEW 옵션을 사용하여서 새로운 DB 커넥션을 만들어서 사용하는 것이다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage) {
@Test
void recoverException_success(){
//given
String username = "로그예외_recoverException_fail";
//when
memberService.joinV2(username);
//then: 모든 데이터가 정상 저장된다.
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
그러면, memberRepository에 Member는 정상 저장되고, logRepository에는 rollback되어 저장이 되지 않는다.
MemgerService에서 MemberRepository의 save호출시, 여기는 REQUIRED 옵션이 없으니까 기존 트랜잭션에 참여하고, 커밋을 호출하지 않고, 커밋을 요청하게 된다.
그다음 LogRepository의 save를 호출시, 여기는 Requried new 옵션이 있으니까 DB에서 새로운 커넥션을 획득하고 그, 커넥션으로 트랜잭션을 수행하게 된다.
여기서 RuntimeException이 발생하게 되고, 언체크 예외는 롤백이니까, 트랜잭션 매니저에게 롤백을 요청하게 된다. 여기서는 새로운 커넥션을 획득했으니까 신규 트랜잭션이고 rollbackOnly마킹을 하지 않고, 그냥 롤백을 친다.
그리고 MemberService는 LogRepository가 던진 RuntimeException을 받게 되는데 우리는 try catch를 통해 RuntimeException을 처리하였다.
그러면 정상로직이 되고, 정상적으로 joinV2메서드가 끝났으므로, 커밋을 요청한다.
여기서도 당연히 rollbackOnly마킹을 검사하는데, memberRepository에서 마킹한게 없으므로 커밋을 한다.
결과적으로 회원의 데이터는 저장되고, 로그데이터만 롤백되는것을 볼 수 있다.
그러나 주의해야하는 점이 있는데,
REQUIRED_NEW를 사용하면 하나의 HTTP요청에 동시에 두개의 데이터베이스 커넥션을 사용하게 된다.
고로 이 옵션을 사용하지 않고 문제를 해결할 단순한 방법이 있다면, 그걸 사용하는게 맞다.
예를 들면, 애초에 구조를 변경하는 방법이 있다.