본문 바로가기
Spring|Spring-boot

Spring Transaction Propagation

by oncerun 2022. 10. 16.
반응형

트랜잭션이 둘 이상인 경우 트랜잭션이 어떻게 동작하는지 알아보자.

여러 트랜잭션이 순차적으로 시작되고 종료되는 경우

@Test
void sequenceTx() {

    TransactionStatus firstTx = txManager.getTransaction(new DefaultTransactionDefinition());
    txManager.commit(firstTx);

    TransactionStatus secondTx = txManager.getTransaction(new DefaultTransactionDefinition());
    txManager.commit(secondTx);

}

테스트의 로그를 보면 생각한 순서대로 진행된다.

트랜잭션 시작함에 있어서 커넥션을 커넥션 풀에서 획득하고 커밋하고 커넥션을 릴리즈한다.
이 과정이 두 번 반복된다.

로그상으로 동일 "conn0" 커넥션을 사용하는 것으로 보이지만 커넥션 풀의 사용으로 그렇게 보이는 것으로 다른 커넥션으로 보아야 한다.

스프링의 기본 커넥션 풀로 사용되는 히카리 커넥션 풀은 실제 커넥션을 주는 것이 아니라 히카리 프록시 커넥션을 생성해서 반환한다.
물론 내부에는 원본 커넥션에 대한 참조는 포함할 것이다.

로그로 구분하기 위해선 다음과 같은 부분의 주소를 보면 구분되는 것을 확인할 수 있다.

