Spring

물리 트랜잭션과 논리 트랜잭션, 그리고 트랜잭션 전파에 관하여

teo_99 2023. 10. 9. 23:07

트랜잭션 경계

여러 작업을 하나의 논리적인 단위로 묶고 싶을 때 트랜잭션을 사용합니다. 트랜잭션은 시작하는 방법은 하나이지만 끝나는 방법은 두 개가 존재합니다. 하나는 롤백(rollback)이며, 다른 하나는 커밋(commit)입니다.

Connection connection = dataSource.getConnection();
connection.setAutoCommit(false); // 트랜잭션 경계 시작점
doSomeThing();
connection.commit(); // 트랜잭션 경계 끝점

그리고 트랜잭션을 시작하고 끝내는 작업을 설정하는 걸 '트랜잭션 경계설정'이라고 하고, 트랜잭션 경계 내에서 논리적으로 묶이길 원하는 로직들이 실행되게 됩니다.

 

물리 트랜잭션과 논리 트랜잭션

스프링에서는 트랜잭션을 실제 데이터베이스의 트랜잭션과 동일하게 다루지 않습니다. 논리 트랜잭션이라는 추가적인 메커니즘을 통해 트랜잭션을 다루게 됩니다. 

 

실제 데이터베이스의 트랜잭션과 상응하는 개념을 물리 트랜잭션이라고 하며, 스프링의 트랜잭션 관리 메커니즘인 PlatformTransactionManager에 의해 관리되는 트랜잭션 단위를 논리 트랜잭션이라고 합니다.

 

 보통 스프링 어플리케이션을 구축하는 경우 AOP를 활용한 선언적 트랜잭션(@Transactional)을 사용합니다. 하지만 @Transactional이 붙었다고 해서 모두 동일하게 물리적 트랜잭션으로 바라본다면 모호한 상황이 생깁니다. 

// UserService.java
@Transactional
public void saveUser() {
	// do something
    mailingService.mail();
}

// MailingService.java
@Transactional
public void mail() {
	// do something
}

스프링을 사용하는 개발자가  1. 유저를 저장하는 비즈니스 로직과 2. 메일을 보내는 로직을 하나의 물리 트랜잭션에서 수행하기를 바란다고 가정해 봅시다.

 

해당 시나리오에서 @Transactional이 물리적인 트랜잭션만을 제공한다면 유저를 저장하는 로직과 메일을 보내는 로직을 하나의 원자적인 단위로 묶을 수 없고 물리 트랜잭션이 두 개 생기게 됩니다. 물론 어노테이션 속성 값 등을 다르게 해서 '물리 트랜잭션이 아님'을 명시할 순 있겠지만, 이는 스프링의 구현 복잡도뿐만 아니라 비즈니스 로직을 작성하는 개발자 입장에서 신경 써야 할 지점이 많아지게 됩니다.

 

또한 같은 기능을 하는 메소드가 독자적으로 사용될 때는 트랜잭션을 지원해야 하고, 다른 메소드에서 호출되어 사용될 때는 트랜잭션을 지원하지 않아야 한다면 불필요한 코드 중복이 생겨나게 됩니다. 트랜잭션이라는 데이터 구현 기술 때문에 비즈니스 로직의 중복이 생겨나게 되는 것입니다.

 

따라서 스프링은 물리 트랜잭션을 그대로 적용하지 않고 논리 트랜잭션이라는 개념을 사용합니다. 어플리케이션 코드와 실제 데이터 구현 기술 사이에 하나의 계층을 더 둠으로써 유연성, 성능 등의 측면에서 이점을 가져갈 수 있게 합니다. 스프링 프레임워크는 단순히 트랜잭션이 선언된 부분에 대해서는 논리적 트랜잭션을 적용하기만 하면 됩니다. 

그리고 스프링에서의 논리 트랜잭션은 다음 두 조건을 만족하는데, 이렇게 단순한 원칙을 세움으로써 여러 개의 트랜잭션을 다루는 경우에 대한 처리가 상당히 간단해졌습니다.

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

 

