트랜잭션은 DB 기술이기 때문에 이를 Spring에서 구현할 수 있는 방법이 필요하다.
방법에는 몇 가지가 있는데 하나씩 알아보며 각각의 문제점을 알아보겠다.
스프링부트에서는 DataSource 인터페이스에서 getConnection()
을 호출할 시에 HikariCP 커넥션 풀에서 커넥션을 꺼내온다.
import javax.sql.DataSource;
import java.sql.Connection;
@Service
public class ConnectionService {
private final DataSource dataSource; // DataSource는 외부에서 주입받았다.
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
try {
con.setAutoCommit(false); // 트랜잭션 시작
//비즈니스 로직
bizLogic(con, fromId, toId, money);
con.commit();
} catch (Exception e) {
con.rollback(); // 실패 시 롤백
throw new IllegalStateException(e);
}finally {
release(con); // release를 위한 메서드가 따로 정의되어 있음
}
}
private void bizLogic(Connection con, String fromId, String toId, int money){
// 비즈니스 로직
}
private void release(){
// 커넥션을 반환하기 위한 코드
}
}
위 코드는 DataSource
로 커넥션을 받아오고 커넥션을 수동커밋으로 전환(con.setAutoCommit(false)
)하면서 트랜잭션이 시작된다.
수동 커밋과 자동 커밋에 대한 내용은 트랜잭션의 기본 포스트에서 확인할 수 있다.
위 코드는 문제없이 트랜잭션을 수행하는 코드이다. 그래도 문제점을 꼽자면 트랜잭션 관련 코드가 반복될 수 있다. 좀 더 중요한 것은 해당 클래스(ConnectionService
)의 Datasource와 Connection는 외부 라이브러리이다.
import javax.sql.DataSource;
import java.sql.Connection;
해당 클래스는 서비스 계층인데 서비스 계층은 외부 라이브러리 코드를 사용하는 것을 권장하지 않는다. 서비스 계층은 애플리케이션의 핵심 비즈니스 로직을 담당하기 때문에 웹 애플리케이션에서 가장 중요한 부분이기 때문이다. 또한, 외부 라이브러리를 사용하면 서비스 계층을 테스트하기 어렵다. 때문에 순수 자바코드로 작성하는 것이 좋다.
스프링에는 트랜잭션을 지원해주는 PlatformTransactionManager
와 TransactionStatus
가 있다. 이를 사용하면 위에서 사용한 Connection
이나 Datasource
에 의존하지 않아도 트랜잭션을 구현할 수 있다.
@RequriedArgsConstructor
@Service
public class ConnectionService {
private final PlatformTransactionManager transactionManager;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
// 트랜잭션 시작
TransactionStatus stauts = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status); // 실패 시 롤백
throw new IllegalStateException(e);
}
}
private void bizLogic(String fromId, String toId, int money){
// 비즈니스 로직
}
}
앞서 말했듯이 PlatformTransactionManager
는 스프링이 지원하기 때문에 외부 라이브러리에 의존하는 코드는 모두 사라졌다. 그리고 트랜잭션 매니저는 해당 트랜잭션이 커밋 혹은 롤백으로 종료되는 경우 자동으로 커넥션을 반환해준다. 따라서 코드를 보면 커넥션을 반환하는 코드가 삭제되었다.
PlatformTransactionManager
을 사용한 경우 외부 라이브러리에 대한 의존을 제거함으로써 순수 자바코드가 되었으나 트랜잭션을 위한 코드가 중복되고 있다. 현재 위의 코드의 경우 실제 로직은 bizLogic()
밖에 없으며 나머지는 모두 트랜잭션 코드이다. 만약 다른 메서드에서 트랜잭션을 구현하려면 똑같은 코드를 또 중복 작성해야한다.
스프링은 트랜잭션 템플릿이라는 것을 제공한다. Template이라는 것은 어떤 '틀'을 말하는 것인데 비즈니스 로직만 쏙 넣을 수 있게끔 트랜잭션 틀을 만들어준다는 것이다. 코드로 알아보자
@Service
public class ConnectionService {
private final TransactionTemplate txTemplate;
public ConnectionService(PlatformTransactionManager txManager){
this.txTemplate = new TransactionTemplate(txManager);
}
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
txTemplate.executeWithoutResult((status)->{
try {
bizLogic(fromId, toId, money);
} catch (SQLException e) {
// 람다에서 체크 예외(SQLException)를 던질 수 없기때문에 언체크 예외(IllegalStateException)를 대신 던지며 실제 예외를 넣어줌
throw new IllegalStateException(e);
}
})
}
private void bizLogic(String fromId, String toId, int money){
// 비즈니스 로직
}
}
트랜잭션 템플릿(TransactionTemplate
)에 주입받은 트랜잭션 매니저를 넣어주었다.
this.txTemplate = new TransactionTemplate(txManager);
자 어떤가? 트랜잭션매니저로 부터 트랜잭션을 가지고 오고 직접 커밋하고 예외 시 롤백하는 코드가 사라지고 람다식 하나만 남았다.
람다식 내부를 보면 예외처리만 하고 있는데, 예외 발생 시 트랜잭션 템플릿이 자동으로 롤백해주고 예외가 발생하지 않는 경우에는 커밋한 뒤 커넥션을 반환해준다. 매우 간단하고 편리하게 변경되었다.
사실 여기까지 오면 앞에서 정말 별 문제가 없다. 트랜잭션 템플릿과 트랜잭션 매니저 관련 의존관계를 설정해주는 것 말고는 정말 없다. 하지만 이것 역시 귀찮다면...?
트랜잭션은 매우 중요하기 때문에 전세계적으로 수많은 개발자들이 사용해야한다. 그래서 스프링은 트랜잭션 템플릿을 AOP로 제공해준다. 트랜잭션 AOP가 무슨 말인지 모르더라도 코드를 보면 한 번쯤 봤던 코드일 것이다.
@Service
public class ConnectionService {
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
private void bizLogic(String fromId, String toId, int money){
// 비즈니스 로직
}
}
지금까지 열심히 코딩했던 트랜잭션 코드들이 모두 사라지고 달랑 @Transactional 애노테이션만 남아있다. 엄청나게 간단하다. @Transactional
이 붙은 메서드는 트랜잭션을 수행해준다. 지금까지 그랬듯이 해당 애노테이션 역시 성공 시 커밋하고 실패 시 롤백하며 트랜잭션이 끝나면 자동으로 커넥션 풀에 커넥션을 반환해준다.
💡
@Transactional
은 public 메서드에만 트랜잭션이 적용된다. 메서드에 붙어있다면 해당 메서드에 트랜잭션을 적용하지 않고, 클래스에 붙어있다면 해당 클래스의 public 메서드에만 트랜잭션을 적용한다.
트랜잭션을 사용하는 데에는 모든 문제점이 사라졌다. 그런데 이제와서 보니 순수 자바코드가 아니었다..!!! 범인은 바로 accountTransfer
메서드에서 던져진 SQLException
이다. SQLException은 체크예외이다. 체크예외의 경우에는 해당 메서드 혹은 클래스에서 체크예외가 발생한다는 것을 무조건 적시해줘야한다.
그렇지 않으면 컴파일 에러가 발생한다.
public void occurException() throws SQLException {
throw new SQLException();
}
그렇다면 던진 예외를 받는 모든 계층에서 throws SQLException
을 해주어야 한다는 것이다. 이는 코드의 유지보수성을 저하시킨다.
우리는 앞서 서비스 계층에서 순수 자바코드를 사용하기로 했다. 체크 예외인 SQLException
을 자바가 지원하는 언체크 예외로 변경해보자. 언체크 예외는 RuntimeException
과 그 하위 예외들을 말한다.
@Service
public class ConnectionService {
@Transactional
public void accountTransfer(String fromId, String toId, int money) {
try{
bizLogic(fromId, toId, money);
} catch (SQLException e){
throw new RuntimeException(e);
}
}
private void bizLogic(String fromId, String toId, int money){
// 비즈니스 로직
}
}
SQLException
발생 시 RuntimeException
으로 변경해서 던져주었다. 이제 SQL코드에도 의존하지 않고 throws 되물림 역시 사라졌다.
💡 위와 같이 예외를 변환해서 던져주는 경우 꼭 변환된 예외에 실제 발생한 예외를 넣어주자! 그렇지 않으면 나중에 예외가 발생했을 때 실제로 어떤 예외가 발생했는 지 추적하기 어렵다.