스프링 트랜잭션 전파

바그다드·2023년 8월 3일
0

스프링 트랜잭션

목록 보기
3/5

트랜잭션은 커넥션을 획득하고, 커밋이나 롤백이 실행되면, 작업이 종료되어 커넥션은 다시 커넥션 풀로 반환된다.
그렇다면 트랜잭션이 이미 진행중인데, 여기서 추가로 트랜잭션을 수행하면 어떻게 될까?
기존의 트랜잭션을 이어받아 트랜잭션을 수행해야 할까? 별도의 트랜잭션을 진행해야 할까?

이처럼
'하나의 트랜잭션이 실행되는 도중 다른 트랜잭션을 실행할 경우 어떻게 동작할지 결정하는 것'
을 트랜잭션 전파(propagation)라고 한다.

1. 전파 기본

먼저 아래의 그림을 보자

  • 외부 트랜잭션이 수행되고 트랜잭션이 끝나지 않았는데, 내부 트랜잭션이 수행이 된다.

  • 외부 트랜잭션은 상대적으로 밖에 있기 때문에 외부 트랙션이라고 하고,
    내부 트랜잭션은 외부 트랜잭션이 수행되는 도중에 호출되기 때문에 내부 트랜잭션이라고 한다.

  • 스프링에서는 트랜잭션을 논리 트랜잭션과 물리 트랜잭션으로 나눈다.

    • 물리 트랜잭션은 실제 DB에 적용되는 트랜잭션을 말한다.
      커넥션을 획득하고, 커밋, 롤백을 하는 최소 단위이다.
    • 논리 트랜잭션은 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위를 말한다.
      트랜잭션을 종료시킬 수 없다.
    • 따라서 논리 트랜잭션들은 하나의 물리 트랜잭션으로 묶인다.
      위에서 외부 트랜잭션과 내부 트랜잭션도 모두 논리 트랜잭션이 된다.
  • 스프링은 위와 같은 경우 외부 트랜잭션과 내부 트랜잭션을 하나의 물리 트랜잭션으로 묶어준다.
    내부 트랜잭션이 외부 트랜잭션에 참여하는 것이다.

    • 이게 스프링에서 제공하는 기본 동작이고, 옵션을 이용해 다른 동작을 할 수도 있다.

스프링 트랜잭션의 원칙

  1. 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
  2. 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.
  • 모든 트랜잭션이 커밋되었을 때
  • 외부 트랜잭션이 롤백되었을 때
  • 내부 트랜잭션이 롤백되었을 때

    이처럼 하나의 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백이 되고, 모든 작업이 커밋될 때만 물리 트랜잭션인 커밋이 된다.

김영한님이 말씀하셨던 것처럼 이런 대원칙을 잡고 가자!
그럼 각 예시로 확인해보자.


2.트랜잭션 전파 예시

  • 각 예시를 보기 전에 트랜잭션 관련 로그를 확인하기 위한 코드를 추가하자
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

1. 물리 트랜잭션 커밋

  • 먼저 모든 트랜잭션이 커밋되어 물리 트랜잭션이 커밋되는 상황을 확인하자.
    @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()가 true를 반환한다.
반면 내부 트랜잭션은 외부 트랜잭션에 참여하므로 isNewTransaction()가 false를 반환한다.

  • 여기서 참여한다는 뜻은 외부 트랜잭션을 그대로 이어받는다는 뜻으로
    외부 트랜잭션의 범위가 내부까지 넓어진다는 뜻이다.
    • 즉, 외부 트랜잭션과 내부 트랜잭션이 하나의 물리 트랜잭션으로 묶이게 된다.

테스트 수행 결과를 다시 확인해보자

  • 내부 트랜잭션이 시작되고,
    Participating in existing transaction이라는 로그를 통해 기존의 트랜잭션에 참여한다는 것을 확인할 수 있다.

  • 내부 트랜잭션은 커밋이 호출되었음에도 아무런 동작이 이뤄지지 않은 반면,
    외부 트랜잭션은 커밋이 호출되자 실제로 커밋이 이뤄지고 정보가 DB에 전달되는 것을 확인할 수 있다.

  • 내부 트랜잭션에서 커밋을 하면 트랜잭션이 끝나버려 외부 트랜잭션이 작업을 이어갈 수 없다.
    따라서 내부 트랜잭션은 DB커넥션을 통한 물리 트랜잭션을 커밋을 하면 안된다.
    이게 트랜잭션 전파의 기본 흐름이다.
    그럼 트랜잭션 전파는 어떻게 동작할까?