트랜잭션 전파(Transaction Propagation)

논리 트랜잭션 개념을 적용함으로써 스프링은 트랜잭션 처리를 단순화할 수 있었지만 이슈가 한 가지 생깁니다. 바로 '여러 논리 트랜잭션이 중첩되는 경우 어떻게 처리할 것인지'입니다. 이 경우 각각의 트랜잭션 경계를 설정하는 방법이 필요하게 됩니다.

 

스프링은 트랜잭션 전파를 통해 이 문제를 해결합니다. 스프링 개발자는 트랜잭션 전파 속성을 통해 논리 트랜잭션들의 관계를 설정할 수 있습니다.

 

앞서 잠깐 언급했듯이 스프링은 트랜잭션 관리 메커니즘으로 PlatformTransactionManger라는 인터페이스를 사용합니다. 그리고 getTransaction의 시그니쳐를 보시면 TransactionDefinition이라는 객체를 받는데, 해당 객체 내부에 트랜잭션 전파 속성에 대한 내용이 담겨 있습니다.

public interface PlatformTransactionManager extends TransactionManager {

	TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
			throws TransactionException;

	void commit(TransactionStatus status) throws TransactionException;


	void rollback(TransactionStatus status) throws TransactionException;

}
public interface TransactionDefinition {

	int PROPAGATION_REQUIRED = 0;

	int PROPAGATION_SUPPORTS = 1;

	int PROPAGATION_MANDATORY = 2;

	int PROPAGATION_REQUIRES_NEW = 3;

	int PROPAGATION_NOT_SUPPORTED = 4;

	int PROPAGATION_NEVER = 5;

	int PROPAGATION_NESTED = 6;
    
    // ...
}

일반적으로 스프링 어플리케이션을 개발하는 입장에서 PlatformTransactionManager들의 구현체들을 직접 다룰 일은 거의 없습니다.

Spring AOP 기술이 접목된 선언형 트랜잭션(@Transactional)을 사용하는 경우가 많고, 이 경우에는 단순히 어노테이션 속성을 지정해 줌으로써 트랜잭션 전파 속성을 변경할 수 있습니다. 

AOP 기술을 활용하지 않고 직접 PlatformTransactionManager를 사용하는 경우, 이를 '프로그램에 의한 트랜잭션'이라고 합니다. (programmatic transaction)

 

참고로 @Transactional 어노테이션을 사용하는 경우 속성 값은 RuleBasedTransactionAttribute라는 객체로 변환되는데, 이는 TransactionDefinition의 서브타입입니다. 저희가 어노테이션에 작성한 트랜잭션 속성 값이 PlatformTransactionManager의 커넥션 획득 인자로 사용되는 것입니다.

 

실습은 아래 코드를 기준으로 진행됩니다. UserService의 addUser 메소드에서 ProfileService의 saveProfile 메소드를 호출하는 경우입니다. saveProfile 메소드의 트랜잭션 전파 속성을 바꿔가면서 실습을 진행합니다.

// UserService.java 
@Transactional
public void addUser() {
    log.info("addUser() 호출!");

    userRepository.save(new User("account", "password", "email"));

    log.info("트랜잭션명: {}", TransactionSynchronizationManager.getCurrentTransactionName());
    log.info("물리 트랜잭션 실행 여부: {}", TransactionSynchronizationManager.isActualTransactionActive());
    
    profileService.saveProfile();
}
// ProfileService.java
@Transactional(propagation = Propagation.SUPPORTS)
public void saveProfile() {
    log.info("saveProfile() 호출!");

    profileRepository.save(new Profile("profile"));

    log.info("트랜잭션명: {}", TransactionSynchronizationManager.getCurrentTransactionName());
    log.info("물리 트랜잭션 실행 여부: {}", TransactionSynchronizationManager.isActualTransactionActive());
}

 

REQUIRED(기본)

