[SpringBoot] Transactional

728x90

JDBC 트랜잭션

Spring 에서 JDBC 를 사용하여 트랜잭션을 사용할 수 있다.

// 데이터베이스를 사용하기 위해 연결을 진행
Connection connection = dataSource.getConnection(); 
try (connection) {
    // Java 에서 데이터베이스 트랜잭션을 시작하는 유일한 방법이다. 
    // setAutoCommit(false) 는 트랜잭션을 직접 관리할 수 있게 해준다. 개발자가 원할 대 커밋 또는 롤백이 가능
    connection.setAutoCommit(false); 
    // 쿼리문 작성
    
    connection.commit(); // 커밋을 진행
} catch (SQLException e) {
    connection.rollback(); // 예외가 발생한 경우 롤백
}

위 코드처럼 간단한 케이스라면 상관이 없겠지만 트랜잭션을 여러개 발생해야 하는 경우에 복잡도가 올라간다. 두 개 이상의 DB에 접근해야 하는 작업을 하나의 트랜잭션으로 만들 수가 없다.

@Transactional

public class UserService {
    @Transactional
    public Long registerUser(User user) {
        // 쿼리문 실행
        // 유저 데이터 DB 에 저장
        // userDao.save(user);
        return id;
    }
}

일반적으로 많이 사용되는 선언적 트랜잭션 방식으로 @Transactional 어노테이션을 붙여서 사용한다. 스프링 부트에서는 자동으로 @Transactional 어노테이션 사용 설정인 @EnableTransactionManagement 어노테이션 설정이 되어 있다.

@Transactional 이 있으면 JDBC 에서 필요한 코드를 삽입해준다.(getConnection(), setAutoCommit(false), 메소드 종료 시 커밋, 예외 발생 시 롤백)

트랜잭션 경계 설정 전략

트랜잭션의 시작과 종료는 Service 레이어 내부 메소드에 달려 있다. 트랜잭션의 경계를 설정하는 방법으로는 PlatformTransactionManager 를 사용하여 트랜잭션 코드를 통해 임의로 지정하는 방법과 AOP 를 이용하여 지정하는 방법으로 나뉘며 AOP 를 활용한 @Transactional 어노테이션이 주로 사용된다.

// 기본 전파 속성은 REQUIRED
@Transactional
public void invoke() {
    System.out.println("*** invoke start");
    insert1();
    insert2();
    System.out.println("*** invoke end");
}

// 기본 전파 속성은 REQUIRED
public void insert1() {
    bookRepository.save(new Book("책1"));
}

// 기본 전파 속성은 REQUIRED
public void insert2() {
    bookRepository.save(new Book("책2"));
}
DEBUG JpaTransactionManager : Creating new transaction with name [dev.highright96.springstudy.transaction.BookServiceImpl.invoke]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
DEBUG JpaTransactionManager : Opened new EntityManager [SessionImpl(2076486718<open>)] for JPA transaction
DEBUG JpaTransactionManager : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@351fadfa]
*** invoke start
DEBUG JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(2076486718<open>)] for JPA transaction
DEBUG JpaTransactionManager : Participating in existing transaction
Hibernate: insert into book (isbn, flag, name) values (null, ?, ?)
DEBUG JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(2076486718<open>)] for JPA transaction
DEBUG JpaTransactionManager : Participating in existing transaction
Hibernate: insert into book (isbn, flag, name) values (null, ?, ?)
*** invoke end
DEBUG JpaTransactionManager : Initiating transaction commit
DEBUG JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(2076486718<open>)]
DEBUG JpaTransactionManager : Closing JPA EntityManager [SessionImpl(2076486718<open>)] after transaction

로그를 살펴보면 JpaTransactionManager 가 트랜잭션 관리를 하는 것을 알 수 있다.

  • invoke start
    • invoke() 메소드가 시작되기 전에 DB 커넥션을 얻는다.
  • Participating in existing transaction
    • invoke() 메소드 내에서 insert1() 과 insert2() 에서 실행되는 트랜잭션은 기존 트랜잭션에 참가하며, 기본 트랜잭션 전파 설정은 REQUIRED 이다.
  • invoke end
    • invoke() 메소드가 실행된 이후, 트랜잭션을 커밋하고 커넥션을 반환환다.

트랜잭션이 시작되는 지점은 invoke() 메소드에 대한 프록시 메소드 내부이고, 다음 순서대로 호출 경계가 설정된다.

  1. 프록시 객체 호출
  2. Proxy.invoke() 시작
  3. 트랜잭션 시작
  4. 트랜잭션 전파 설정에 따른 내부 메소드 트랜잭션 처리
  5. 트랜잭션 커밋
  6. Proxy.invoke() 종료

