[210224] 자바의 트랜잭션

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.setSavepoint 는 Savepoint 객체를 현재 트랙잭션 안에 설정한다. 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.releaseSavepoint 는 Savepoint 객체를 인자로 받아서 현재 트랜잭션에서 제거한다. 어느 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 을 이용해도 동일한 트랜잭션이 수행되도록 한다.

즉, HibernateTransactionManager 는 UserService 의 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: 2025-04-26 Sat 03:49

Emacs 27.2 (Org mode 9.4.4)

Validate