본문 바로가기

Web Sever 개발과 CS 기초/스프링

Spring Transaction 전파 이해하기

관련 내용

해당 프로젝트 깃허브

 

<JDBC, Spring - Transaction 처리에 대한 완벽한 이해>

  • 순수 JDBC만을 사용한 Transaction 사용 방법 알아보기

JDBC를 사용한 Trasaction 처리와 이해

  • 순수 JDBC-Transaction 문제 Spring TransactionManger로 해결하기 - 트랜잭션 템플릿, 트랜잭션 AOP 사용

JDBC Trasaction문제 Spring으로 해결하기

  • Spring Transaction AOP 동작 과정과 주의 사항 알아보기

스프링 Trasaction AOP 동작 과정과 주의 사항

  • (현재 글)Spring Transaction 전파 원리 이해하기

Spring Transaction 전파 이해하기

  • Spring을 사용한 JDBC 예외 누수 문제 해결하기

Spring을 사용한 JDBC 예외 누수 문제 해결하기

  • 자바 예외에 대한 이해

자바 예외에 대한 이해

개요 목적

이번 시간에는 Spring Transaction 전파가 어떻게 동작하는 지,

그리고 트랜잭션 외부와 내부에서 롤백이 발생했을 때 어떻게 처리하는 지 파악해보자.

분리된 트랜잭션(전파x)의 커밋 롤백 동작 과정

해당 글의 모든 소스 코드는 KIMHWANG\springtx\src\test\java\hello\springtx\propagation\BasicTxTest.java 이 곳에 저장되어 있다.

그리고 트랜잭션의 자세한 동작 과정을 담은 로그를 확인하기 위해서는 아래 설정을 추가해줘야 한다.

//application.properties에 추가
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

먼저 트랜잭션의 전파 없이, 하나의 트랜잭션이 완전히 끝나고(커밋되거나, 롤백) 다른 트랜잭션이 시작될 때의 과정을 코드로 살펴보자

@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 시작
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]
//트랜잭션 1이 사용한 커넥션을 완전히 반납한다.
Releasing JDBC Connection [HikariProxyConnection@1064414847 wrapping conn0] 
after transaction

//트랜잭션2 시작
//트랜잭션1 커넥션이 이어지는 것이 아니라 새 커넥션으로 트랜잭션을 시작한다.
Creating new transaction with name [null]: 
PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@778350106 wrapping conn0] for JDBC 
transaction
Switching JDBC Connection [HikariProxyConnection@778350106 wrapping conn0] to 
manual commit
//트랜잭션2 커밋
Initiating transaction commit
Committing JDBC transaction on Connection [HikariProxyConnection@778350106 
wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@778350106 wrapping conn0] 
after transaction

스프링 트랜잭션 전파 원리 이해하기 - 기본 옵션(REQUIRED)

트랜잭션 전파란, 트랜잭션이 이미 진행 중일 때, 추가 트랜잭션을 수행하면 어떻게 동작할 지 결정하는 것을 의미한다. 전파 종류에는 기존 트랜잭션과 별도로 추가 트랜잭션 실행하기나, 기존 트랜잭션을 이어 받아서 진행하기 등이 있을 수 있다.

이번 챕터에서는 트랜잭션 전파의 기본 옵션인 REQUIRED(기존 트랜잭션 이어받기)에 대해서 원리를 알아보자.

기본적인 동작 과정. 하나의 물리 트랜잭션과 여러 개의 논리 트랜잭션으로 만들기

  • 외부 트랜잭션이 수행 중인데(아직 commit이나 rollback 안 함) 내부 트랜잭션이 시작된다.
    • 외부 트랜잭션은 처음 시작된 트랜잭션을 의미한다.(나중 트랜잭션 전파 좀 있다 나오는최종 커밋에서 중요한 역할을 한다.)
  • 스프링은 두 트랜잭션을 논리 트랜잭션으로 개념을 설정하고
  • 두 논리 트랜잭션을 물리 트랜잭션이라는 개념으로 묶는다.
    • 물리적 트랜잭션은 실제 데이터 베이스에 적용되는 트랜잭션이다. 실제 커넥션을 통해서, 트랜잭션 시작(setAutoCommit(false))하고 커밋과 롤백을 하는 단위이다.

논리적 트랜잭션 개념을 도입해서 만들 수 있는 원칙

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

실제 코드를 통해서 본 트랜잭션 전파 확인하기

위 챕터에서는 트랜잭션 전파가 개념적으로 어떻게 이루어지는 지 확인했다. 이번에는 실제 코드를 통해서 하나의 트랜잭션이 끝나지 않았는데, 새로운 트랜잭션이 시작하면 어떤 과정을 거치는 지 확인해보자.