2. 트랜잭션 전파 흐름

트랜잭션이 커밋을 될 때의 예시로 트랜잭션 전파 흐름을 짚고 넘어가자.

호출 흐름

  • 외부 트랜잭션
  1. txManager.getTransaction()으로 외부 트랜잭션을 시작한다.
  2. 트랜잭션 메니저에서 데이터 소스를 이용해 커넥션을 생성한다.
  3. 커넥션을 수동 커밋 모드로 설정한다.
    • 물리 트랜잭션 시작
  4. 트랜잭션 매니저는 커넥션은 트랜잭션 동기화 매니저에 저장한다.
  5. 트랜잭션 생성 결과를 TransactionStatus에 담아서 반환단다.
    • isNewTransaction()을 통해 신규 트랜잭션인지 확인할 수 있다.
  6. 로직1이 실행되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저로부터 커넥션을 획득해 사용한다.
  • 내부 트랜잭션
  1. txManager.getTransaction()으로 내부 트랜잭션을 시작한다.
  2. 트랜잭션 매니저가 트랜잭션 동기화 매니저를 통해 기존 트랜잭션이 존재하는지 확인한다.
  3. 기존 트랜잭션이 존재하므로 기존 트랜잭션에 참여한다.
    • 이미 외부 트랜잭션에서 물리 트랜잭션을 시작했고, 커넥션을 트랜잭션 동기화 매니저에 담아두었다.
    • 따라서 그냥 두면 로직이 기존의 트랜잭션에 참여하게 된다.
    • 이후 로직은 트랜잭션 동기화 매니저에 보관된 커넥션을 사용하게 된다.
  4. 트랜잭션 매니저가 트랜잭션 생성 결과를 TransactionStatus에 담아서 반환한다.
    • 신규 트랜잭션이 아니므로 isNewTransaction()는 false를 반환한다.
  5. 로직 2가 시작되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저로부터 기존 커넥션을 획득해 사용한다.

응답 흐름

  • 내부 트랜잭션
  1. 로직2가 완료되고 트랜잭션 매니저로 커밋을 호출한다.
  2. 트랜잭션 매니저는 신규 트랜잭션 여부에 따라 다르게 동작한다.
    • 여기서는 신규 트랜잭션이 아니므로 커밋을 호출하지 않는다.
    • 신규 트랜잭션일 경우 커밋을 호출한다.
  • 외부 트랜잭션
  1. 로직1이 완료되고 트랜잭션 매니저로 커밋을 호출한다.
  2. 여기서는 신규 트랜잭션이므로 커넥션에 실제 커밋을 호출한다.(논리 커밋)
  3. 실제 데이터베이스에 커밋이 반영되고, 물리 트랜잭션을 종료한다.
    • 트랜잭션 매니져에 커밋을 하는 것은 논리적인 커밋
    • 실제 커넥션에 커밋하는 것을 물리 커밋이라 한다.

3. 외부 트랜잭션 롤백

  • 외부 트랜잭션에서 롤백이 이뤄지는 과정을 알아보자.
    @Test
    void outer_rollback() {
        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.rollback(outer);
    }

  • 외부 트랜잭션에서 롤백을 호출하고 있다.
    내부 트랜잭션은 물리 트랜잭션에 관여하지 않는다.
  • 따라서 외부 트랜잭션에서 시작한 물리 트랜잭션의 범위가 내부 트랜잭션까지 적용된다.

응답흐름

  • 내부 트랜잭션
  1. 로직 2가 끝나고 트랜잭션 매니저로 커밋을 호출한다.
  2. 트랜잭션 매니저는 신규 트랜잭션 여부에 따라 신규 트랜잭션이 아니므로 커밋을 호출하지 않는다.
  • 외부 트랜잭션
  1. 로직 1이 끝나고 트랜잭션 매니저로 롤백을 호출한다.
  2. 신규 커넥션이므로 트랜잭션 매니저는 DB커넥션에 롤백을 호출한다.
  3. 실제 데이터베이스에 롤백이 반영되고 물리 트랜잭션도 종료된다.

내부 트랜잭션 롤백

