자바의 트랜잭션

Table of Contents

1 JDBC에서 의 트랜잭션

내가 속한 회사에서 clojure를 쓰고 있지만 jdbc를 쓰고 있으므로, 공유차 jdbc connection 에서 transaction 설정을 어떻게 하는지 정리했다. (다 까먹었기에 다시 확인차 하는 것도 있다.) 공식문서를 그대로 참고 및 요약했다.

1.1 auto-commit 모드 끄기

Mysql과 연결된 커넥션에 auto-commit 을 false로 바꾸면 하나의 SQL statusment들을 그룹지어서 실행할 수 있게 된다.

con.setAutoCommit(false);

1.2 트랜잭션 커밋하기

con.commit() 을 수행하면 끝난다. 아래코드는 공식문서 예시다.

public void updateCoffeeSales(HashMap<String, Integer> salesForWeek) throws SQLException {
  String updateString = 
    "update COFFEES set SALES = ? where COF_NAME = ?";
  String updateStatement = 
    "update COFFEES set TOTAL = TOTAL + ? where COF_NAME = ?";

  try (PreparedStatement updateSales = con.PreparedStatement(updateString);
       PreparedStatement updateTotal = con.PreparedStatement(updateStatement))
  {
    con.setAutoCommit(false);  // 주목!
    for (Map.Entry<String, Integer> e : salesForWeek.entrySet()) {
      updateSales.setInt(1, e.getValue().intValue());
      updateSales.setString(2, e.getKey());
      updateSales.executeUpdate();  // 커밋은 되지 않는다.

      updateTotal.setInt(1, e.getValue().intValue());
      updateTotal.setString(2, e.getKey());
      updateTotal.executeUpdate();  // 커밋은 되지 않는다.

      con.commit();  // 여기서 커밋이 진행된다.
    }
  } catch (SQLException e) {
    JDBCtutorialUtilities.printSQLException(e);
    if (con != null) {
      try {
	System.err.print("Transaction is being rolled back");
	con.rollback();
      } catch (SQLException excep) {
	JDBCtutorialUtilities.printSQLException(excep);
      }
    }
  }
}

1.3 트랜잭션 격리규칙

JDBC의 Connection 인터페이스는 5개의 트랜잭션 격리 레벨을 제공한다.

transaction-isolation-level.png

Figure 1: Java Transaction

  • TRANSACTION_READ_COMMITTED : 커밋될 때까지는 해당 값을 읽을 수 없다. (dirty read 허용X)
  • dirty read : 업데이트는 수행했지만 커밋이 안된 경우, 이것을 읽는 것이 dirty read이다. (롤백이 될 수 있음에도 읽는 것이니까)
  • non-repeatable read : transaction A가 row를 조회할 때, transaction B가 이어서 해당 row를 update 했다면 transaction A는 나중에 같은 row를 다시 조회한다. 즉, Transaction A는 같은 row조회를 두번하였지만 서로 다른 두 값을 접하게 된다.
  • phantom read : transaction A가 주어진 조건에 만족하는 row집합을 조회할 때, transaction B가 이어서 해당 조건에 만족하는 값을 insert, update한다. 이 경우 transaction A는 나중에 주어진 조건으로 다시 조회한다. transaction A는 이제 추가된 row가 보이게 된다. 이 추가된 row를 phantom 이라고 부른다.

디폴트 transaction isolation level은 내가 사용하는 DBMS에 의존한다. Java DB는 TRANSACTION_READ_COMMITTED 가 디폴트다. JDBC는 사용하는 DBMS에서 transaction isolation level이 무엇인지 조회하는 메소드를 제공한다. (Connection method getTransactionIsolation ) 그리고 이것을 지정할 수도 있다. (Connection method setTrasactionIsolation).

중요한 점은 JDBC 드라이버가 모든 transaction isolation level을 지원하지 않을 수 있다는 점이다. 만약 setTrasactionIsolation 을 호출할 때, 해당 레벨을 지원하지 않으면 더 높은 레벨의 제약으로 선택할 수도 있다. 그리고 만약에 대체할 만한 (더 제약이 높은) 트랙젝션 레벨이 없다면, SQLException 을 던진다. DatabaseMetaData.supportsTransactionIsolationLevel 을 사용해서 지원하는 것들을 확인하자.