즉, 메소드가 끝날 때까지 커밋 또는 커넥션 반환이 이루어지지 않고 트랜잭션 내부 메소드 내에서 발생하는 SQL 은 동일한 커넥션을 사용한다. 따라서 처리 시간이 긴 메소드의 경우에는 트랜잭션 단위를 조정해서 DB Lock 지속 시간이 지나치게 길어지거나 DB 커넥션 풀의 커넥션 개수가 모자라지 않도록 해야 한다.

트랜잭션 전파

트랜잭션 전파는 임의의 한 트랜잭션이 경계에서 이미 진행 중인 트랜잭션이 존재할 때, 혹은 존재하지 않을 때 동작 방식을 결정하는 설정이다.

A 라는 트랜잭션이 시작되고 트랜잭션 A 가 끝나지 않을 시점에서 트랜잭션 B 메소드가 호출될 때 B 는 어느 트랜잭션에서 동작할까.

A, B 트랜잭션

트랜잭션 전파 설정에 따라 여러 시나리오가 존재하지만 아주 간단하게 두 가지만 확인하도록 한다.

  1. A 라는 트랜잭션이 시작되었고 아진 진행 중인 상태라면 B 는 새로운 트랜잭션을 만들지 않고 A 에서 시작된 트랜잭션에 참여하게 된다. 이 경우 B 를 호출한 B.method() 까지 마치고 이후 작업에서 예외가 발생한다면 A 와 B 가 모두 A 트랜잭션에 하나로 묶여 있기 때문에 전체가 롤백된다.
  2. 트랜잭션 B 와 트랜잭션 A 를 별도의 트랜잭션으로 구분하여 실행시킬 수 있다. 이 경우에 트랜잭션 B 경계를 빠져 나가는 순간, B 트랜잭션은 독립적으로 커밋되거나 롤백된다. 트랜잭션 A 는 B 트랜잭션에 영향을 받지 않고 진행된다. 즉, A 의 (2) 에서 예외가 발생하더라도 트랜잭션 A 만 롤백되고, 트랜잭션 B 는 아무런 영향을 받지 않는다.

여기서 A 트랜잭션과 B 트랜잭션은 서로 다른 Service 레이어에 속해야 한다. 만약 한 Service 레이어에서 A 트랜잭션을 실행하고, A 트랜잭션 코드에서 다시 내부 B 트랜잭션을 실행한다면 B 트랜잭션은 트랜잭션이 적용되지 않은 일반 코드가 실행된다.

REQUIRED(기본값)

가장 많이 사용되는 트랜잭션 전파 속성으로 이미 진행 중인 트랜잭션이 없으면 새로 시작하고 진행 중인 트랜잭션이 있다면 기존 트랜잭션에 참여한다.

즉 위 예시에서 1번 예시에 해당한다.

REQUIRED

A 는 새로운 트랜잭션을 생성하고 B 는 트랜잭션을 생성하지 않고 진행 중인 트랜잭션 즉, A 트랜잭션에 합류한다. 

즉 현재 서비스 레이어에서 실행되는 메소드는 A 라는 하나의 트랜잭션만 존재한다.

REQUIRES_NEW

항상 새로운 트랜잭션을 시작하는 방식으로 이미 진행중인 트랜잭션이 있든 없든 간에 항상 새로운 트랜잭션을 만들어 독립적으로 동작시킨다. 즉, 독립적인 트랜잭션이 보장되어야 하는 코드에 적용할 수 있다.

REQUIRES_NEW

A 트랜잭션이 생성되고, B 트랜잭션이 생성된다. 각각의 트랜잭션은 커밋, 롤백을 따로따로 진행한다.

B 기능이 끝날 때 B 트랜잭션이 커밋되고, A 기능이 끝날 때 A 트랜잭션이 커밋된다.

MANDATORY

이미 진행중인 트랜잭션이 있으면 해당 트랜잭션에 합류하는 방식으로 REQUIRED 와 동일한 방식처럼 보이지만 진행 중인 트랜잭션이 없다면 예외를 발생시킨다는 점이 REQUIRED 와 다르다.

독립적인 트랜잭션을 생성하면 안되는 경우에 사용한다.

NESTED