Acquired Connection [HikariProxyConnection@311687383 wrapping conn0:
Acquired Connection [HikariProxyConnection@1345362299 wrapping conn0:

이는 첫 번째 트랜잭션에서 사용된 커넥션을 두 번째 트랜잭션이 재사용된 것이다.

트랜잭션의 중첩 구조

하나의 트랜잭션이라고 하는 것은 기본적으로 동일 커넥션을 사용한다는 것을 의미한다.

그럼 트랜잭션 중간에 새로운 트랜잭션의 작업이 시작되는 경우 어떻게 될까?

  1. 기존 트랜잭션에 포함되어 진행된다.
  2. 별도의 트랜잭션이 시작된다.

각각의 경우 둘 다 사용해야 할 경우가 있을 것이다. 내부 작업에 의해 외부 작업까지 동일한 결과를 만들어야 한다면 기존 트랜잭션에 포함되어야 할 것이고 결과를 위해 동일한 트랜잭션 안에서 서로 다른 트랜잭션이 시작되어야 하지만 각 결과에 대해 독립적인 결과를 가져야 한다면 별도의 트랜잭션이 시작되어야 할 것이다.

이러한 동작을 결정하기 위해 사용되는 것이 Transaction Propagation이라고 한다.

Transaction Propagation에는 7가지의 속성을 가지고 있다.

  • REQUIRED (default)
  • REQUIREDSNEW
  • MADATORY
  • NOTSUPPORTED
  • SUPPORTS
  • NEVER
  • and NESTED

기본 속성은 REQUIRED로 다음의 내용은 기본 속성이라는 전제가 있다.

트랜잭션에 대한 이야기가 아닌 스프링 트랜잭션이 다중 구조의 트랜잭션을 만났을 때 어떻게 행동하는지에 초점을 두고 내용을 말하기에 매우 깊은 이야기는 아닐 것이다.

스프링은 외부 트랜잭션이 종료되지 않은 경우에 또 다른 트랜잭션이 시작되는 경우에는 두 개의 트랜잭션을 묶어 하나의 트랜잭션으로 만든다.

추가적으로 스프링 트랜잭션에 대한 용어를 짚고 넘어가야 이해가 더욱 쉬울 것이다.

1.Local Transaction
2.Global Transaction
3.Physical Transaction
4.Logical Transaction

1차적인 목표는 논리적, 물리적 트랜잭션들의 차이점과 전파 설정이 이 차이점에 적용되는 방식을 이해하는 것이다.

global, local transaction에 대한 설명은 다음 url을 확인하자
global, local Transaction

logical, physical Trnascation

논리 트랜잭션들은 하나의 물리 트랜잭션으로 묶인다.

여기서 말하는 물리 트랜잭션은 실제 데이터베이스에 적용되는 트랜잭션을 의미한다.
즉 트랜잭션의 시작하고 커넥션을 통해 커밋, 롤백하는 하나의 단위라고 설명한다.

또한 논리 트랜잭션은 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위이다.
이런 논리 트랜잭션 개념은 트랜잭션의 중첩 구조(required)에서 나타난다.

이는 트랜잭션이 사용 중일 때 또 다른 트랜잭션이 개입되어 사용될 때 논리 트랜잭션 개념을 사용하면 단순한 원칙을 만들 수 있기에 분리한 것이다.

원칙

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

어떠한 설정도 하지 않은 스프링의 중첩 구조의 트랜잭션 기본 동작을 로그로 확인해보자.

@Test
void inner_commit() {
    log.info("outer tx start");
    TransactionStatus outerTx = txManager.getTransaction(new DefaultTransactionDefinition());

    innerTxStart();

    log.info("outer tx commit");
    txManager.commit(outerTx);
}

private void innerTxStart() {
    log.info("inner tx start");
    TransactionStatus innerTx = txManager.getTransaction(new DefaultTransactionDefinition());
    log.info("inner tx is new? = {}", innerTx.isNewTransaction());

    log.info("inner tx commit");
    txManager.commit(innerTx);
}

내부 트랜잭션이 새로운 트랜잭션인지 확인하는 isNewTransaction()의 값이 false인 것으로 보아 외부 트랜잭션의 범위가 내부 트랜잭션을 포함하는 하나의 트랜잭션으로 실행된 것을 확인할 수 있다.

로그 상에서 "Participating in existing transaction"을 보면 기존 트랜잭션에 참여한다는 로그도 확인할 수 있다.

더욱 신기한 것은 내부 트랜잭션을 커밋한 경우 어떠한 로그를 남기지 않는데, 이는 내부 트랜잭션 커밋에 대해 작업을 하지 않는다.

스프링은 중첩된 트랜잭션 경우 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 한다.

이 과정을 보면 각 트랜잭션 매니저의 TrasactionStatus을 통해 내부 프로세스가 달라진다는 것을 알 수 있다.

트랜잭션 중첩 구조에서 내부 트랜잭션의 롤백

처음 시작된 외부 트랜잭션이 물리 트랜잭션을 관리하기 때문에 외부 트랜잭션이 커밋하면 내부 트랜잭션의 롤백하였다 해도 커밋이 될 것 같다.

또한 내부 트랜잭션 매니저의 행동은 물리 트랜잭션에 영향을 주지 않기 때문에 롤백하였다 해도 그 작업이 무시될 가능성이 있어 보인다.

이러한 복잡한 상황을 어떻게 해결해야 할까?

@Test
void inner_rollback() {
    log.info("outer tx start");
    TransactionStatus outerTx = txManager.getTransaction(new DefaultTransactionDefinition());
    innerTxRollback();
    log.info("outer tx commit");
    txManager.commit(outerTx);
}

테스트를 진행한 로그에는 예외가 발생한다.

Global transaction is marked as rollback-only but transactional code requested commit
exception :
org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only

이 경우 다음과 같은 로그를 확인할 수 있다.
Participating transaction failed - marking existing transaction as rollback-only

트랜잭션의 참여 실패로 rollback-only라는 마킹을 한다고 한다.

이 테스트의 결과는 커밋이 아닌 롤백이다.

이 흐름은 다음과 같다.

내부 트랜잭션의 매니저는 신규 트랜잭션이 아니기 때문에 실제 롤백을 호출하지 않는다.
아직 외부 트랜잭션이 남아있어 지속되어야 하는데 만약 실제 롤백을 호출하면 물리 트랜잭션이 종료되기 때문이다.

대신 트랜잭션 동기화 매니저에 rollbackOnly=true라는 표시를 해둔다.

이후 외부 트랜잭션 매니저는 트랜잭션 동기화 매니저의 rollbackOnly 속성 값을 확인하여 true일 경우 요청 작업이 commit이라도 rollback 한다.

commit을 기대한 작업에 대해 rollback 되었기 때문에 런타임 예외를 던진다.
해당 런타임 예외가 UnexpectedRollbackException이다.

만약 이를 확인해야 한다면 외부 트랜잭션의 TransactionStatus의 isRollbackOnly() 메서드를 통해 확인할 수 있다.

REQUIRES_NEW

이전에 대한 이야기는 기본적으로 전파 옵션이 REQUIRED라는 가정하에 설명되었다.

REQUIRES_NEW 속성은 외부 트랜잭션과 내부 트랜잭션을 별도의 물리 트랜잭션으로 사용하는 방법이다. 따라서 커밋과 롤백도 각각 별도로 이루어진다.

이는 트랜잭션의 영향 범위가 독립적으로 정해지기 때문에 내부 트랜잭션에서의 문제가 발생하여 롤백 시 외부 트랜잭션에 영향을 주지 않고 그 역도 성립한다.

private void innerTxRollback_REQUIRES_NEW() {

    DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
    definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);

    log.info("inner tx start");
    TransactionStatus innerTx = txManager.getTransaction(definition);

    log.info("inner tx is new? = {}", innerTx.isNewTransaction());

    log.info("is rollbackOnly mark ={}", innerTx.isRollbackOnly());
    
    log.info("inner tx rollback");
    txManager.rollback(innerTx);
}

다음과 같이 내부 트랜잭션의 propagation 속성 값을 REQUIRES_NEW로 지정하여 트랜잭션 매니저에게 전달하여 사용하면 별도의 트랜잭션 영역을 사용하게 된다.

@Test
void inner_rollback_required_new() {
    log.info("outer tx start");
    TransactionStatus outerTx = txManager.getTransaction(new DefaultTransactionDefinition());

    innerTxRollback_REQUIRES_NEW();

    log.info("outer tx commit");
    log.info("is rollbackOnly mark ={}", outerTx.isRollbackOnly());
    txManager.commit(outerTx);
}

해당 테스트의 로그 중 내부 트랜잭션 처리 부분을 살펴보자.

실제로 새로운 트랜잭션이 시작되고 롤백하고 커넥션을 반납하는 과정까지 포함되어 있다.

다만 REQUIRES_NEW 속성을 사용할 때는 어쩔 수 없이 오버헤드가 발생한다.
오버헤드는 성능에 영향을 미치기 때문에 기존 하나의 트랜잭션에서 여러 개로 분리하려는 경우에 여러 테스트를 진행하고 진행해야 한다.

만약 성능적인 이슈가 발생한다면 데이터베이스 커넥션을 확인하는 것이 좋다.

트랜잭션을 분리한다는 것은 결국 커넥션을 여러 개 사용한다는 의미로 하나의 요청에 대해 여러 커넥션 리소스를 점유하기 때문인데, 이러한 경우 다른 방법을 사용하는 것이 좋다.

다양한 propagation option

다양한 전파 옵션에 대해 알아보자.

전파 옵션의 기본 값은 REQUIRED이고 대부분 REQUIRED 옵션을 사용하긴 한다. 다만 특별한 상황에 REQURIES_NEW, NOTSUPPORT를 고려하는 것 같다.

  1. REQUIRED
    가장 많이 사용하는 기본 설정으로 트랜잭션이 없으면 생성하고 있다면 참여한다.
  2. REQUIRES_NEW
    항상 새로운 트랜잭션을 생성하여 별도의 범위를 갖게 한다.
  3. SUPPORT
    트랜잭션을 지원한다는 뜻으로 기존 트랜잭션이 없으면, 없는 대로 진행하고, 있으면 참여한다.
  4. NOT_SUPPORT
    트랜잭션을 지원하지 않는다는 의미다. 다만 기존 트랜잭션이 있다면 보류한 후 자신의 행동이 끝난 이후 재개된다.
  1. MANDATORY
    강한 의무로 트랜잭션이 반드시 있어야 한다는 뜻으로 기존 트랜잭션이 없으면 IllegalTransactionStateException 예외가 발생한다. 기존 트랜잭션이 존재하면 참여한다.
  2. NEVER
    트랜잭션을 사용하지 않는다. 기존 트랜잭션이 있다면 IllegalTransactionStateException 예외가 발생한다.
  3. NESTED
    기존 트랜잭션이 없으면 새로운 트랜잭션을 생성하고 기존 트랜잭션이 있으면 중첩 트랜잭션을 만든다. 중첩 트랜잭션은 외부 트랜잭션의 영향을 주지 않는다.
    이는 중첩 트랜잭션이 롤백되어도 외부 트랜잭션은 커밋이 가능하고, 반대로 외부 트랜잭션이 롤백되면 중첩 트랜잭션도 함께 롤백된다. (JDBC savepoint 기능을 사용하며, 드라이버의 기능 지원 여부를 확인해야 하고, JPA에서는 사용할 수 없다.)

추가적으로 isolation, timeout, readOnly는 트랜잭션 처음 시작될 때만 적용된다.
트랜잭션에 참여하는 경우에는 적용되지 않는다.

https://docs.spring.io/spring-framework/docs/

 

Index of /spring-framework/docs

 

docs.spring.io

 

반응형

댓글