1.4 SavePoints 설정하기

Connection.setSavepointSavepoint 객체를 현재 트랙잭션 안에 설정한다. Connection.rollback 메소드에 SavePoint 인자를 받을 수 있도록 오버로딩 되어있다.

아래 메소드 CoffeesTable.modifyPricesByPercentage 는 퍼센트로 특정 커피의 가격을 올린다.( PriceModifer ). 하지만 새로운 가격이 지정된 가격( maximumPrice )을 넘으면, 기존 가격으로 되돌린다(reverted).

public void modifyPricesByPercentage(String coffeeName, float priceModifier, float maximumPrice) throws SQLException{
  String priceQuery = "SELECT COF_NAME, PRICE FROM COFFEES WHERE OF_NAME = ?";
  String updateQuery = "UPDATE COFFEES SET PRICE = ? WHERE COF_NAME = ?";

  try (PreparedStatement getPrice = con.PreparedStatement(priceQuery, ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY);
       PreparedStatement updatePrice = con.PreparedStatement(updateQuery)) 
  {
    Savepoint save1 = con.setSavePoint();  // save point!
    getPrice.setString(1, coffeeName);
    if (!getPrice.execute()) {
      System.out.println("Could not find entry for coffee name " + coffeeName); // 못찾음.
    } else {
      rs = getPrice.getResultSet();
      rs.first()
      float oldPrice = rs.getFloat("PRICE");
      float newPrice = oldPrice + (oldPrice * priceModifier);
      System.out.printf("Old price of %s is $%.2f%n", coffeeName, oldPrice);
      System.out.printf("New price of %s is $%.2f%n", coffeeName, newPrice);
      System.out.println("Performing update...");
      updatePrice.setFloat(1, newPrice);
      updatePrice.setString(2, coffeeName);
      updatePrice.executeUpdate();
      System.out.println("\nCOFFEES table after update:");
      CoffeesTable.viewTable(con);
      if (newPrice > maximumPrice) {
      System.out.printf("The new price, $%.2f, is greater " +
			"than the maximum price, $%.2f. " +
			"Rolling back the transaction...%n",
			newPrice, maximumPrice);
      con.rollback(save1);  // save point로 롤백한다.
      System.out.println("\nCOFFEES table after rollback:");
      CoffeesTable.viewTable(con);
    }
    con.commit();
    }
  }
}

예제코드를 그대로 가져왔다. 다 넘어가고 알아야 할 점은 아래와 같다.

Savepoint save1 = con.setSavepoint();
con.rollback(save1);

Savepoint를 사용하면, SavePoint로 롤백된 부분은 커밋되지 않는다. 다른 모든 갱신된 행은 커밋된다.

Connection.releaseSavepointSavepoint 객체를 인자로 받아서 현재 트랜잭션에서 제거한다. 어느 savepoint라도 트랜잭션에서 생성되면 트랜재겻ㄴ이 커밋될 때, 자동 릴리즈된다. savepoint로 롤백하면 또한 자동 릴리즈 된다.

2 Spring Transaction Management

2.1 트랜잭션 걸기

@Transactional(propagation=TransactionDefinition.NESTED, 
	       isolation=TransactionDefinition.ISOLATION_READ_UNCOMMITTED)

이것은 아래 코드처럼 된다고 생각해보자.

import java.sql.Connection;

// isolation=TransactionDefinition.ISOLATION_READ_UNCOMMITTED
connection.setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED); 

// propagation=TransactionDefinition.NESTED
Savepoint savePoint = connection.setSavePoint();
...
connection.rollback(savePoint)

2.2 CGlib & JDK Proxy [WIP]

Spring으로는 위에 설명한 것 같이 connection code를 추가하기 위해 우리의 기존 자바 클래스를 다시 쓸 수는 없다. (bytecode weaving을 사용할 수 있지만 지금은 무시)

다음 코드를 보자

@Configuration
@EnableTransactionManagement
public class MySpringConfig {
  @Bean
  public PlatformTransactionManager txManager() {
    // 요렇게 트랜잭션매니저를 지정할 수 있다?!
    return yourTxManager; // more on that later
  }
}

