이 포스팅은 2024.06.26에 작성되었습니다.
트랜잭션의 전파에 대해 공부하다가 트랜잭션의 동기화와 추상화까지 내려왔다.
JDBC
로 Connection을 직접 사용하여 트랜잭션을 만지는 방법부터, 많이 사용하는 Spring @Transactional
까지 나아가보려고 한다.
https://hudi.blog/spring-transaction-synchronization-and-abstraction/
위 블로그를 많이 참고하여 작성합니다! 읽어보시는 것 추천드립니다.
workbench, sql 콘솔 등으로 데이터베이스 쿼리를 날릴 때, INSERT
문 하나만 단독으로 실행해도 테이블에 반영이 된다. 이는 내부적으로 자동으로 커밋해주는 auto commit
기능 덕분이다.
하지만 우리는 여러 개의 쿼리문을 한 번에 트랜잭션으로 처리해줘야하는 경우가 많다.
ex) 사용자가 회원가입을 하는 동시에, 사용자의 계좌가 생성되어야함
JDBC를 사용하면서 여러 개의 쿼리문을 원자적으로 묶어 실행하고 싶은 경우, auto commit
기능을 비활성화하고, 커밋과 롤백 시점을 직접 지정해줄 수 있다.
아래 코드는 회원가입 시에 회원이 생성되는 동시에, 메시지가 동시에 같이 생성되는 코드이다.
public class MemberDao {
public void saveMember(final Connection connection, final String name) {
try {
String sql = "insert into member(name) values (?)";
PreparedStatement preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, name);
preparedStatement.execute();
} catch (final SQLException e) {
e.printStackTrace();
}
}
}
public class MessageDao {
public void saveMessage(final Connection connection, final String name) {
try {
String sql = "insert into seat(message) values (?)";
PreparedStatement preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, name + "님 환영합니다");
preparedStatement.execute();
} catch (final SQLException e) {
e.printStackTrace();
}
}
}
member를 저장하면서 message를 저장하는 로직을 트랜잭션으로 묶어줘야하기 때문에, 각 dao의 메서드들은 같은 connection
을 공유해야한다. 그래서 Dao의 메서드들은 connection
을 외부에서 주입받아야한다.
public class MemberService {
...
public void register(final String name) {
Connection connection = null;
try {
connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
connection.setAutoCommit(false);
memberDao.saveMember(connection, name);
messageDao.saveMessage(connection, name);
connection.commit();
} catch (final SQLException e) {
try {
connection.rollback();
} catch (final SQLException ignored) {
}
} finally {
try {
connection.close();
} catch (final SQLException ignored) {
}
}
}
}
dao를 사용하는 서비스코드이다.
서비스 레이어의 관심사는 비즈니스 로직이지만, memberDao와 messageDao에서 실행되는 쿼리를 트랜잭션으로 묶어줘야하기 때문에 서비스에서 connection을 관리하고 있는 모습이다. 그래서 connection을 생성/관리하는 로직 + JDBC를 사용할 때 따라오는 try/catch/finally 보일러 플레이트 코드 모두 서비스 코드에 포함되었다.
✅ 서비스에서는 커넥션 생성, 트랜잭션 경계를 설정하는 코드와 비즈니스 로직이 뒤섞이게 되고
✅ Dao에서는 데이터 엑세스 기술이 Service에 종속된다
위와 같은 문제를 해결하기 위해 Spring에서는 트랜잭션 동기화
를 이용해 데이터베이스 커넥션을 관리한다. 트랜잭션을 시작하기 위해 생성한 Connection 객체를 별도의 특별한 공간에 보관하고, 이 커넥션이 필요한 곳에서 커넥션을 꺼내 쓴다.
+) 이 동기화 작업은 쓰레드마다 독립적으로 Connection 객체를 보관하므로 멀티 쓰레드 환경에서도 걱정없이 사용할 수 있다
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/txtest?serverTimezone=Asia/Seoul
username: user
password: user
커넥션을 가져올때 DataSource
가 필요하므로, 데이터 소스에 대한 설정을 application.yml
에 추가한다.
public void register(final String name) {
//트랜잭션 동기화 초기화
TransactionSynchronizationManager.initSynchronization();
//커넥션 획득
Connection connection = DataSourceUtils.getConnection(dataSource);
try {
connection.setAutoCommit(false);
memberDao.saveMember(name);
messageDao.saveMessage(name);
connection.commit();
} catch (final SQLException e) {
try {
connection.rollback();
} catch (final SQLException ignored) {
}
} finally {
// 커넥션 자원 해제
DataSourceUtils.releaseConnection(connection, dataSource);
}
}
MemberService
는 TransactionSynchronizationManager
클래스를 사용하여 트랜잭션 동기화 작업을 활성화하고, DataSourceUtils
라는 헬퍼 클래스를 통해 커넥션을 가져온다.
이제 connection은 DataSourceUtils에서 꺼내올 수 있기 때문에 service가 직접 생성/관리하고 dao 메서드에 넘겨주지 않아도 된다.
하지만 memberDao와 messageDao의 트랜잭션 경계를 설정해줘야하기 때문에 connection에 대한 커밋/롤백은 서비스에서 관리해야한다.
@RequiredArgsConstructor
@Repository
public class MessageDao {
private final DataSource dataSource;
public void saveMessage(final String name) {
Connection connection = DataSourceUtils.getConnection(dataSource);
try {
String sql = "insert into seat(message) values (?)";
PreparedStatement preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, name + "님 환영합니다");
preparedStatement.execute();
} catch (final SQLException e) {
e.printStackTrace();
}
}
}
@RequiredArgsConstructor
@Repository
public class MemberDao {
private final DataSource dataSource;
public void saveMember( final String name) {
Connection connection = DataSourceUtils.getConnection(dataSource);
try {
String sql = "insert into member(name) values (?)";
PreparedStatement preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, name);
preparedStatement.execute();
} catch (final SQLException e) {
e.printStackTrace();
}
}
}
dao에서도 이제 connection을 외부에서 주입받지 않고, DataSourceUtils를 통해서 주입받는다.
위에서는 connection을 통해 직접적으로 트랜잭션 경계를 설정하였다. 이 경우 데이터 엑세스 기술이 Service에 종속된다는 문제점이 있다.
스프링에서는 트랜잭션 추상화를 위해 PlatformTransactionManager
인터페이스를 제공한다. 이 인터페이스를 통해 connection을 사용하여 트랜잭션 경계를 직접 지정하는 코드를 추상화할 수 있다.
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
스프링은 트랜잭션 전파라는 특징을 가지고 있어, 자유롭게 트랜잭션을 서로 조합하고 트랜잭션의 경계를 확장할 수 있다. 그래서
begin()
이라는 네이밍 대신, 현재 트랜잭션을 가져온다는 의미의getTransaction()
을 사용한다.(실제로PROPAGATION_REQUIRED
전파속성을 사용하는 경우, 직접 지정해준 트랜잭션이 아닌 상위 트랜잭션의 속성을 가져오는 경우가 있다)
PlatformTransactionManager 의 구현 클래스는 다음과 같은 것들이 있다.
아래에서는 DataSourceTransactionManager
를 사용하였다.
public void register(final String name) {
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
memberDao.saveMember(name);
messageDao.saveMessage(name);
transactionManager.commit(status);
} catch (RuntimeException e) {
transactionManager.rollback(status);
}
}
connection 객체를 직접 사용하여 트랜잭션 경계를 설정하는 코드가 사라지니 훨씬 코드가 간결해진 걸 볼 수 있다. 또한 트랜잭션 동기화를 위한 connection 객체를 얻고, 자원을 해제하는 코드도 PlatformTransactionManager
를 통해 추상화되었다.
TransactionDefinition
: 트랜잭션의 속성을 나타내는 인터페이스TransactionStatus
: 현재 참여하고 있는 트랜잭션의 ID와 구분 정보를 담고 있음. 커밋과 롤백시 이 정보를 통해 트랜잭션을 식별함public void registerOnlyMember(final String name) {
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
memberDao.saveMember(name);
transactionManager.commit(status);
} catch (RuntimeException e) {
log.info("에러발생, rollback");
transactionManager.rollback(status);
}
}
public void callRegister(final String name) {
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
registerOnlyMember(name);
messageDao.saveMessage(name);
//throw new RuntimeException();
transactionManager.commit(status);
} catch (RuntimeException e) {
transactionManager.rollback(status);
e.printStackTrace();
}
}
callRegister
에서 registerOnlyMember
를 호출하여 트랜잭션 전파를 확인해봤다.
1) registerOnlyMember
메서드가 잘 동작되고, callRegister
에서 예외가 발생한 경우
2) registerOnlyMember
메서드에서 예외가 발생한 경우
org.springframework.transaction.IllegalTransactionStateException:
Transaction is already completed - do not call commit or rollback more than once per transaction
IllegalTransactionStateException
의 경우 트랜잭션의 존재여부가 illegal state일 때 발생하는 예외라고 설명되어있다.
트랜잭션 추상화까지만 해도 충분히 코드가 간결해졌지만, 트랜잭션 코드를 비즈니스 로직과 분리하고 싶은 욕심이 생길 수 있다.
스프링은 트랜잭션 코드와 같은 부가 기능 코드가 존재하지 않는 것처럼 보이기 위해 해당 로직을 클래스 밖으로 빼내 별도의 모듈로 만드는 AOP를 고안 및 적용하였고, 이를 적용한 트랜잭션 어노테이션 @Transactional
을 지원하게 되었다.
@Transactional
public void register(final String name) {
memberDao.saveMember(name);
messageDao.saveMessage(name);
}
너무 깔끔하게 비즈니스로직만 남은 걸 확인할 수 있다.
이번에 JDBC를 이용해 Connection을 직접 만져보고 개선해나가면서 트랜잭션에 대해 더 깊게 이해할 수 있었던 것 같다. 트랜잭션은 깊이 알아볼수록 더 재밌는 것 같다.
트랜잭션의 전파속성, @Transactional의 동작방식 등 더 공부하고 정리하고 싶은 내용이 아직 너무 많아서 얼른 포스팅하고 싶다.
https://hudi.blog/spring-transaction-synchronization-and-abstraction/
https://mangkyu.tistory.com/154