트랜잭션 전파

고동현·2024년 7월 8일
0

DB

목록 보기
12/13

트랜잭션 전파 커밋, 롤백

@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를 커넥션 풀에서 획득한 것.
그러므로 서로 다른 커넥션이다.

그렇다면, 둘을 구분할 수 있는방법

  • 트랜잭션 1 HikariProxyConnection@1282401410 wrapping conn0
  • 트랜잭션 2 HikariProxyConnection@1175319617 wrapping conn0

커넥션 풀에서 커넥션 획득하면 실제 커넥션을 반환하는게 아니라, 내부 관리를 위해서 히카리 프록시 커넥션이라는 객체를 생성해서 conn0으로 감싸서 반환한다.
그리고 이 프록시 내부에 실제 커넥션이 포함되어있다.

커넥션을 다루는 프록시 객체의 주소가 트랜잭션 1,2가 다른 것을 볼 수 있다.
결과적으로 conn0을 통해 커넥션이 재사용되는것은 맞지만, 1282401410,1175319617을 통해 각각 커넥션 풀에서 커넥션을 조회한 것을 확인 할 수 있다.

트랜잭션 전파 기본

앞에서 한것처럼 트랜잭션을 각각 사용하는것이 아닌, 트랜잭션이 이미 진행중인데, 여기에 추가로 트랜잭션을 수행하면 어떻게 될까?

이런경우 어떻게 동작할지 결정하는 것을 트랜잭션 전파라고 한다.

만약 트랜잭션을 수행하고 있는도중에(아직 커밋이나 롤백을 하지 않은경우) 또다른 트랜잭션을 내부에서 수행한다면,

스프링은 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 트랜잭션으로 만들어준다.

내부 트랜잭션이 외부 트랜잭션에 참여하는것이다.
이게 기본동작이고, 옵션을 통해 다른 동작 방식도 선택할 수 있다.

그러면, 논리 트랜잭션과 물리 트랜잭션을 나눌수 있다.

  • 물리 트랜잭션: 실제 데이터 베이스에 적용되는 트랜잭션
    실제 커넥션을 통해서 트랜잭션 시작(setAutoCommit(false))하고, 실제 커넥션을 통해서 커밋,롤백하는 단위
  • 논리 트랜잭션: 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위
    논리 트랜잭션은 트랜잭션이 진행되는 중에 내부에 추가로 트랜잭션을 사용하는 경우에 나타난다.
    단순히 트랜잭션이 하나인 경우 둘을 구분하지 않는다.

커밋과 롤백 조건

  • 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
  • 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.

트랜잭션 전파예제

 @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 커넥션을 통한 물리 트랜잭션을 커밋하면 안된다.

고로, 여러 트랜잭션이 사용되는경우 처음 트랜잭션을 시작한 외부트랜잭션이 실제 물리 트랜잭션을 관리 하도록한다.

  1. txManager.getTransaction()호출하여 외부 트랜잭션 시작
  2. 트랜잭션 매니저는 데이터 소스를 통해 커넥션을 생성
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
  3. 수동 커밋모드로 설정 -> 물리 트랜잭션 시작
  4. 트랜잭션 매니저는 트랜잭션 동기화 매니저에 해당 커넥션을 보관
  5. 트랜잭션 매니저는 트랜잭션을 생성한 결과를 TransactionStatus에 담아서 반환, 신규 트랜잭션의 여부가 담겨 있음.
    isNewTransaction을 통해 신규 트랜잭션 여부를 확인 할 수 있다. 여기서는 트랜잭션을 처음 시작했으므로 신규 트랜잭션임.
  6. 로직1이 실해되고, 커넥션이 필요한 경우(내부 트랜잭션할때) 트랜잭션 동기화 매니저를 통해 트랜잭션이 이미 적용된 커넥션을 획득해서 사용

내부 트랜잭션

  1. txManager.getTransaction(new DefaultTransactionAttribute());으로 내부 트랜잭션 시작
  2. 트랜잭션 매니저가 트랜잭션 동기화 매니저를 통해서 기존 트랜잭션이 존재하는 지확인
  3. 이미 동기화 매니저에 트랜잭션이 적용된 커넥션이 있음(외부 트랜잭션에서 시작된 커넥션을 이미 동기화 매니저에 담아두었음), 그러면 기존 트랜잭션에 참여(사실 아무것도 하지 않는다는뜻)
  4. 트랜잭션 매니저는 트랜잭션을 생성한 결과를 TransactionStatus에 담아서 반환하는데, 여기서는 기존 트랜잭션에 참여하였으므로, false임
  5. 로직 2가 사용되고, 커넥션이 필요하면, 동기화 매니저를 통해 외부 트랜잭션이 보관한 커넥션을 획득해서 사용(여기서는 내부 트랜잭션이 1개밖에없어서 안씀)