public class UserService {
  public Long registerUser(User user) {
    Connection connection = dataSource.getConnection(); // (1)
    try (connection) {
      connection.setAutoCommit(false); // (1)

      // execute some SQL that e.g.
      // inserts the user into the db and retrieves the autogenerated id
      // userDao.save(user); <(2)

      connection.commit(); // (1)
    } catch (SQLException e) {
      connection.rollback(); // (1)
    }
  }
}

스프링은 저절로 내 서비스 코드에 저런 코드를 심어주는 것이다. 그런데 스프링은 이런 코드를 추가하기 위해서 우리 코드를 임의로 다시 쓸 수는 없다. 하지만 스프링은 IoC Container라는 장점이 있다!

이게 무슨말인가? @Autowired 에 곧바로 초기화한 객체를 넣는 것이 아니라, IoC 컨테이너로 Bean등록한 객체를 Spring이 주입해준다는 말이다.

Spring은 여기서 트릭을 사용한다. Bean주입을 할 때 그냥 UserService 를 넣는 것이 아니다. 바로 UserService 의 프록시(트랜잭션을 다루는)를 주입하는 것이다.

이것은 Cglib 라이브러리의 proxy-through-subclassing 라는 메서드를 호출하여 이것이 가능하게 한다. 물론 Dynamic JDK proxy 를 이용할 수도 있다. 하지만 스프링은 Cglib 를 사용하므로 넘어가자.

이렇게 만들어진 프록시는 다음과 같은 일을 한다.

  • Opening and closing database connections/transactions. DB 커넥션(트랜잭션)을 열고 닫는다.
  • And then delegating to the real UserService, the one you wrote. 쓰기를 시작할 때 실제 UserService 에게 위임한다.
  • And other beans, like your UserRestController will never know that they are talking to a proxy, and not the real thing. UserRestController 같이 다른 빈은 UserService 가 Proxy인지 아닌지 절대 모른다.

2.2.1 코드로 자세히 이해하자

@Configuration
@EnableTransactionManagement
public static class MyAppConfig {
  @Bean
  public UserService userService() {  // (1)
    return new UserService();
  }

  @Bean
  public DataSource dataSource() {
      return new MysqlDataSource(); // (1)
  }

  @Bean
  public PlatformTransactionManager txManager() {
      return new DataSourceTransactionManager(dataSource()); // (2)
  }
}

여기서 DataSourceTransactionManager 의 코드를 잠시 구경하자.

public class DataSourceTransactionManager implements PlatformTransactionManager {
  @Override
  protected void doBegin(Object transaction, TransactionDefinition definition) {
    Connection newCon = obtainDataSource().getConnection();
    // ... 
    con.setAutoCommit(false);
    // 요게 핵심이다.
  }

  @Override
  protected void doCommit(DefaultTransactionStatus status) {
    // ...
    Connection connection = status.getTransaction().getConnectionHolder().getConnection();
    try {
      con.commit();
    } catch (SQLException ex) {
      throw new TransactionSystemException("Could not commit JDBC transaction", ex);
    }
  }
}

이 코드만 봐도 save 수행 전에는 doBegin , 정상적으로 수행된 후 doCommit 이 수행될 것을 알 수 있다.

2.2.2 physical and logical transaction 물리/논리 트랜잭션

@Service
public class UserService {
  @Autowired
  private InvoiceService invoiceService;

  @Transactional
  public void invoice() {
    invoiceService.createPdf();
    // send invoice as email, etc.
  }
}

@Service
public class InvoiceService {
  @Transactional
  public void createPdf() {
    // ...
  }
}

UserService의 invoice 는 트랜잭션 어노테이션을 갖고있다. 이제 UserService.invoice()를 수행하면 setAutoCommit(false) , commit() 과 같은 함수들로 감싸질 것이다. 이것을 스프링은 physical transaction 이라고 한다.

하지만 스프링관점에서 보면 InvoiceService.createPdf() 에 또다른 트랜잭션 어노테이션이 있음을 알 수 있다. 스프링은 똑똑하게도 이 두 @Transactional 어노테이션을 확인 후 동일한 물리 트랜잭션을 써야함을 인지한다.

어떻게 이런 것이 가능한가? 다음처럼 propagation을 바꾸면 어떻게 될까?

@Service
public class InvoiceService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createPdf() {
	// ...
    }
}