이미 실행 중인 트랜잭션이 존재할 경우 중첩 트랜잭션을 생성한다. 중첩 트랜잭션이란 트랜잭션 내부에 다시 트랜잭션을 만드는 것으로 부모 트랜잭션에서 새로운 트랜잭션을 내부에 만드는 것이다.

중첩 트랜잭션은 부모 트랜잭션의 커밋과 롤백에는 영향을 받지만 중첩 트랜잭션은 부모 트랜잭션에 영향을 주지 않는다. REQUIRED 와 마찬가지로 부모 트랜잭션, 이미 진행중인 트랜잭션이 존재하지 않을 경우에는 독립적으로 트랜잭션을 생성해서 사용한다.

NESTED

A 라는 부모 트랜잭션이 존재하기 때문에 중첩 트랜잭션 B 를 생성한다. B 트랜잭션이 모두 끝나도 모든 커밋은 부모 트랜잭션인 A 트랜잭션의 끝에서 이뤄진다.

만약 A 트랜잭션에서 예외가 발생해 롤백이 될 경우 B 트랜잭션도 같이 롤백이 된다. 하지만 중첩 트랜잭션인 B 트랜잭션에서 예외가 발생해 롤백이 발생할 경우 해당 롤백을 부모 트랜잭션인 A 트랜잭션에게 전파하지 않는다.

NEVER

트랜잭션을 사용하지 않도록 강제하는 방식으로 트랜잭션을 사용하지 않는 방식이다. NOT_SUPPORTED 와 다른 점은 NOT_SUPPORTED 는 트랜잭션을 무시하고 보류하는 반면에 NEVER 는 트랜잭션이 존재하면 예외를 발생 시킨다.

NEVER

NOT_SUPPORTED

해당 속성을 사용하면 트랜잭션 자체를 무시한다. 즉, 트랜잭션 없이 동작한다는 뜻이다. 트랜잭션의 경계 설정 대부분은 AOP 를 이용하여 여러 메소드를 일괄적으로 적용한다. 따라서 특별한 임의의 메소드 하나에만 트랜잭션을 적용하지 않기 위한 전파 속성이다.

@Transactionl 주의점

@Transactional 어노테이션을 붙이면, 트랜잭션 처리를 위해 빈 객체에 대한 프록시 객체를 생성한다. 이때 프록시 타겟 클래스를 상속하여 생성된다. 따라서 상속이 불가능한 private 메소드의 경우 @Transactional 어노테이션을 붙여도 트랜잭션이 동작하지 않는다.

 

DBMS 의 종류에 따라 DB Lock 지속 시간이나 Read Consistency 의 차이, 그리고 이로 인한 서비스 동시성 문제 등을 생각하면 메소드 단위로 경계가 설정되는 AOP 방식의 트랜잭션이 비효율적일 수 있다. 예를 들어, 실행 시간이 긴 메소드에 AOP 로 트랜잭션을 붙였을 경우 불필요하게 DB 커넥션을 점유하거나 DB Lock 이 유지되는 시간이 길어질 수 있다.

public class TransactionInvoker {
    private final A1Dao a1dao;
    private final A2Dao a2dao;
    
    @Transactionl
    public void invoke() {
        // 긴 Business Login
        doInternalTransaction();
    }
    
    public void doInternalTransaction() {
        a1dao.insertA1();
        a2dao.insertA2();
    }
}

에를 들어, 위와 같은 상황에서는 비즈니스 로직이 트랜잭션에 포함되는 비효율이 발생할 수 있다. 이러한 경우에 개발자가 직접 트랜잭션의 경계를 설정할 필요가 있고, 이 때 TransactionTemplate 이 사용된다.

public class TransactionInvoker {
    private final A1Dao a1dao;
    private final A2Dao a2dao;
    private final TransactionTemplate transactionTemplate;
    
    public void setTransactionManager(PlatformTransactionManager transactionManager) {
        this.transactionTemplate = new TransactionTemplate(transactionManager);
        this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
    }
    
    public void invoke() throws Exception {
        // 비즈니스 로직
        doInternalTransaction();
    }
    
    private void doInternalTransaction() throws Exception {
        transactionTemplate.execute(new TransactionCallbackWithoutResult() {
            public void doInTransactionWithoutResult(TransactionStatus status) {
                try {
                    a1dao.insertA1();
                    a2dao.insertA2();
                } catch (Exception e) {
                    status.setRollbackOnly();
                }
                return;
            }
        }
    }
}

Spring 에서 setter 를 통해 TransactionTemplate 을 주입받는다. 그 후 TransactionTemplate 을 생성 및 Transaction 속성을 설정한다.

728x90