@Transactional은 Propagation 속성을 지정해주지 않는 경우 REQUIRED로 작동합니다. REQUIRED는 다음의 몇 가지 특징을 가집니다.

  • 기존에 진행 중인 트랜잭션이 있다면 참여한다.
  • 없다면 새로 생성한다.

여기서 참여한다는 의미는 트랜잭션을 그대로 이어간다는 말과 동일합니다. UserService의 addUser를 실행했을 때 나오는 로그는 다음과 같습니다. 기존에 이미 진행 중이던 논리 트랜잭션(addUser)이 있으므로 참여하게 되고 새로운 트랜잭션을 생성하지는 않습니다.

addUser의 @Transactional을 제거하는 경우에는 기존에 진행중이던 트랜잭션이 존재하지 않으므로, REQUIRED 속성에 따라 새로운 트랜잭션을 생성하게 됩니다. 로그를 보시면 아시다시피 UserService에서는 트랜잭션이 없고, ProfileService에서는 saveProfile 메소드를 기준으로 하나의 트랜잭션을 생성하게 됩니다.

 

 

 

REQUIRES_NEW

REQUIRES_NEW는 다음과 같은 특징을 가집니다.

  • 항상 새로운 트랜잭션을 만든다.
  • 기존에 진행 중인 트랜잭션이 있다면 보류(suspend) 처리한다.

새로운 트랜잭션을 만든다는 것은 물리적 트랜잭션을 새로 하나 더 만든다는 것이며, DB 커넥션 자체를 두 개 소모한다는 말과 동일합니다. 그리고 기존에 진행중인 트랜잭션은 새로운 트랜잭션이 끝나야 작업을 이어갈 수 있으므로 자연스레 보류가 됩니다.

실행 로그를 보면 각각의 메소드가 다른 트랜잭션을 사용하고 있는 것을 알 수 있습니다. 또한 두 트랜잭션은 완전히 물리적으로도 다른 트랜잭션이기에 내부 트랜잭션이 롤백되어도 외부 트랜잭션은 영향을 받지 않습니다.

 

SUPPORTS

SUPPORTS는 다음과 같은 특징을 가집니다.

  • 외부 트랜잭션이 있다면 참여한다.
  • 없다면 트랜잭션 없이 수행한다.

외부 트랜잭션이 있는 경우
외부 트랜잭션이 없는 경우

 

 

MANDATORY

MANDATORY는 다음과 같은 특징을 가집니다.

  • 외부 트랜잭션이 있다면 참여한다.
  • 없다면 예외를 던진다.

외부 트랜잭션이 있는 경우
외부 트랜잭션이 없는 경우

 

NEVER

NEVER는 다음과 같은 특징을 가집니다.

  • 외부 트랜잭션이 있다면 예외를 던진다.
  • 없다면 트랜잭션 없이 수행한다.

외부 트랜잭션이 있는 경우
외부 트랜잭션이 없는 경우

 

NOT_SUPPORTED

NOT_SUPPORTED는 다음과 같은 특징을 가집니다.

  • 외부 트랜잭션이 있다면 보류(suspend) 처리하고 트랜잭션 없이 수행한다.
  • 없다면 트랜잭션 없이 수행한다.

외부 트랜잭션이 있는 경우
외부 트랜잭션이 없는 경우

 

NESTED

NESTED는 조금 독특한 특성을 가지는데, 다음과 같습니다.

  • 외부 트랜잭션이 있다면 새로운 중첩(nested) 트랜잭션을 생성한다.
    • 중첩 트랜잭션으로 작동하는 경우에는 롤백이 외부 트랜잭션까지 영향을 미치지 않는다.
    • 반면 외부 트랜잭션의 롤백은 중첩 트랜잭션에 영향을 미친다. 
  • 없다면 새로 생성한다.

메일을 보내는 로직이 실패했다고 해서 회원가입까지 롤백되기를 원하지 않는 경우 등에서 NESTED를 사용할 수 있습니다. 하지만 이 기능은 JDBC의 savepoint 기능을 사용하기 때문에 JPA 등에서는 사용이 불가능합니다. 만약 실행하게 되면 아래와 같은 오류가 뜹니다.