응답흐름

  1. 로직2가 끝나고 트랜잭션 매니저를 통해 내부 트랜잭션 커밋
  2. 신규 트랜잭션이 아니면 실제 커밋을 호출하지 않음.
    실제 커넥션에 커밋이나 롤백 호출시 물리 트랜잭션이 끝나버리므로, 아직 트랜잭션이 끝나지 않았으므로 실제 커밋을 호출하면 안됨
  3. 로직 1이 끝나고 트랜잭션 매니저를 통해 외부 트랜잭션을 커밋
  4. 트랜잭션 매니저는 외부 트랜잭션은 신규 트랜잭션이므로 DB커넥션에 실제 커밋을 호출
  5. 트랜잭션 매니저에 외부트랜잭션이 커밋하면 실제 데이터베이스에 커밋이 반영되고, 물리 트랜잭션도 끝난다.

핵심은 트랜잭션 매니저에 커밋을 호출한다고 해서 항상 실제 커넥션에 물리 커밋이 발생하지 않는다는 것이다.

신규 트랜잭션(외부 트랜잭션)인 경우에만 실제 커넥션을 사용해서 물리 커밋과 롤백을 수행한다.

트랜잭션 전파 - 외부 롤백

내부 트랜잭션 커밋, 외부 트랜잭션이 롤백되는 상황에 대해 알아보자.

    @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 런타임 예외를 던진다. 그래서 커밋을 시도했지만 기대하지 않은 롤백이 발생했다고 명확히 알려준다.

REQUIRES_NEW

이번에는 외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 사용하는 방법에 대해 알아보자

이번에는 외부트랜잭션과 내부 트랜잭션을 완전히 분리해서 각각 별도의 물리 트랜잭션을 사용하므로 각각 별도로 커밋,롤백을 실행하게 된다.

물리 트랜잭션을 분리하려면 내부 트랜잭션 시작시 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 옵션을 주었다.
이 전파옵션은 내부 트랜잭션 시작시 기존트랜잭션에 참여하는것이 아닌, 새로운 물리 트랜잭션을 만들어서 시작한다.

외부 트랜잭션 시작

  • 외부 트랜잭션 시작하면서 conn0번 획득
  • newTransaction()이 true임을 확인 할 수 있음

내부 트랜잭션 시작
current transaction을 일시 정지 시킴
그리고 새로운 커넥션 conn1을 획득함(외부 트랜잭션에 참여하는게 아니라 전파옵션을 requires_new옵션이므로 완전히 새로운 신규 트랜잭션 생성)
isNewTransaction이 true임

내부 트랜잭션 롤백함
내부 트랜잭션은 신규 트랜잭션이므로 실제 물리 트랜잭션에서 롤백함
항상 물리트랜잭션,신규트랜잭션일때만 커밋 롤백가능
내부 트랜잭션은 conn1을 사용하므로 conn1에 물리 롤백 수행

외부 트랜잭션을 다시 resuming함
그리고 외부 트랜잭션 또한 신규 트랜잭션이므로 실제 물리트랜잭션을 커밋함
외부 트랜잭션은 conn0을 사용하므로 conn0에 물리 커밋 수행

requires_new옵션으로 내부트랜잭션에서 새로운 트랜잭션을 시작한다.

내부 트랜잭션은 신규 트랜잭션이므로 실제 물리 트랜잭션에서 롤백침
외부 트랜잭션 또한 신규 트랜잭션이므로 실제 물리트랜잭션에서 커밋침, rollbackOnly설정 없으므로 커밋가능

정리

  • Requires_New 옵션을 사용하면 물리트랜잭션이 명확하게 분리된다.
  • Requires_New 를 사용하면 데이터베이스 커넥션이 동시에 2개가 사용되는점을 주의해야한다.

