JDBC 트랜잭션에 대한 이해
Transaction 기본 동작 원리를 살펴보고자.
궁극적으로는 @Transactional 이 어떻게 돌아가는지를 살펴보겠지만, 우선은 그것보다도 중요한 JDBC에서 트랜잭션을 사용하는 방법에 대해서 이해하고 넘어갈 것이다.
사실 Spring의 @Transactional 애노테이션, JPA(Hibernate), JOOQ 등을 쓰면 라이브러리 내에서 관리되므로 아래와 같은 문제는 일어날 일이 없다.
앞서 언급한대로 원활한 이해를 위해 아래 JDBC가 트랜잭션을 다루는 코드를 살펴보자.
import java.sql.Connection;
Connection connection = dataSource.getConnection(); // (1)
try (connection) {
connection.setAutoCommit(false); // (2)
// execute some SQL statements...
connection.commit(); // (3)
} catch (SQLException e) {
connection.rollback(); // (4)
}
- 데이터베이스를 사용하기 위해선 연결부터 해야한다. (data-source 설정했고 data-source를 통해서 Connection을 가져왔다고 가정.)
- 자바에서 데이터베이스의 트랜잭션을 사용하는 유일한 방법이다.
- setAutoCommit(true)는 모든 SQL statement를 래핑한다.(JDBC 라이브러리 룰에 따라 코드에 명시하지 않아도 트랜잭션의 커밋, 롤백이 자동으로 이루어진다.)
- setAutoCommit(false)는 이와 반대로 트랜잭션의 주인은 내가 된다. 즉, 제어를 개발자가 하고 개발자가 원할 때 커밋 또는 롤백한다.
- commit을 한다.
- 또는 에러가 발생했을 때 롤백을 한다.
이것이 JDBC의 기본적인 트랜잭션 방법이자, Spring의 @Transactional 의 전부이다.
위에서 언급한대로 '자바에서 트랜잭션을 시작하는 유일한 방법'이기 때문에 Spring의 @Transactional 도 똑같이 동작한다.
JDBC isolation levels & savepoints
Spring의 @Transactional 을 사용하면 아래와 같은 코드를 주로 보게된다.
@Transactional(propagation=TransactionDefinition.NESTED,
isolation=TransactionDefinition.ISOLATION_READ_UNCOMMITTED)
그리고 위와 같이 Spring의 @Tansactional에서 격리 레벨과 savepoints를 파라미터로 설정하는 방식을 JDBC 코드로 정리하면 아래와 같다.
import java.sql.Connection;
// isolation=TransactionDefinition.ISOLATION_READ_UNCOMMITTED
connection.setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED); // (1)
// propagation=TransactionDefinition.NESTED
Savepoint savePoint = connection.setSavepoint(); // (2)
...
connection.rollback(savePoint);
- 트랜잭션의 고립 레벨을 지정하는 방법
- NESTED 전파옵션의 트랜잭션은 데이터베이스의 savepoints로 동작
위에서 봤듯이 JDBC를 이용한 Transaction isolation, propagation처리를 하는 것을, 스프링에서는 직접 JDBC와 같은 코드를 개발자가 사용하지 않고 편리하게 사용할 수 있도록 다양한 방법을 제공한다.
Spring Transaction Management의 동작 방식
그렇다면 @Transactional이 아닌 JDBC와 같이 Spring에서 코드적으로 트랜잭션을 관리하는 방법은 무엇일까? 물론 해당 방법은 주로 사용되는 방법은 아니다.
@Service
public class UserService {
@Autowired
private TransactionTemplate template;
public Long registerUser(User user) {
Long id = template.execute(status -> {
// SQL 실행
// ex) inserts the user into the db and returns the autogenerated id
return id;
});
}
}
위와 같이 TransactionTemplate을 사용하거나 직접 PlatformTransactionManager를 이용하면 된다.
- JDBC 예제와의 비교
- 데이터베이스 커넥션(Connection)을 직접 열고 닫을 필요가 없다. 대신에 트랜잭션 콜백을 사용한다.
- SQLExceptions를 처리할 필요가 없다. 스프링이 알아서 RuntimeException으로 변환해준다.
- 스프링 환경에 더 적절하고, TransactionTemplate은 내부적으로 PlatformTransactionManager를 사용한다.
모든 것이 Spring context configuration에서 지정해야하는 빈(Bean)들이지만, 나중에 수정할 필요가 없다.
다시 말하지만 프로그래밍적인 방식은 잘 사용을 안 한다. @Transactinal을 이해하기 위한 사전 자료라고 생각하고 가볍게 읽고 넘어가면 된다. XML방식도 있는데 이 역시도 잘 안 쓰는 방식이라 생략한다.
@Transactional annotation
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;
}
}
위 방식이 우리가 주로 사용하는 방식인 선언적 트랜잭션 방식이다.
- Spring Configuration에 @EnableTransactionManagement 애노테이션을 붙인다. (스프링 부트에서는 자동으로 해준다.)
- Spring Configuration에 트랜잭션 매니저를 지정한다.
- 그러면 스프링은 @Transactional 애노테이션이 달린 public 메서드에 대해서 내부적으로 데이터베이스 트랜잭션 코드를 실행해준다.
@Configuration
@EnableTransactionManagement
public class MySpringConfig {
@Bean
public PlatformTransactionManager txManager() {
return yourTxManager; // more on that later
}
}
위의 설정을 해주면 @Transactional이 쓰인 UserService 코드는 내부적으로 아래와 같이 변환된다.
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)
}
}
}
- @Transactional 이 있으면 JDBC에서 필요한 코드를 자동 삽입해준다. Connection 가져오고, setAutoCommit(false)해주고, 메소드 끝나면 커밋, 예외 발생하면 롤백!
- 비지니스 로직
그렇다면 여기서 궁금증이 생긴다. 스프링은 이 코드를 어떻게 넣는 것일까?
스프링이 Transaction 코드를 넣는 방법
실제로 스프링이 내가 작성한 자바 코드에 추가로 재 작성할 수는 없다. (바이트 코드 위빙 같은 고급기술이 아닌 이상...)
대신에 스프링은 IoC Container의 역할을 활용한다. (Bean을 만들고 연결(autowire)하는 방법)
즉, @Transcational이 포함된 UserService를 인스턴스화할 때 UserService의 트랜잭션 '프록시'도 같이 인스턴스화한다.
이때는 CGlib 라이브러리의 도움을 받아 프록시를 통하는 방식을 통해서 마치 코드를 넣는 것 처럼 동작하게 한다.
내가 작성한 public 메소드 앞/뒤에 JDBC 코드를 프록시 객체에 직접 넣는 것은 아니다.
모든 트랜잭션(open, commit, close)를 처리하는 것은 프록시 자체에서가 아니라 트랜잭션 매니저에 위임하여 처리하는 것이다.
모든 트랜잭션 매니저는 "doBegin" 또는 "doCommit" 같은 메소드를 가진다.
단순화해서 표현하면 아래와 같다.
public class DataSourceTransactionManager implements PlatformTransactionManager {
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
Connection newCon = obtainDataSource().getConnection();
// ...
newCon.setAutoCommit(false);
// yes, that's it!
}
@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);
}
}
}
- 스프링은 @Transactional 애노테이션을 발견하면 해당 빈의 다이나믹 프록시를 만든다.
- 그 프록시 객체는 트랜잭션 매니저에 접근하고 트랜잭션이나 커넥션을 열고 닫도록 요청한다.
- 트랜잭션 매니저는 JDBC방식으로 코드를 실행해준다.
물리적 트랜잭션과 논리적 트랜잭션 차이
- 물리적 트랜잭션 : 실제 JDBC 트랜잭션
- 논리적 트랜잭션 : @Tansactional 로 중첩된 메소드
@Transactional Propagtion Level
트랜잭션 전파 레벨에는 여러가지가 있다. @Transactional 이 어떻게 동작하는지(JDBC 코드 이용)를 파악했으니 예상해볼 수 있다.
- Required(default) : 메소드는 트랜잭션을 필요로 해. 트랜잭션을 새로 하나 열든지, 기존에 있던 거를 쓰든지 할거야 = getConnection(); setAutoCommit(false); commit();
- Supports : 상관안해 트랜잭션 열든지말든지. 그냥 잘 실행할 수 있어. = JDBC는 아무것도 안함
- Mandatory : 스스로 트랜잭션을 열진 않을거지만, 아무도 트랜잭션을 열지 않으면 울 거야 = JDBC는 아무것도 안함
- Required_new : 온전히 내 소유의 트랜잭션이 필요해. = getConnection(); setAutoCommit(false); commit();
- Not_Supported : 트랜잭션 싫어. 이미 실행중인 트랜잭션이 있으면 중지시킬거야 = JDBC는 아무것도 안함
- Never : 울거야. 누군가 트랜잭션을 시작시킨다면. → JDBC는 아무것도 안함
- Nested : 복잡한데... 저장점을 잡아줄게! → savepoints...?
결과적으로는 어떤 전파 옵션을 선택했을 때 JDBC코드가 들어가느냐 안 들어가느냐만 이해하면 된다.
@Transactional Isolation Level
@Transactional(isolation = Isolation.REPEATABLE_READ)
앞서 언급했듯, 위와 같이 사용하면 아래와 같이 프록시에서 대신 실행해준다.
connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
트랜잭션 중에 격리 수준을 전환할 때, 데이터베이스나 JDBC 드라이버에서 기능이 지원되는지를 분명하게 먼저 확인해야할 필요가 있다.
프록시를 통하지 않고 다시 말해 Spring IoC Container에 제어권을 넘기지 않고 @Transactional 이 붙은 내부 메소드 호출할 때 생기는 프록시 문제는 추후에 다뤄보도록 하겠다.
Spring과 JPA(Hibernate) Transaction Management 동작
목적 : 스프링의 @Transactional 과 Hibernate/JPA 동기화
Hibernate를 사용해서 트랜잭션을 관리할 때 코드는 다음과 같다.
public class UserService {
@Autowired
private SessionFactory sessionFactory; // (1)
public void registerUser(User user) {
Session session = sessionFactory.openSession(); // (2)
// 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();
}
}
- 하이버네이트 쿼리의 시작인 SessionFactory를 사용한다.
- 직접 세션을 관리하고 하이버네이트 API를 통해 트랜잭션을 관리한다.
하지만 위 코드에는 다음과 같은 문제가 있다고 생각할 수 있다.
- 하이버네이트는 스프링의 @Transactional 애노테이션을 모른다
- 스프링의 @Transactional 은 하이버네이트 트랜잭션을 모른다.
하지만 반전으로 두 트랜잭션은 서로 알고 있다.
둘 다 유일한 방법인 JDBC 기본 방식을 사용하기 때문이다. 다만 서로 인지할 수 있게 스프링과 하이버네이트의 통합은 이뤄져야 한다. (사실 스프링에서 이미 다 자동화해놓았다)
@Service
public class UserService {
@Autowired
private SessionFactory sessionFactory; // (1)
@Transactional
public void registerUser(User user) {
sessionFactory.getCurrentSession().save(user); // (2)
}
}
- 이전과 같이 같은 SessionFactory를 사용한다.
- 그러나 더 이상 직접 상태를 관리하지 않는다. 대신 getCurrentSession()과 @Transactional 이 동기화 한다.
그렇다면 어떻게 동기화가 이뤄질까?
HibernateTransactionManager 사용
사실 Spring은 이미 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있다. 이를 이용함으로써 애플리케이션에 각 기술마다(JDBC, JPA, Hibernate 등) 종속적인 코드를 이용하지 않고도 일관되게 트랜잭션을 처리할 수 있도록 해주고 있다.
Spring이 제공하는 트랜잭션 경계 설정을 위한 추상 인터페이스는 PlatformTransactionManager 이다. 예를 들어 만약 JDBC의 로컬 트랜잭션을 이용한다면 DataSourceTxManager를 이용하면 된다.
즉, DataSourcePlatformTransactionManager 를 쓰는 대신, HibernateTransactionManager 를 쓰면 된다. JPA를 통해 Hibernate를 사용한다면 JpaTransactionManager 를 쓰면 된다.
HibernateTransactionManager는 하이버네이트를 직접 사용할 때 트랜잭션을 관리하고, JpaTransactionManager는 JPA를 통해서 간접적으로 사용할 때 트랜잭션을 관리한다.
스프링에서는 spring-boot-starter-data-jpa같은 라이브러리를 쓰면 자동으로 JpaTransactionManager를 사용한다.
정리
결국 Hibernate를 쓰든 JPA를 쓰든 @Transactional 을 쓰든 JDBC 기본(getConnection(), setAutoCommit(false), commit())방식으로 접근한다.
이 뼈대(JDBC 기본 접근 방식)만 알고 있으면 트랜잭션을 조작할 때, 추가적으로 어떤 일이 일어나는지에 대해서 이해하기 쉬울 것이다.
참고