NESTED는 롤백 상황을 제외하고는 디폴트 속성인 REQUIRED와 똑같이 동작한다는 점 참고해 주시면 좋을 것 같습니다.

 

롤백 상황에서는 어떻게 대응할까?

마지막으로 롤백에 대한 이야기를 잠깐 하도록 하겠습니다. 논리 트랜잭션이 여러 개 중첩되는 경우에는 롤백을 어떤 기준으로 해야 할까요? 

 

참여한 트랜잭션이 롤백이 되어야 한다면 앞서 설명한 논리 트랜잭션의 기본 원칙에 따라(하나의 논리 트랜잭션이 롤백되면 물리 트랜잭션은 롤백된다) 외부 트랜잭션도 롤백이 되어야 합니다. 다만 이 경우 내부 트랜잭션(참여한 트랜잭션)에서 바로 롤백을 진행하는 게 아니라 RollbackOnly라는 마킹만 해주게 됩니다. 내부 트랜잭션에서 롤백을 해버리면 커넥션이 종료되어버리기 때문입니다.

public interface TransactionExecution {
    /**
     * Set the transaction rollback-only. This instructs the transaction manager
     * that the only possible outcome of the transaction may be a rollback, as
     * alternative to throwing an exception which would in turn trigger a rollback.
     */
    void setRollbackOnly();
    
    // ...
}

PlatformTransactionManager는 TransactionStatus라는 인자를 통해 트랜잭션의 상태를 관리하는데, TransactionStatus의 슈퍼타입인 TransactionExecution을 보면 다음과 같이 setRollback라는 메소드가 존재합니다. 해당 메소드를 통해 트랜잭션이 rollback 되어야 함을 마킹하게 되고, 트랜잭션 매니저가 데이터베이스에 커밋을 하려는 시점에 해당 마킹을 보고 롤백을 진행하게 됩니다.

 

PlatformTransactionManager의 구현체인 AbstractPlatformTransactionManager의 processRollback 메소드를 보시면 아래와 같이 참여한 트랜잭션이라면 rollbackOnly를 마킹하는 로직이 존재합니다.

public abstract class AbstractPlatformTransactionManager implements PlatformTransactionManager, Serializable {

    private void processRollback(DefaultTransactionStatus status, boolean unexpected) {
        // ...
        // Participating in larger transaction
        if (status.hasTransaction()) {
            if (status.isLocalRollbackOnly() || isGlobalRollbackOnParticipationFailure()) {
                if (status.isDebug()) {
                    logger.debug("Participating transaction failed - marking existing transaction as rollback-only");
                }
                doSetRollbackOnly(status);
            }
        // ...
    }
}

그리고 AbstractPlatformTransactionManager의 commit 메소드에서는 롤백 마킹이 되었는지 확인해서 만약 마킹이 존재한다면 커밋을 진행하지 않고 롤백을 진행합니다.

public abstract class AbstractPlatformTransactionManager implements PlatformTransactionManager, Serializable {

    @Override
    public final void commit(TransactionStatus status) throws TransactionException {
        if (status.isCompleted()) {
            throw new IllegalTransactionStateException(
                    "Transaction is already completed - do not call commit or rollback more than once per transaction");
        }

        DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
        if (defStatus.isLocalRollbackOnly()) {
            if (defStatus.isDebug()) {
                logger.debug("Transactional code has requested rollback");
            }
            processRollback(defStatus, false);
            return;
        }
        // ...
    }
}

 

마치며

물리 트랜잭션, 논리 트랜잭션의 개념과 여러 개의 논리 트랜잭션을 다루기 위해 사용하는 방법인 트랜잭션 전파, 그리고 롤백을 어떻게 수행하는지에 대해 알아보았습니다. 

 

감사합니다.

 

참고 자료

토비의 스프링 vol. 1

JavaDocs - Propagation

우아한기술블로그 - 응? 이게 왜 롤백되는거지?

https://mangkyu.tistory.com/269