스터디/Spring 스터디

[Spring] Transaction 사용 방법

제이온 (Jayon) 2022. 4. 4.

spring-study에서 스터디를 진행하고 있습니다.

 

Spring에서 트랜잭션을 사용하는 방법

JDBC 트랜잭션

Connection connection = dataSource.getConnection(); // (1) 
try (connection) { 
        connection.setAutoCommit(false); // (2) 
        // execute some SQL statements... 
        connection.commit(); // (3) 
} catch (SQLException e) { 
        connection.rollback(); // (4) 
}

 

  1. 데이터베이스를 사용하려면 연결을 해야 한다. (data-source를 설정했고, 이를 통해 Connection을 가져왔다고 가정)
  2. Java에서 데이터베이스의 트랜잭션을 시작하는 유일한 방법이다. setAutoCommit(false) 는 트랜잭션을 직접 관리할 수 있게 해준다. 즉, 개발자가 원할 때 커밋 또는 롤백할 수 있다.
  3. 커밋을 한다.
  4. 예외가 발생한 경우 롤백을 한다.

 

JDBC 트랜잭션의 단점

  • 트랜잭션을 발생해야 하는 경우, 데이터 접근 기능에서 결코 자유롭지 않다. 즉, 두 개 이상의 DB에 접근해야 하는 작업을 하나의 트랜잭션으로 만들 수 없다.
  • Java Transaction API(JTA)를 이용할 수도 있지만, API를 직접 다루는 것은 의도하지 않은 에러 발생 가능성을 높인다.
  • 기술 환경이 변화함에 따라 코드가 달라질 수 있다.

 

PlatformTransactionManager

  • 스프링에서는 PlatformTransactionManager 인터페이스 추상화를 통해, 해당 인터페이스를 스프링 설정 파일을 거쳐 Bean으로 등록하고, DI를 받아 사용한다.
  • PlatformTransactionManager는 TransactionManager의 최상위 인터페이스로, 인터페이스에 각자의 환경에 맞는 TransactionManager 클래스를 주입한다.
  • 예를 들어, JDBC 및 MyBation 등의 JDBC 기반 라이브러리로 데이터베이스에 접근하는 경우에 DataSourceTransactionManager를 주입하고, 하이버네이트는 HibernateTransactionManager, JPA는 JpaTransactionManager를 주입하면 된다.

 

// PaymentService.java
public class PaymentService {

    private PlatformTransactionManager transactionManager;

    public void setTransactionManager(PlatformTransactionManager transactionManager){
        this.transactionManager = transactionManager;
    }

    public void transactionCode() throws Exception {
        TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            // execute some SQL statements... 
            this.transactionManager.commit(status);
        } catch (RuntimeException e) {
            this.transactionManager.rollback(status);
            throw e;
        }
    }
}

// applicationContext.xml
<bean id="paymentService" class="spring.test.service.PaymentService">
    <property name="paymentDao" ref="paymentDao" />
    <property name="transactionManager" ref="transactionManager" />
</bean>

<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionMager">
    <property name="dataSource" ref="dataSource" />
</bean>

 

@Transactional

public class UserService {

        @Transactional 
        public Long registerUser(User user) {
                // execute some SQL that e.g. 
                // inserts the user into the db and retrieves the autogenerated id 
                // userDao.save(user); 
                return id;
        } 
}

 

  • 일반적으로 많이 사용하는 선언적 트랜잭션 방식이다.
  • @Transactional 어노테이션을 사용하고 싶으면 설정에서 @EnableTransactionManagement를 추가한 뒤, 트랜잭션을 사용하고 싶은 클래스 및 메소드에 @Transactional을 사용하면 된다.
  • 스프링 부트에서는 자동으로 @EnableTransactionManagement 설정이 되어 있다.
  • @Transactional이 있으면 JDBC에서 필요한 코드를 삽입해 준다. (getConnection(), setAutoCommit(false), 메소드 종료 시 커밋, 예외 발생 시 롤백)

 

