spring/스프링 DB 2편

9. 스프링 트랜잭션 전파 - 기본

sh1mj1 2023. 2. 24. 13:04
이 글은 배민 기술이사 김영한 이사님의 인프런 강의 "스프링 DB 2편 - 데이터 접근 활용 기술" 을 기반으로 작성되었습니다. 문제 시 삭제 조치하겠습니다.

 

아래와 같은 순서로 공부를 진행합니다.

 

  • 커밋, 롤백
  • 트랜잭션 전파란?
  • 트랜잭션 전파 동작 순서
  • 롤백 트랜잭션 전파 동작 순서
  • 내부 롤백에도 외부에 영향주지 않는 방법
  • 스프링 트랜잭션 전파 옵션 종류

 

스프링에서 트랜잭션은 부모와 자식 사이에 전파가 됩니다. 트랜잭션 전파에서도 여러 전파 수준이 있습니다. 먼저 트랜잭션의 커밋과 롤백을 되짚어보면서 트랜잭션 전파을 알아봅시다.

 

커밋, 롤백

트랜잭션 시작시 Hikari 에서 커넥션(Connection)을 가져오고 커밋을 하면 Hikari 에 커넥션을 반납합니다. 또한 롤백도 마찬가지로 반영된 내용을 모두 롤백하고 Hikari 에 커넥션을 반납합니다.

 

만약 아래 코드처럼 첫번째 트랜잭션이 커밋되고 두번째 트랜잭션이 롤백된다면 트랜잭션 사용은 어떻게 될까요?

 

@Test
void double_commit() {
  log.info("트랜잭션1 시작");
  TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
  log.info("트랜잭션2 커밋");
  txManager.commit(tx1);

  log.info("트랜잭션2 시작");
  TransactionStatus tx2 = TxManager.getTransaction(new DefaultTransactionAttribute());
  log.info("트랜잭션2 롤백");
  txManager.rollback(tx2);
}
  1. 트랜잭션 1에서 먼저 커넥션을 사용하고 커밋 후 커넥션을 반납합니다.
  2. 트랜잭션 2에서 커넥션을 새롭게 사용하고 롤백 후에 반납합니다.

 

테스트 결과

트랜잭션1 시작
Creating new transaction with name [null]: 
PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@1064414847 wrapping conn0] for JDBC transaction
Switching JDBC Connection [HikariProxyConnection@1064414847 wrapping conn0] to  manual commit

트랜잭션1 커밋
Initiating transaction commit
Committing JDBC transaction on Connection [HikariProxyConnection@1064414847 wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@1064414847 wrapping conn0]  after transaction
...2도 동일

로그 메시지를 살펴보면 트랜잭션1과 트랜잭션2 가 같은 conn0 커넥션을 사용 중입니다. 트랜잭션 1은 conn0 커넥션을 모두 사용하고 커넥션 풀에 반납까지 완료했습니다. 이후에 트랜잭션 2는 conn0 을 커넥션 풀에서 휙득한 것입니다.

따라서 이 두 개를 완전히 다른 커넥션으로 인지해야 합니다.

 

Hikari 커넥션 풀에서 커넥션을 휙득하면 실제 커넥션을 그대로 반환하는 것이 아니라 내부 관리를 위해 히카리 프록시 커넥션이라는 객체를 생성해서 반환합니다. 물론 내부에는 실제 커넥션이 포함되어 있습니다. 이 객체의 주소를 확인하면 커넥션 풀에서 휙득한 커넥션을 구분할 수 있습니다.

 

결론적으로 트랜잭션을 각자 관리하기 때문에 이 경우에는 전체 트랜잭션을 묶을 수 없습니다.

 

 

트랜잭션 전파란?

트랜잭션을 각각 사용하는 것이 아니라 트랜잭션이 이미 진행중인 상태에서 추가로 트랜잭션을 추가로 수행하면 어떻게 될까요?

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

즉, 트랜잭션 전파하나의 트랜잭션 내부에 다른 트랜잭션이 있는 경우에 트랜잭션 정책에 따라 어떻게 동작할지 결정하는 것입니다.

 

계속 사용했듯이 스프링은 트랜잭션 범위를 정하기 위해서 추상화된 AOP 인 @Transactional 을 사용하지요. 트랜잭션 내부에 트랜잭션이 존재하는 경우 트랜잭션 전파 수준에 따라 트랜잭션을 새로 만들 수도 있고 롤백 범위도 달라집니다.

 

스프링은 각 로직에 있는 트랜잭션을 논리 트랜잭션,
논리 트랜잭션을 묶어 하나의 물리 트랜잭션이라고 합니다.

 

물리 트랜잭션은 우리가 이해하는 실제 데이터베이스에 적용되는 트랜잭션을 뜻합니다. 실제 커넥션을 통해서 트랜잭션을 시작(setAutoCommit(false))하고, 실제 커넥션을 통해서 커밋, 롤백하는 단위입니다.

 

