[Spring DB] 트랜잭션 전파 동작 과정 알아보기

Loopy·2023년 3월 1일
0

스프링

목록 보기
15/16
post-thumbnail

☁️ 트랜잭션 커밋과 롤백 기본

트랜잭션 관련 로그 확인

  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. 논리 트랜잭션 : 트랜잭션 매니저를 통해 트랜잭션을 수행하는 단위이다.

외부 트랜잭션에서 내부 트랜잭션이 또 실행되면 여러 복잡한 상황이 생기기 때문에, 스프링은 다음과 같이 논리 트랜잭션의 개념 도입을 통해 대원칙을 생성하였다.

🔖 트랜잭션 전파 원칙
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);
    }

1. 외부 트랜잭션 시작

새로 시작되었으므로 신규 트랜잭션(isNewTransaction=true)이 된다.

2. 내부 트랜잭션이 외부에 참여

내부 트랜잭션을 시작하는 시점에는 이미 외부 트랜잭션이 진행중인 상태이다. 이 경우 내부 트랜잭션은 외부 트랜잭션에 참여하게 된다.

주의할 점은, 바로 외부 트랜잭션만이 물리 트랜잭션을 시작하고, 커밋한다는 것이다. 내부 트랜잭션에서 커밋 동작을 수행하게 되면, 트랜잭션이 끝나며 커넥션 또한 종료되기 때문에 내부 트랜잭션 실제 DB 커넥션을 사용하면 안된다.

어떻게 이게 가능한지는, 바로 밑에서 동작 과정을 확인해보자.

3. 외부 트랜잭션 커밋

외부 트랜잭션이 성공적으로 커밋되었다.

☁️ 트랜잭션 전파 동작 과정 : 신규 트랜잭션 여부

요청 흐름

  1. 외부 트랜잭션이 시작된다. 커넥션을 생성하고, 트랜잭션 동기화 매니저에 존재하는 ThreadLocal 에 생성한 커넥션을 보관한다.

  1. 기존에 존재하는지 가져와서 체크

doBegin()은 추상 메서드므로 오버라이딩 된 메서드가 실행이 됌.(HibernateTransactionManger / DataSourceTransactionManager 등)새로운 커넥션이면 저장하고 뒤에서 동기화 작업

  1. 트랜잭션 생성한 결과를 TransactionStatus 에 담아서 반환하는데, 신규 트랜잭션 여부를 나타내는 isNewTransaction 를 통해 신규 트랜잭션 여부를 확인 가능하다.
  2. 내부 트랜잭션이 시작되고, 트랜잭션 매니저는 트랜잭션 동기화 매니저를 통해 기존 트랜잭션이 존재하는지 확인한다.

  1. 기존 트랜잭션이 있다면, 트랜잭션 동기화 매니저에 보관된 기존 커넥션을 사용하여 외부 트랜잭션에 참여한다.

응답 흐름

  1. 내부 트랜잭션의 커밋 요청을 한다. 트랜잭션 매니저는 이때 신규 트랜잭션인지 여부를 확인 후 신규 트랜잭션이 아니기 때문에 실제 커밋을 호출하지 않는다. (커넥션이 유지되어야 하기 때문에)
  2. 외부 트랜잭션의 커밋 요청을 한다. 트랜잭션 매니저는 이때 신규 트랜잭션인지 여부를 확인 후, 신규 트랜잭션이기 때문에 실제 DB 커넥션을 사용하여 커밋을 한다.

즉 쉽게 말하면 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작하기 때문에 무조건 커밋을 호출한다고, 모두 커밋이 되지 않는다.

Service - Repository 에 적용

☁️ 트랜잭션 전파 - 커밋과 롤백

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

외부 트랜잭션은 커밋되었는데, 내부 트랜잭션이 롤백되는 경우를 알아보자.

지금까지 학습한 바로는 외부가 커밋되어야 하기 때문에 커밋이 일어나야 하지만, 대원칙을 살펴보면 하나라도 롤백이 수행되면 전체가 롤백되어야 한다는 것을 알 수 있다.

