[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개의 트랜잭션 격리 레벨을 제공한다.
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
를 보자. 링크