트랜잭션 전파옵션

  • REQUIRED
    가장 많이하는 기본설정, 기존 트랜잭션이 없으면 생성, 있으면 참여

  • REQUIRES_NEW
    항상 새로운 트랜잭션을 생성

  • SUPPORT
    기존트랜잭션이 없으면, 없는대로 진행, 있으면 참여

  • NOT_SUPPORT
    기존 트랜잭션이 없으면, 트랜잭션 없는대로 진행, 트랜잭션 있으면 기존 트랜잭션 보류후 트랜잭션 없이 진행

  • MANDATORY
    트랜잭션이 반드시 있어야한다. 기존 트랜잭션이 없으면 예외발생

  • NEVER
    트랜잭션을 사용하지 않는다. 기존 트랜잭션이 있으면 예외 발생

트랜잭션 전파활용 예제 프로젝트

비즈니스 요구사항

  • 회원을 등록하고, 조회한다.
  • 회원 데이터가 변경될때 변경이력을 DB Log테이블에 남겨야한다.

두가지 경우를 생각해보자.

  • 회원저장할때 Transactional 따로, 로그저장할때 Transactional 따로 여러곳에서 Transactional적용
  • 서비스에 단일 Transactional사용

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 응답로직

  • LogRepository는 트랜잭션 C와 관련된 con2를 사용한다.
  • 로그예외라는 이름을 전달해서 런타임 예외가 발생한다.
  • LogRepository는 해당 예외를 밖으로 던진다. 이경우 트랜잭션 AOP가 예외를 받게된다.
  • 런타임 예외가 발생하였으므로, 트랜잭션 AOP는 트랜잭션 매니저에 롤백을 호출한다.
  • 트랜잭션 매니저는 신규 트랜잭션이므로 물리 롤백을 호출한다.(왜냐하면 MemberRepository와는 다른 커넥션을 새로 만들어 사용하고있기 때문이다.)

이 경우 회원은 저장되지만, 회원 이력 로그는 롤백된다. 정합성 문제가 발생한다.

둘을 하나의 트랜잭션으로 묶어서 처리해보자.

회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션으로 묶는 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 적용하는것이다.

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());
    }

  1. 클라이언트가 MemberService호출하면서 트랜잭션 AOP가 호출, 신규 트랜잭션 생성, 물리 트랜잭션 시작
  2. MemberRepository를 호출하면서 트랜잭션 AOP가 호출(Transactional이 save메서드에 있으므로), 이미 트랜잭션이 있으므로 기존트랜잭션에 참여
  3. MemberRepository의 로직은 정상응답이므로, 트랜잭션 매니저에 커밋을 요청, 신규 트랜잭션이 아니므로 실제 커밋 호출 x
  4. LogRepository를 호출하면서 트랜잭션 AOP가 호출 여기도 save메서드에 Transactional이 있으므로, 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여
  5. LogRepository에서는 런타임 예외 발생, 트랜잭션 매니저에 롤백을 요구, 신규트랜잭션이 아니므로 물리 롤백 호출x 대신에 rollbackOnly를 마킹함
  6. MemberService에서 LogRepository가 던진 런타임 예외를 받게됨, 트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요구, 신규트랜잭션이므로 물리 롤백호출
    만약 여기서 커밋을 호출한다면 예외 발생함 (UnexpectedRollbackException), 이미 rollbackOnly마킹이 되어있기 때문
    MemberService에서 예외처리를 하지 않았으므로 클라이언트한테 LogRepository로 붙터 넘어온 런타임 예외를 던지게 되고, 당연히 물리 트랜잭션이 롤백을 쳤으므로, MemberRepository도 커밋되는게 아니라 전체가 롤백됨.

정리
MemberRepository와 LogRepository를 하나의 트랜잭션으로 묶었으므로 하나가 rollback이 터졌을때 모두가 함께 롤백된다.(rollbackOnly마킹도 새김).
이 마킹이 있으면 무조건 물리 트랜잭션에서 롤백을 쳐야하므로 데이터 정합성에 문제가 발생되지 않는다.

트랜잭션 전파 활용 - 복구 REQUIRED

일단 트랜잭션에 언체크 예외, 런타임 예외가 발생하면 롤백된다.
그러면,
이전에 로그 리포지토리에 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요청에 동시에 두개의 데이터베이스 커넥션을 사용하게 된다.
고로 이 옵션을 사용하지 않고 문제를 해결할 단순한 방법이 있다면, 그걸 사용하는게 맞다.
예를 들면, 애초에 구조를 변경하는 방법이 있다.

profile
항상 Why?[왜썻는지] What?[이를 통해 무엇을 얻었는지 생각하겠습니다.]

0개의 댓글