@Transactional 동작 원리

@Transactional을 이해하려면, Spring AOP에 대한 지식이 있어야 한다.

 

Spring AOP

Spring AOP는 일반적으로 두 가지 방식이 있다.

 

  • JDK Dynamic Proxy
  • CGLIB

 

 

AOP 프록시 생성 과정에서 타겟 객체가 하나 이상의 인터페이스를 구현하고 있는 클래스라면 JDK Dynamic Proxy를 사용하고, 그렇지 않다면 CGLIB를 사용한다.

 

JDK Dynamic Proxy

java.lang에 포함되어 있는 Reflection의 Proxy 클래스가 말 그대로 동적으로 생성한다고 하여 Dynamic Proxy라고 부른다. 타겟의 Interface를 기준으로 Proxy를 생성한다는 것이 Dynamic Proxy의 핵심이라고 할 수 있다.

 

CGLIB (Code Generator Library)

스프링 부트는 CGLIB가 default다. CGLIB는 클래스의 바이트 코드를 조작하여 프록시 객체를 생성하는 라이브러리이다. CGLIB를 사용하여 인터페이스가 아닌 타겟에 대해서 프록시를 생성할 수 있다.

 

동작 과정

@Transactional을 메소드 또는 클래스에 명시하면, AOP를 통해 타겟이 상속하고 있는 인터페이스 또는 타겟을 상속한 프록시 객체가 생성된다. 이때 프록시 객체의 메소드를 호출하면 타겟 메소드 전 후로 트랜잭션 처리를 수행한다.

 

 

  1. Caller에서 AOP 프록시를 탄다. 이때 타겟을 호출하지는 않고, 프록시를 호출한다.
  2. AOP 프록시는 트랜잭션 Advisor를 호출한다. 이 과정에서 커밋이 되거나 롤백이 된다.
  3. Custom Advisor가 있다면, 트랜잭션 Advisor 실행 전후로 동작한다.
  4. Custom Advisor는 타겟 메소드를 호출하여, 비즈니스 로직을 호출한다.
  5. 후에 순서대로 리턴된다.

 

트랜잭션 경계 설정 전략

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

 

선언전 트랜잭션에 경계를 설정할 수 있는 이유는 위에서 설명한 프록시 객체 덕분이다. 트랜잭션은 대부분 그 성격이 비슷하기 때문에 적용 대상 별로 일일이 선언하지 않고 일괄적으로 설정하는 편이 좋다. 따라서, 특정 부가 기능을 임의의 타겟 객체에 부여할 수 있는 AOP가 주로 사용된다.

 

// JPA를 사용한 트랜잭션 코드

//Defalut Propagation : REQUIRED
@Transactional
public void invoke() {
    System.out.println("*** invoke start");
    insert1();
    insert2();
    System.out.println("*** invoke end");
}

//Defalut Propagation : REQUIRED
public void insert1() {
    bookRepository.save(new Book("오브젝트"));
}

//Defalut Propagation : REQUIRED
public void insert2() {
    bookRepository.save(new Book("토비의 스프링"));
}

 

위 코드에서 invoke() 메소드를 호출한 로그는 아래와 같다.

 

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() 메소드에 대한 프록시 메소드 내부이므로, 다음 순서대로 호출 경계가 설정된다.

 

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

 

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

 

트랜잭션 전파

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

A라는 트랜잭션이 시작되고, 트랜잭션 A가 끝나지 않은 시점에서 트랜잭션 B 메소드가 호출된다고 가정해 보자. 그렇다면, B는 어느 트랜잭션에서 동작해야 할까?

 

 