앞서 봤던 내용에서는 내부 트랜잭션은 물리 트랜잭션에 영향을 주지 않는다고 하였다. 그럼 스프링에서는 내부 트랜잭션에서 롤백을 호출할 때 어떻게 처리할까?
코드로 확인해보자

    void inner_rollback() {
        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.rollback(inner);

        log.info("외부 트랜잭션 롤백");

        Assertions.assertThatThrownBy(() -> txManager.commit(outer))
                .isInstanceOf(UnexpectedRollbackException.class);
    }
  • 외부 트랜잭션은 커밋을, 내부 트랜잭션을 롤백을 호출하고 있다.
    그런데 테스트 코드에서는 UnexpectedRollbackException을 발생시것이라 예상하고 있다.
    결과를 확인해보자.
  • Participating transaction failed - marking existing transaction as rollbackonly
    내부에서 물리 트랜잭션을 롤백을 하는 대신에 기존 트랜잭션을 롤백 전용으로 표시한다.
  • Global transaction is marked as rollback-only
    외부 트랜잭션에서 커밋을 호출했지만 트랜잭션이 롤백 전용으로 표시되어 있어 트랜잭션을 롤백한다.

응답 흐름

  • 내부 트랜잭션
  1. 로직2가 끝나고 트랜잭션 매니저를 통해 롤백을 호출한다.
  2. 트랜잭션 매니저는 신규 트랜잭션인지 여부에 따라 다르게 동작하는데,
    신규 트랜잭션이 아니므로 커넥션에 롤백을 호출하지는 않는다,
  3. 다만 트랜잭션 동기화 매니저에 rollbackOnly=true라는 표시를 해둔다.
  • 외부 트랜잭션
  1. 로직1이 끝나고 트랜잭션 매니저를 통해 커밋을 호출한다.
  2. 트랜잭션 매니저는 신규 트랜잭션이므로 커넥션에 커밋을 호출해야 하는데,
    이에 앞서 트랜잭션 동기화 매니저에 롤백 전용 표시가 있는지 확인한다.
  3. 실제 데이터베이스에 롤백이 반영되고, 트랜잭션도 종료된다.
  4. UnexpectedRollbackException이라는 런타임 예외를 던진다.
    • 개발자 입장에서는 커밋을 호출했는데, 실제로는 롤백이 호출되었다.
      이는 시스템 입장에서는 반드시 알려야하는 사항이다.
    • 예를 들어 상품 주문의 경우 상품 주문이 완료되었다고 해놓고 아무러 주문이 생성되지 않은 것이다.
    • 따라서 스프링에서는 이 경우 UnexpectedRollbackException이라는 런타임 예외를 던져 개발자에게 롤백이 발생했다는 것을 분명하게 알려준다.

정리

트랜잭션의 전파에 대해 알아보았다.
스프링 트랜잭션 전파란?
하나의 트랜잭션에서 추가로 트랜잭션이 발생하는 경우 이것을 어떻게 처리할 것인지를 말한다.

트랜잭션 전파에는 대원칙이 있는데,
논리 트랜잭션이 하나라도 롤백이 된다면 물리 트랜잭션은 롤백이 되고,
모든 논리 트랜잭션이 커밋이 되어야 물리 트랜잭션도 커밋이 된다는 것이다.

내부 트랜잭션의 경우 커밋이나 롤백으로 물리 트랜잭션에 관여할 수 없는데,
트랜잭션 매니저에서는 트랜잭션이 신규 커넥션인지 기존 트랜잭션인지 여부를 확인하여 신규 트랜잭션일 경우에만 커밋, 롤백 등을 호출하고 처리하기 때문이다.

  • 외부 트랜잭션의 경우 isNewTransaction()이 true를 반환하지만
    내부 트랜잭션의 경우 isNewTransaction()이 false를 반환한다.

내부 트랜잭션은 물리 트랜잭션에 영향을 주지 않지만 내부 트랜잭션에서 롤백을 할 경우, 트랜잭션 매니저에서 트랜잭션 동기화 매니저에 롤백 전용 표시를 남긴다. 이후 외부 트랜잭션에서 트랜잭션 매니저를 통해 실제 커밋을 호출하기 전에 트랜잭션 동기화 매니저에 롤백 전용 표시를 확인하고 롤백을 호출한다.

  • 이 때 개발자는 커밋을 기대했지만 실제로는 롤백이 실행되었으므로 스프링에서는 UnexpectedRollbackException이라는 런타임 예외를 던진다.

이것으로 트랜잭션 전파와 각 상황에 따른 동작에 대해 알아보았다. 그런데 만약 외부 트랜잭션과 내부 트랜잭션을 각각 분리해서 처리하려면 어떻게 해야할까? 다음 포스팅에서 알아보자.

출처 : 김영한 - 스프링 DB 2편

profile
꾸준히 하자!

0개의 댓글