논리 트랜잭션트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위입니다. 논리 트랜잭션 개념은 트랜잭션이 진행되는 중에 내부에 추가로 트랜잭션을 사용하는 경우에 나타납니다. 물론 단순히 트랜잭션이 하나인 경우 둘을 구분하지는 않습니다.

 

논리 트랜잭션은 아래와 같은 원칙을 따릅니다.

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

 

 

스프링에서는 트랜잭션이 여러 개인 경우, 첫 논리 트랜잭션이 물리 트랜잭션을 시작시키며, 최종 커밋도 합니다.

외부 논리 트랜잭션은 DB 커넥션을 이용해서 커밋을 하며 내부 논리 트랜잭션은 외부 논리 트랜잭션에 참여만 해야 합니다.

 

아래 코드로 확인해봅시다.

 

트랜잭션 전파 동작 순서

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

 

테스트 결과

외부 트랜잭션 시작
Creating new transaction with name [null]: 
PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@1943867171 wrapping conn0] for JDBC transaction
Switching JDBC Connection [HikariProxyConnection@1943867171 wrapping conn0] to manual commit
outer.isNewTransaction()=true

내부 트랜잭션 시작
Participating in existing transaction
inner.isNewTransaction()=false
내부 트랜잭션 커밋

외부 트랜잭션 커밋
Initiating transaction commit
Committing JDBC transaction on Connection [HikariProxyConnection@1943867171

 

외부 트랜잭션이 시작하면서 커넥션을 휙득(Acquired Connection) 및 새로운 트랜잭션을 시작합니다.(outer.isNewTransaction() = true) 처음 시작되는 논리 트랜잭션이 물리 트랜잭션을 시작하기 때문입니다.

 

내부 트랜잭션은 외부 트랜잭션에 참여만 하고(Participating in existing transaction) 새로운 트랜잭션을 시작하지 않습니다.(inner.isNewTransaction() = false)

 

내부 트랜잭션은 외부 트랜잭션에 참여하기 때문에 내부 트랜잭션 커밋 이후에 아무런 커넥션 작업이 발생하지 않습니다.

트랜잭션 전파가 실제 어떻게 동작하는지 그림으로 알아보겠습니다.

 

 

외부 트랜잭션 코드가 실행되고 이어서 내부 트랜잭션 코드가 실행됩니다. 외부 트랜잭션 코드는 이전에 작성했던 스프링 트랜잭션 작동 원리 순서와 동일합니다.

 

내부 트랜잭션의 경우(REQUIRED) 신규 트랜잭션을 시작하지 않고 기존 트랜잭션이 있으면 기존 트랜잭션에 참여합니다. 트랜잭션 동기화 매니저에 외부 트랜잭션 로직을 수행했던 커넥션이 보관되어 있습니다. 이 커넥션을 내부 트랜잭션에서 그대로 사용합니다.

 

 

외부 트랜잭션이 신규 트랜잭션을 처음 시작하므로 물리 트랜잭션을 관리합니다. 내부 트랜잭션의 경우 (REQUIRED) 기존 트랜잭션에 그대로 참여하기 때문에 커밋을 하더라도 물리 트랜잭션을 커밋하지 못합니다.

 

내부 트랜잭션이 모두 완료되고 외부 트랜잭션이 커밋을 할 때 DB 커넥션에 실제 커밋을 호출합니다.

 

롤백 트랜잭션 전파 동작 순서

외부 롤백 트랜잭션 전파 순서

 

외부 트랜잭션이 롤백되면 물리 트랜잭션은 전체 롤백됩니다.

 

외부 트랜잭션 롤백

 

외부 트랜잭션 롤백 흐름

내부 트랜잭션의 경우(REQUIRED) 신규 트랜잭션을 시작하지 않고 기존 트랜잭션에 참여합니다. 또한 내부 트랜잭션이 커밋되어도 신규 트랜잭션에 커밋을 호출하지 않습니다.

 

외부 트랜잭션의 경우 롤백을 요청하면 트랜잭션 매니저는 실제 DB 커넥션에 롤백을 호출합니다. 따라서 실제 데이터베이스에 롤백이 적용되고 트랜잭션을 종료합니다.

 

내부 롤백 트랜잭션 전파 순서

내부 트랜잭션이 롤 백되면 물리 트랜잭션은 전체 롤백됩니다. 단, 이 경우에는 외부 롤백 트랜잭션보다 조금 더 복잡합니다.

 

내부 트랜잭션 롤백

 

내부 트랜잭션 롤백 순서

 

내부 트랜잭션의 경우(REQUIRED) 롤백이 발생하면 트랜잭션 동기화 매니저에 rollbackOnly=true 을 표시합니다. 하지만 기존 트랜잭션에 참여하고 있기 때문에 실제 DB 커넥션에 롤백을 호출하지 않습니다.

 

외부 트랜잭션의 경우 실제 커밋을 호출하기 위해 트랜잭션 동기화 매니저에게 커넥션을 휙득해야 하는데 rollbackOnly=true 로 표시되어 있기 때문에 커밋을 하지 못하고 롤백을 합니다.

 

또한 단순히 롤백할 뿐 아니라 UnexpectedRollbackException 을 던집니다. 시스템 입장에서는 커밋을 호출했지만 롤백이 되었다는 것을 명확히 알려주기 위함입니다.

 

내부 롤백에도 외부에 영향주지 않는 방법

 

REQUIRES_NEW 정책 사용

 

REQUIRES_NEW 정책을 사용할 수 있습니다. 외부와 내부 각각 별도의 트랜잭션이 생성되기 때문에 서로 독립적이므로 롤백이 영향을 주지 않습니다.(Suspending current transaction)

 

기본 트랜잭션 전파 전략의 경우(REQUIRED) 트랜잭션 동기화 매니저에서 1개의 커넥션을 사용하지만, REQUIRES_NEW 정책의 경우 각각 커넥션을 생성하여 총 2개의 커넥션을 사용하므로 서로 영향을 주지 않습니다.

이 때 커넥션을 2개나 사용하므로 커넥션 관리에 주의를 해야 합니다.

 

 

구조 변경

 

기존의 구조를 변경해서 트랜잭션을 묶지 않고 분리합니다.

기존에는 외부 트랜잭션, 내부 트랜잭션을 하나의 물리 트랜잭션으로 묶었지만, 트랜잭션을 묶지 않고 별도의 트랜잭션을 가진 클래스에서 메서드가 호출된다면 물리 트랜잭션이 2개로 분리됩니다. 따라서 다른 커넥션을 사용하며 롤백을 해도 서로 영향을 미치지 않습니다.

 

@Test
void inner_rollback_requires_new() {
     log.info("외부 트랜잭션 시작");
     TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
     log.info("outer.isNewTransaction()={}", outer.isNewTransaction());

     log.info("내부 트랜잭션 시작");
     DefaultTransactionAttribute definition = new DefaultTransactionAttribute();

//전파 옵션인 propagationBehavior 에 PROPAGATION_REQUIRES_NEW 옵션
//내부 트랜잭션을 시작할 때 기존 트랜잭션에 참여하는 것이 아니라 
//새로운 물리 트랜잭션을 만들어서 시작하게 된다.    
    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); //커밋
}