가능한 시나리오는 여러 가지가 있다. 두 가지만 살펴 보자.

 

  1. A라는 트랜잭션이 시작되었고 아직 진행 중이라면, B의 코드는 새로운 트랜잭션을 만들지 않고 A에서 시작된 트랜잭션에 참여할 수 있다. 이러한 경우, B를 호출한 b.method() 까지 마치고 이후 작업에서 예외가 발생한다면 A와 B가 모두 A의 트랜잭션에 하나로 묶여 있으므로 전체가 롤백될 것이다.
  2. 트랜잭션 B가 트랜잭션 A와 별도의 트랜잭션을 만들 수 있다. 이 경우, 트랜잭션 B 경계를 빠져 나가는 순간, B 트랜잭션은 독립적으로 커밋되거나 롤백된다. 트랜잭션 A는 그 영향을 받지 않고 진행될 것이다. A의 (2)에서 예외가 발생하더라도 트랜잭션 A만 롤백되고, 트랜잭션 B에는 아무런 영향이 없을 것이다.

 

주의 사항

A 트랜잭션과 B 트랜잭션은 서로 다른 Service 레이어에 속해야 한다. 만약 한 Service 레이어에서 A 트랜잭션을 실행하고, A 트랜잭션 코드에서 다시 내부 B 트랜잭션을 호출한다면 B 트랜잭션은 트랜잭션이 적용되지 않은 일반 코드가 실행된다. 이것은 Spring AOP의 프록시 성질을 파악하지 못하여 발생하는 문제이다.

 

REQUIRED (기본 값)

 

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

 

REQUIRES_NEW

 

  • 항상 새로운 트랜잭션을 시작하는 방식이다.
  • 앞에 시작된 트랜잭션의 존재 유무와 상관 없이 새로운 트랜잭션을 만들어 독립적으로 동작시킨다. 즉, 독립적인 트랜잭션이 보장되어야 하는 코드에 적용할 수 있다.

 

MANDATORY

 

  • 이미 진행 중인 트랜잭션이 있으면 해당 트랜잭션에 합류한다.
  • 만약 진행 중인 트랜잭션이 없다면 예외를 발생시킨다는 것이 REQUIRED와 다른 점이다.
  • 독립적인 트랜잭션을 생성하면 안되는 경우에 사용한다.

 

NESTED

 

  • 이미 실행 중인 트랜잭션이 존재한다면, 중첩 트랜잭션을 만든다.
  • 중첩 트랜잭션이란 트랜잭션 내부에 다시 트랜잭션을 만드는 것이다. 즉, 부모 트랜잭션에서 새로운 트랜잭션을 내부에 만든다.
  • 중첩 트랜잭션은 부모 트랜잭션의 커밋과 롤백에는 영향을 받지만, 중첩 트랜잭션 자기 자신은 부모 트랜잭션에 영향을 주지 않는다.
  • REQUIRED와 마찬가지로 부모 트랜잭션이 존재하지 않으면 독립적으로 트랜잭션을 생성해서 사용한다.

 

NEVER

 

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

 

SUPPORTS

  • 이미 진행 중인 트랜잭션이 있다면 해당 트랜잭션에 합류한다. 이미 진행 중인 트랜잭션이 없다면 트랜잭션 없이 진행한다.

 

NOT_SUPPORTED

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

 

@Transactional 주의할 점

Inner Method에서의 동작

public class BooksImpl implements Books {

        public void addBooks(List<String> bookNames) {
                bookNames.forEach(bookName -> this.addBook(bookName));
        }

        @Transactional
        public void addBook(String bookName) {
                Book book = new Book(bookName);
                bookRepository.save(book);
                book.setFlag(true);
        }
}

 

위 코드의 문제점은 addBook() 메소드에 @Transactional이 적용되지 않는다는 것이다. 따라서 해당 코드를 실행해도 변경 감지가 동작하지 않아서 DB에는 저장된 book 정보의 Flag 컬럼이 정상적으로 업데이트되지 않는다.