@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);
}
//결과 확인
outer.isNewTransaction()=true

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

내부 트랜잭션을 시작할 때 Participating in existing transaction 이라는 메시지를 확인할 수있다.

이 메시지는 내부 트랜잭션이 기존에 존재하는 외부 트랜잭션에 참여한다는 뜻이다.

그리고 내부 트랜잭션의 isNewTransaction()을 확인했을 때 처음으로 시작하는 트랜잭션이 아님도 확인할 수 있다.

<트랜잭션 전파의 흐름 따라가기>

요청 흐름 - 외부 트랜잭션

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

요청 흐름 - 내부 트랜잭션

  • txManager.getTransaction() 를 호출해서 내부 트랜잭션을 시작한다.
  • 트랜잭션 매니저는 트랜잭션 동기화 매니저를 통해서 기존 트랜잭션이 존재하는지 확인한다.
  • 기존 트랜잭션이 존재하므로 기존 트랜잭션에 참여한다. (이미 기존 트랜잭션인 외부 트랜잭션에서 물리 트랜잭션을 시작했다. 그리고 물리 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 담아두었다. 따라서 이미 물리 트랜잭션이 진행중이므로 그냥 두면 이후 로직이 기존에 시작된 트랜잭션을 자연스럽게 사용하게 되는 것이다.)
  • 트랜잭션 매니저는 트랜잭션을 생성한 결과를 TransactionStatus 에 담아서 반환하는데, 여기에서 isNewTransaction 를 통해 신규 트랜잭션 여부를 확인할 수 있다. 여기서는 기존 트랜잭션에 참여했기 때문에 신규 트랜잭션이 아니다. ( false )
  • 로직2가 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 외부 트랜잭션이 보관한커넥션을 획득해서 사용한다.

응답 흐름 - 내부 트랜잭션

  • 로직2가 끝나고 트랜잭션 매니저를 통해 내부 트랜잭션을 커밋한다.
  • 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 이 경우 신규 트랜잭션이 아니기 때문에 실제 커밋을 호출하지 않는다. 이 부분이 중요한데, 실제 커넥션에 커밋이나 롤백을 호출하면 물리 트랜잭션이 끝나버린다. 아직 트랜잭션이 끝난 것이 아니기 때문에 실제 커밋을 호출하면 안된다. 물리 트랜잭션은 외부 트랜잭션을 종료할 때 까지 이어져야한다

응답 흐름 - 외부 트랜잭션

  • 로직1이 끝나고 트랜잭션 매니저를 통해 외부 트랜잭션을 커밋한다.
  • 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다. 외부 트랜잭션은 신규 트랜잭션이다. 따라서 DB 커넥션에 실제 커밋을 호출한다.
  • 트랜잭션 매니저에 커밋하는 것이 논리적인 커밋이라면, 실제 커넥션에 커밋하는 것을 물리 커밋이라 할 수 있다. 실제 데이터베이스에 커밋이 반영되고, 물리 트랜잭션도 끝난다.

핵심

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

신규 트랜잭션이 아니면 실제 물리 커넥션을 사용하지 않는다. 그래서 트랜잭션을 생성할 때, TransactionStatus에 isNewTransaction()의 값을 담아서 신규 트랜잭션을 표시해둔 것이다.

여기서는 외부 트랜잭션이 커밋되어야 물리 트랜잭션(실제 트랜잭션이 커밋된다.)

추가로, 코드를 통해서, 내부 트랜잭션 커밋 부분을 주석처리해도, 외부 트랜잭션 커밋만 확인된다면 실제 트랜잭션 커밋이 성공하는 것을 알 수 있다.

//log.info("내부 트랜잭션 커밋");
//txManager.commit(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
--------------------------------------------------------------------------------
Initiating transaction commit

외부 롤백 트랜잭션 상황 코드로 확인하기 (내부 커밋, 외부 롤백)

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

결과를 먼저 살펴보면, 논리 트랜잭션이 하나라도 롤백되면, 전체 물리 트랜잭션은 롤백된다.

@Test
void outer_rollback() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new
        DefaultTransactionAttribute());
    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new
        DefaultTransactionAttribute());
    log.info("내부 트랜잭션 커밋");
    txManager.commit(inner);
    
    //내부 트랜잭션은 커밋되었는데, 외부는 롤백
    //->결과적으로 물리 트랜잭션이 롤백된다.
    log.info("외부 트랜잭션 롤백");
    txManager.rollback(outer);
}
//결과 확인
내부 트랜잭션 커밋