그렇다면 과연 스프링은 내부에서 롤백이 일어나면 전체를 롤백하게 하는 과정을 어떻게 가능하게 했을까? 우선 테스트에 대한 로그를 확인해보자.

  1. 외부 트랜잭션 시작

  2. 내부 트랜잭션 시작 및 롤백

    내부 트랜잭션은 사실상, 직접적으로 DB에 아무런 작업도 못하는 상태이므로 롤백을 해야한다는 표시를 현재 존재하는 전체 트랜잭션(외부 트랜잭션)에 남겨둔다. 이 표시가 바로 이미지에 보이는 rollback-only 옵션이다.

  3. 외부 트랜잭션 커밋 요청

    따라서, 이후 외부 트랜잭션에서는 커밋을 호출해도 전체 트랜잭션이 롤백 표시가 되어있기 때문에 롤백을 하게 된다. 그리고 UnexpectedRollbackException.class 예외가 발생한다.

☁️ 트랜잭션 전파 동작 과정 : RollbackOnly 옵션

요청 흐름

요청 흐름은 위에서의 동작 과정과 같다.

응답 흐름

  1. 내부 트랜잭션에서 예외가 발생해 롤백 요청을 한다. 트랜잭션 매니저는 신규 트랜잭션이 아니므로 실제 롤백을 하지 않는데, 커밋과 마찬가지로 롤백을 하면 커넥션 자체가 종료되기 때문이다. 대신, 트랜잭션 동기화 매니저에 rollbackOnly 옵션 값을 True 로 설정해둔다.
  2. 외부 트랜잭션에서 커밋 요청을 한다. 트랜잭션 매니저는 신규 트랜잭션이므로 커밋을 호출해야 하는데, 이 전에 먼저 rollbackOnly 설정 여부를 확인한다. 설정이 되어있다면 물리 트랜잭션을 롤백하고 되어있지 않다면 커밋하고 UnexpectedRollbackException 예외를 던진다.

참고로 커밋을 요청했는데 롤백이 된다는 것은 정상적인 흐름이 아니므로, 추가적으로 스프링에서 예외를 던짐으로써 기대하지 않은 롤백이 발생했다는 것을 확실히 알려주는 것이다.

☁️ 트랜잭션 전파 : Requires_New

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 커넥션을 통해 롤백이 이루어진 것을 볼 수 있다.

요청 흐름

  1. 외부 트랜잭션 요청 흐름은 앞서서와 같다.
  2. 내부 트랜잭션을 시작한다. 트랜잭션 매니저는REQUIRES_NEW 옵션을 확인하고, 기존 트랜잭션에 참여하지 않고 새로운 트랜잭션을 시작한다.
  3. 따라서 내부 트랜잭션에서 새롭게 생성된 커넥션은 트랜잭션 동기화 매니저에 저장되고, 이후 로직에서 사용할 때는 새롭게 생성된 커넥션을 사용하게 될 것이다.

응답 흐름

  1. 내부 트랜잭션은 롤백을 요청한다. 트랜잭션 매니저는 신규 트랜잭션이므로 con2 를 사용해서 실제 물리 롤백을 하고, 종료되거나 커넥션 풀에 반환된다.
  2. 외부 트랜잭션은 커밋을 요청한다. 트랜잭션 매니저는 rollbackOnly 설정이 되어있지 않은 것과 신규 트랜잭션임을 확인하고 con1 를 사용해서 실제 물리 커밋을 한다.

주의 사항

하나의 HTTP 요청이 여러 DB 커넥션을 여러개 들고 있기 때문에, 트래픽이 많다면 DB 장애가 일어날 수 있다는 점 참고하자.

☁️ 트랜잭션 전파 옵션

스프링은 다양한 트랜잭션 전파 옵션을 제공하며, 별도의 설정을 하지 않으면 기본 값은 REQUIRED 이다.

REQUIRED

기존 트랜잭션이 없으면 생성하고, 있으면 기존 트랜잭션에 참여한다.

REQUIRES_NEW

항상 새로운 트랜잭션을 생성한다.

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글