프록시 적용되면 클라이언트는 프록시를 타겟 객체로 생각하고 프록시 메소드를 호출하게 된다. 프록시는 클라이언트로부터 요청을 받으면 타겟 객체의 메소드로 위임하고, 경우에 따라 부가 작업을 추가한다. Trasaction AOP에 의해 추가된 프록시라면, 타겟 객체 메소드 호출 전에 트랜잭션을 시작하고, 호출 후에 트랜잭션을 커밋하거나 롤백을 한다. 즉, 프록시는 클라이언트가 타겟 객체를 호출하는 과정에만 동작하며, 타겟 객체의 메소드가 자기 자신의 다른 메소드를 호출할 때는 프록시가 동작하지 않는다.

위 예제에서 addBook() 메소드는 프록시로 감싸진 메소드가 아니므로 @Transaction 어노테이션이 동작하지 않게 된다.

 

Private Method에서의 동작

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

 

Spring TransactionTemplate

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

 

public class TransactionInvoker {

        private final A1Dao a1dao;
        private final A2Dao a2dao;

        @Transactional
        public void invoke() {
                // 매우 긴 Business Logic ...
                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{
                // Business Logic ...
                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을 생성 및 Trasaction 속성을 설정한다. (물론 생성자 주입을 선택해도 된다.)

 

출처

 

예상 면접 질문 및 답변

선언전 트랜잭션 방식을 사용하는 이유는?

비즈니스 로직이 트랜잭션 처리를 필요로 할 때, 트랜잭션 처리 코드와 비즈니스 로직이 공존한다면 코드 중복이 발생하고 비즈니스 로직에 집중하기 어렵다. 따라서 트랜잭션 처리와 비즈니스 로직을 분리할 수 있는 선언적 트랜잭션 방식을 자주 사용한다.

 

@Transactional의 동작 원리는?

@Transactional을 메소드 또는 클래스에 명시하면 AOP를 통해 Target이 상속하고 있는 인터페이스 또는 Target 객체를 상속한 Proxy 객체가 생성되며, Proxy 객체의 메소드를 호출하면 Target 메소드 전 후로 트랜잭션 처리를 수행한다.

 

@Transactional을 사용할 때 주의할 점은?

  • Proxy 객체의 Target Method가 내부 메소드를 호출하면 실제 메소드가 호출되기 때문에 Inner Method에서 @Transactional 어노테이션이 적용되지 않는 것을 주의해야 한다.
  • @Transactional 어노테이션을 붙이면 트랜잭션 처리를 위해 Proxy 객체를 생성하는데, Proxy는 Target Class를 상속하여 생성된다. 따라서 상속이 불가능한 Private 메소드의 경우 @Transactional 어노테이션을 적용할 수 없다는 것을 주의해야 한다.

 

@Transactional를 스프링 Bean의 메소드 A에 적용하였고, 해당 Bean의 메소드 B가 호출되었을 때, B 메소드 내부에서 A 메소드를 호출하면 어떤 요청 흐름이 발생하는지 설명하라.

프록시는 클라이언트가 타겟 객체를 호출하는 과정에만 동작하며, 타겟 객체의 메소드가 자기 자신의 다른 메소드를 호출할 때는 프록시가 동작하지 않는다. 즉 A 메소드는 프록시로 감싸진 메소드가 아니므로 트랜잭션이 적용되지 않는 일반 코드가 수행된다.

 

A라는 Service 객체의 메소드가 존재하고, 그 메소드 내부에서 로컬 트랜잭션 3개(다른 Service 객체의 트랜잭션 메소드를 호출했다는 의미)가 존재한다고 할 때, @Transactional을 A 메소드에 적용하면 어떤 요청 흐름이 발생하는지 설명하라.

트랜잭션 전파 수준에 따라 달라진다. 만약 기본 옵션인 REQUIRED를 가져간다면 로컬 트랜잭션 3개가 모두 부모 트랜잭션인 A에 합류하여 수행된다. 그래서 부모 트랜잭션이나 로컬 트랜잭션 3개나 모두 같은 트랜잭션이므로 어느 하나의 로직에서든 문제가 발생하면 전부 롤백이 된다.

댓글

추천 글