REQUIRES_NEW 를 붙이면 스프링은 createPDF() 는 수행할 때 자신만의 트랜잭션이 있어야 한다는 의미다. (이미 수행되고 있는 트랜잭션과는 독립적으로!)

이 말은 즉, 각각 물리적인 커넥션과 트랜잭션을 갖게된다는 것이다. (2개의 getConnection, setAutoCommit(false), commit()) 스프링은 이렇게 코드적으로 둘로 트랜잭션이 나뉘어 있지도 않지만? 논리적인 조각( invoice(), createPdf() )을 서로다른 물리 트랜잭션으로 매핑할 수 있다는 것이다.

정리하자면

  • Physical Transactions: Are your actual JDBC transactions. 실제 JDBC 트랜잭션
  • Logical Transactions: Are the (potentially nested) @Transactional-annotated (Spring) methods. 어노테이션 단위를 논리 트랜잭션 단위라고 한다.

2.2.3 @Transactional propagation level 사용하기

  • Required (default): My method needs a transaction, either open one for me or use an existing one → getConnection(). setAutocommit(false). commit().
  • Supports: I don't really care if a transaction is open or not, i can work either way → nothing to do with JDBC
  • Mandatory: I'm not going to open up a transaction myself, but I'm going to cry if no one else opened one up → nothing to do with JDBC
  • Requirenew: I want my completely own transaction → getConnection(). setAutocommit(false). commit().
  • NotSupported: I really don't like transactions, I will even try and suspend a current, running transaction → nothing to do with JDBC
  • Never: I'm going to cry if someone else started up a transaction → nothing to do with JDBC
  • Nested: It sounds so complicated, but we are just talking savepoints! → connection.setSavepoint()

2.3 How Spring and JPA / Hibernate Transaction Management works

Rewriting the UserService from before to Hibernate would look like this: UserService를 이전에서 Hibernate로 다시 쓰면 다음처럼 보인다.

public class UserService {
  @Autowired
  private SessionFactory sessionFactory; // (1) 하이버네이트 객체, 모든 쿼리의 엔트리 포인트

  public void registerUser(User user) {

    Session session = sessionFactory.openSession(); // (2) DB 커넥션

    // lets open up a transaction. remember setAutocommit(false)!
    session.beginTransaction();

    // save == insert our objects
    session.save(user);

    // and commit it
    session.getTransaction().commit();

    // close the session == our jdbc connection
    session.close();
  }
}
  • 하이버네이트는 Spring의 @Transaction 어노테이션을 모른다.
  • 스프링은 하이버네이트의 트랜잭션에 대해서 모른다.

하지만 우리는 이 둘이 잘 연결되기를 바란다. 아래코드로 연결을 해보자.

@Service
public class UserService {
  @Autowired
  private SessionFactory sessionFactory; // 위에서 본 녀석

  @Transactional
  public void registerUser(User user) {
    sessionFactory.getCurrentSession().save(user); // 위랑은 다르게 이거 하나로 끝낸다고?
  }
}

위 코드와는 다르게 수동으로 커밋이나 클로즈하는 코드가 없다. 어떻게 된 걸까?

2.3.1 HibernateTransactionManager를 사용하자

설정에 DataSourcePlatformTransactionManager 를 쓰지말고, HibernateTransactionManager 을 쓰거나(하이버네이트만 쓴다면) JpaTransactionManager 를 쓴다.(JPA를 통한 Hibernate를 사용)

HibernateTransactionManager

  • 트랜잭션을 Hibernate를 통해서 수행한다. (SessionFactory이용)
  • 그냥 @Transaction 을 이용해도 동일한 트랜잭션이 수행되도록 한다.

즉, HibernateTransactionManagerUserService 의 Proxy를 만들 것이다. 그리고 이 Proxy는

sf.startTx() , syncHibernateAndJdbc(ds) 를 저장하기 전에 수행하며, sf.close(), desyncHibernateAndJdbc(ds) 를 저장이 끝나고 수행한다.

좀더 알고 싶다면 PlatformTransactionManager 를 보자. 링크

3 clojure next.jdbc의 트랜잭션

4 참고문헌

Date: 2021-02-24 Wed 00:00

Author: Younghwan Nam

Created: 2022-09-14 Wed 01:26

Emacs 27.2 (Org mode 9.4.4)

Validate