[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 를 보자. 링크