외부 트랜잭션 롤백
//롤백 된 것을 확인할 수 있다. 
Initiating transaction rollback
Rolling back JDBC transaction on Connection [HikariProxyConnection@461376017 
wrapping conn0]

내부 롤백 트랜잭션 상황 코드로 확인하기 (외부 커밋, 내부 롤백)

이번 상황은 외부 롤백 상황보다 좀 더 유의 깊게 지켜봐야 한다. 왜냐하면, 초기 트랜잭션(외부 트랜잭션)이 커밋되는 것에 반응해서, 물리 트랜잭션이 커밋된다는 것을 트랜잭션 전파 예시에서 확인했다.

그런데, 물리 트랜잭션에 영향을 주지 않는 내부 트랜잭션이 롤백되면, 물리 트랜잭션 자체가 롤백되는 결과가 나온다. 어떻게 이런 상황이 나오는 지 알아보자.

@Test
void inner_rollback() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new
        DefaultTransactionAttribute());
    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new
        DefaultTransactionAttribute());
    log.info("내부 트랜잭션 롤백");
    txManager.rollback(inner);
    
		log.info("외부 트랜잭션 커밋");
    //외부 트랜잭션이 커밋될 때, 예외가 발생하는 것을 유의 깊게 보자.
    assertThatThrownBy(() -> txManager.commit(outer))
        .isInstanceOf(UnexpectedRollbackException.class);
}
//결과 확인
내부 트랜잭션 롤백
Participating transaction failed - marking existing transaction as rollbackonly
Setting JDBC transaction [HikariProxyConnection@220038608 wrapping conn0] 
rollback-only

외부 트랜잭션 커밋
Global transaction is marked as rollback-only but transactional code requested 
commit
Initiating transaction rollback
Rolling back JDBC transaction on Connection [HikariProxyConnection@220038608 
wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@220038608 wrapping conn0] 
after transaction
  • 내부 트랜잭션을 롤백하면 실제 물리 트랜잭션은 롤백하지 않는다. 그러나 대신에 기존 트랜잭션을 롤백 전용으로 표시한다.(Participating transaction failed - marking existing transaction as rollbackonly) 내부 트랜잭션은 물리 트랜잭션을 롤백하지 않는 대신에 트랜잭션 동기화 매니저에 rollbackOnly=true 라는 표시를 해둔다
  • 외부 트랜잭션 커밋을 호출 했지만, 전체 트랜잭션이 롤백 전용으로 표시되어 있다. 따라서 물리 트랜잭션을 롤백한다.(Global transaction is marked as rollback-only)

외부, 내부 트랜잭션을 완전히 분리하는 전파 옵션(REQUIRES_NEW) 사용해보기

이번에는 외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 사용하는 방법에 대해서 알아보자. 이 방법 사용하면, 내부 트랜잭션에 문제가 발생해서 롤백해도, 외부 트랜잭션에는 영향을 주지 않는다. 반대로 외부 트랜잭션에 문제가 발생해도 내부 트랜잭션에 영향을 주지 않는다.

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

    //내부 트랜잭션을 시작할 때 전파 옵션인 propagationBehavior 에 PROPAGATION_REQUIRES_NEW
    //옵션을 주었다.
    //기존 트랜잭션에 참여하는 것이 아니라 새로운 물리 트랜잭션을 만들어서 시작하게 된다.
    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); //커밋
}
//사용 결과
내부 트랜잭션 롤백
Initiating transaction rollback
Rolling back JDBC transaction on Connection [HikariProxyConnection@778350106 
wrapping conn1]
Releasing JDBC Connection [HikariProxyConnection@778350106 wrapping conn1] 
after transaction
Resuming suspended transaction after completion of inner transaction
외부 트랜잭션 커밋
Initiating transaction commit

이 옵션을 설저하여, 외부와 내부 트랜잭션이 서로 다른 커넥션을 획득하는 것을 확인할 수 있고, 내부 트랜잭션이 롤백되어도, 외부 트랜잭션은 커밋되는 것을 확인할 수 있다.

스프링 트랜잭션의 다양한 전파 옵션

스프링은 다양한 트랜잭션 전파 옵션을 제공한다. 전파 옵션에 별도의 설정을 하지 않으면 REQUIRED 가 기본으로 사용된다. 참고로 실무에서는 대부분 REQUIRED 옵션을 사용한다. 그리고 아주 가끔 REQUIRES_NEW 을 사용하고, 나머지는 거의 사용하지 않는다. 그래서 나머지 옵션은 이런 것이 있다는 정도로만 알아두고 필요할 때 찾아보자.