하지만 데이터베이스 커넥션이 동시에 2개 사용된다는 점을 주의해야 합니다.

 

스프링 트랜잭션 전파 옵션 종류

REQUIRED

  • 기존 트랜잭션 없음 : 새로운 트랜잭션을 생성한다.
  • 기존 트랜잭션 있음 : 기존 트랜잭션에 참여한다.

 

REQUIRES_NEW

  • 기존 트랜잭션 없음 : 새로운 트랜잭션을 생성한다.
  • 기존 트랜잭션 있음 : 새로운 트랜잭션을 생성한다.

 

SUPPORT

  • 기존 트랜잭션 없음: 트랜잭션 없이 진행한다.
  • 기존 트랜잭션 있음: 기존 트랜잭션에 참여한다.

 

NOT_SUPPORT

  • 기존 트랜잭션 없음: 트랜잭션 없이 진행한다.
  • 기존 트랜잭션 있음: 트랜잭션 없이 진행한다. (기존 트랜잭션은 보류한다)

 

MANDATORY

  • 기존 트랜잭션 없음: IllegalTransactionStateException 예외 발생
  • 기존 트랜잭션 있음: 기존 트랜잭션에 참여한다.

 

NEVER

  • 기존 트랜잭션 없음: 트랜잭션 없이 진행한다.
  • 기존 트랜잭션 있음: IllegalTransactionStateException 예외 발생

 

NESTED

  • 기존 트랜잭션 없음: 새로운 트랜잭션을 생성한다.
  • 기존 트랜잭션 있음: 중첩 트랜잭션을 만든다.

 

중첩 트랜잭션은 외부 트랜잭션의 영향을 받지만, 중첩 트랜잭션은 외부에 영향을 주지 않는다.

  • 중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋할 수 있다.
  • 외부 트랜잭션이 롤백 되면 중첩 트랜잭션도 함께 롤백된다.

 

정리

핵심은 전파 속성에 따른 롤백 유무 입니다.

중첩된 여러 트랜잭션이 롤백될 때 어떻게 동작하는지 이해하고 설계하는 것이 핵심입니다.

추가로 우아한 형제들 기술 블로그에서 트랜잭션 롤백관련 이슈를 다룬 글이 있으니 읽어보시는 것도 재밌겠네요!
https://techblog.woowahan.com/2606/

다음 글에서는 직접 프로젝트에 적용을 해보면서 스프링 트랜잭션 전파 기술을 활용해보도록 하겠습니다.