[DB] Spring 의 트랜잭션 추상화 (트랜잭션 매니저)
아래 코드는 서비스 클래스로, 트랜잭션을 사용하기 위해서 JDBC 기술에 의존하고 있습니다. 즉, JDBC에서 JPA 같은 다른 데이터 접근 기술로 변경하면 서비스 계층의 트랜잭션 관련 코드도 모두 함께 수정해야 하는 문제가 있습니다.
package hello.jdbc.service;
import hello.jdbc.domain.Account;
import hello.jdbc.repository.Account.AccountRepository;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
// JDBC 기술에 의존
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import static hello.jdbc.common.Constants.TEST_ID_FOR_EXCEPTION;
@Slf4j
@AllArgsConstructor
public class AccountService {
private final DataSource dataSource;
private final AccountRepository repository;
public void transferMoney(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection(); // 커넥션 얻기
try {
con.setAutoCommit(false); // 트랜잭션 시작
// business logic
businessLogic(con, fromId, toId, money);
con.commit();
} catch (Exception e) {
con.rollback(); // 실패시 롤백
throw new IllegalStateException(e);
} finally {
release(con);
}
}
private void businessLogic(Connection con, String fromId, String toId, int money) throws SQLException {
Account fromAccount = repository.findById(con, fromId);
Account toAccount = repository.findById(con, toId);
repository.update(con, fromId, fromAccount.getMoney() - money);
repository.update(con, toId, toAccount.getMoney() + money);
}
private void release(Connection con) {
if (con != null) {
try {
con.setAutoCommit(true); // 풀에 반납하기 전에 default 값이 자동커밋모드로 바꿔주는 것이 좋다!!
con.close(); // 커넥션 종료가 아니라 풀에 반납한다는 의미!
} catch (SQLException e) {
log.error("error", e);
}
}
}
}
따라서 트랜잭션 추상화를 통해 서비스 계층과 JDBC 기술과 같은 특정 트랜잭션 기술에 직접 의존하지 않도록 해야 합니다. 즉 아래와 같이 추상화된 인터페이스에 의존하도록 수정이 필요합니다.
클라이언트인 서비스는 인터페이스에 의존하고 DI를 사용한 덕분에 OCP 원칙 ( 확장에는 개방되어 있어야 하고 수정에는 폐쇄)을 지키게 되었습니다.
스프링의 트랜잭션 추상화
스프링에서 제공하는 트랜잭션 추상화 기술이 있어서 이를 사용하면 됩니다.
참고로, 스프링 5.3부터는 JDBC 트랜잭션을 관리할 때 DataSourceTransactionManager를 상속받아서 약간의 기능을 확장한 JdbcTransactionManager를 제공합니다. (기능 차이는 크지 않습니다.)
PlatformTransactionManager 인터페이스
package org.springframework.transaction;
public interface PlatformTransactionManager extends TransactionManager {
// 트랜잭션 시작
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
// 트랜잭션 커밋
void commit(TransactionStatus status) throws TransactionException;
// 트랜잭션 롤백
void rollback(TransactionStatus status) throws TransactionException;
}
리소스 동기화
스프링이 제공하는 트랜잭션 매니저는 위에서 설명한 트랜잭션 추상화 외에도 리소스 동기화라는 역할을 수행합니다.
트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야 합니다. 이를 위해서 해당 포스팅에서는 파라미터로 커넥션을 전달하였습니다. 하지만, 파라미터로 커넥션을 전달하는 것은 코드가 복잡해지고, 커넥션을 넘기는 메서드와 넘기지 않는 메서드를 중복해서 만들어야 하는 등의 단점이 많습니다.
이를 위해 스프링은 트랜잭션 동기화 매니저를 제공합니다. 원리는 스레드 로컬(ThreadLocal)을 사용해 커넥션을 동기화해 줍니다. 트랜잭션 매니저는 내부에서 이 트랜잭션 동기화 매니저를 사용하여 리소스 동기화를 수행합니다.
트랜잭션 동기화 매니저는 쓰레드 로컬을 사용하기 때문에 멀티쓰레드 상황에 안전하게 커넥션을 동기화할 수 있습니다. 따라서 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득하면 되고 이전처럼 파라미 터로 커넥션을 전달하지 않아도 됩니다.
- 트랜잭션 매니저는 DataSource를 통해 커넥션을 만들고 트랜잭션을 시작합니다.
- 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관합니다.
- 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용합니다. (파라미터 전달할 필요 X)
- 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션도 닫습니다.
트랜잭션 매니저 적용
AccountRepository 클래스
이 포스팅에서 작성한 Repository를 아래처럼 수정하였습니다. 커넥션을 파라미터로 전달하는 부분이 모두 제거되었습니다.
package hello.jdbc.repository.Account;
import hello.jdbc.domain.Account;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;
import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;
@Slf4j
@AllArgsConstructor
public class AccountRepositoryV1 {
private final DataSource dataSource;
public Account save(Account account) throws SQLException {
String sql = "insert into account(account_id, money) values (?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, account.getAccountId());
pstmt.setInt(2, account.getMoney());
pstmt.executeUpdate();
return account;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public Account findById(String id) throws SQLException {
String sql = "select * from account where account_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, id);
rs = pstmt.executeQuery();
if (rs.next()) {
int money = rs.getInt("money");
return new Account(id, money);
} else {
throw new NoSuchElementException("account not found account_id = " + id);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public void update(String id, int money) throws SQLException {
String sql = "update account set money=? where account_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, id);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public void delete(String accountId) throws SQLException {
String sql = "delete from account where account_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, accountId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
//주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() throws SQLException {
//주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
}
DataSourceUtils.getConnection()
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환합니다.
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해서 반환합니다.
DataSourceUtils.releaseConnection()
- 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지합니다.
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫습니다.
AccountService 클래스
package hello.jdbc.service;
import hello.jdbc.domain.Account;
import hello.jdbc.repository.Account.AccountRepositoryV1;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import java.sql.SQLException;
import static hello.jdbc.common.Constants.TEST_ID_FOR_EXCEPTION;
@Slf4j
@AllArgsConstructor
public class AccountServiceV1 {
private final PlatformTransactionManager transactionManager;
private final AccountRepository repository;
public void transferMoney(String fromId, String toId, int money) {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
// status에 현재 트랜잭션의 상태 정보가 포함되어 있는데, 이후 트랜잭션 커밋/롤백할 때 필요하다.
try {
// business logic
businessLogic(fromId, toId, money);
transactionManager.commit(status); //성공 시 커밋
} catch (Exception e) {
transactionManager.rollback(status); // 실패 시 롤백
throw new IllegalStateException(e);
}
}
private void businessLogic(String fromId, String toId, int money) throws SQLException {
Account fromAccount = repository.findById(fromId);
Account toAccount = repository.findById(toId);
repository.update(fromId, fromAccount.getMoney() - money);
// exception occur
validation(toAccount);
repository.update(toId, toAccount.getMoney() + money);
}
private void validation(Account account) {
if (account.getAccountId().equals(TEST_ID_FOR_EXCEPTION)) {
throw new IllegalStateException("Exception while transferring!!");
}
}
}
테스트 코드
package hello.jdbc.service;
import hello.jdbc.domain.Account;
import hello.jdbc.repository.Account.AccountRepositoryV1;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import java.sql.SQLException;
import static hello.jdbc.common.Constants.TEST_ID_FOR_EXCEPTION;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class AccountServiceV1Test {
private AccountRepositoryV1 repository;
private AccountServiceV1 service;
@BeforeEach
void setUp() {
// JDBC 용 트랜잭션 매니저 선택 (DataSourceTransactionManager)
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
// 트랜잭션 매니저는 DataSource를 통해 커넥션을 생성하므로 DataSource가 필요
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
repository = new AccountRepositoryV1(dataSource);
service = new AccountServiceV1(transactionManager, repository);
}
@AfterEach
void tearDown() throws SQLException {
repository.delete("accountA");
repository.delete("accountB");
repository.delete(TEST_ID_FOR_EXCEPTION);
}
@Test
@DisplayName("정상 이체")
void transferMoney() throws SQLException {
Account accountA = new Account("accountA", 50000);
Account accountB = new Account("accountB", 10000);
repository.save(accountA);
repository.save(accountB);
service.transferMoney(accountA.getAccountId(), accountB.getAccountId(), 10000);
Account findAccountA = repository.findById(accountA.getAccountId());
Account findAccountB = repository.findById(accountB.getAccountId());
assertThat(findAccountA.getMoney()).isEqualTo(40000);
assertThat(findAccountB.getMoney()).isEqualTo(20000);
}
@Test
@DisplayName("이체중 예외 발생")
void transferException() throws SQLException {
Account accountA = new Account("accountA", 50000);
Account accountEx = new Account(TEST_ID_FOR_EXCEPTION, 10000);
repository.save(accountA);
repository.save(accountEx);
assertThatThrownBy(() -> service.transferMoney(accountA.getAccountId(), accountEx.getAccountId(), 10000)).isInstanceOf(IllegalStateException.class);
Account findAccountA = repository.findById(accountA.getAccountId());
Account findAccountEx = repository.findById(accountEx.getAccountId());
assertThat(findAccountA.getMoney()).isEqualTo(50000);
assertThat(findAccountEx.getMoney()).isEqualTo(10000);
}
}
정리
- 서비스 계층에서 transactionManager.getTransaction()을 호출해서 트랜잭션을 시작합니다.
- 트랜잭션을 시작하려면 먼저 데이터베이스 커넥션이 필요하므로 트랜잭션 매니저는 내부에서 DataSource를 사용해 서 커넥션을 생성합니다.
- 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작합니다.
- 커넥션을 트랜잭션 동기화 매니저에 보관합니다.
- 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관하여 멀티 쓰레드 환경에 안전하게 커넥션을 보관할 수 있도록 합니다.
- 서비스는 비즈니스 로직을 실행하면서 리포지토리의 메서드들을 호출합니다. (커넥션을 파라미터로 전달하지 않습니다.)
- 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하므로DataSourceUtils.getConnection() 을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용합니다. 이 과정을 통해서 자연스럽게 같은 커넥션을 사용하고, 트랜잭션도 유지됩니다.
- 획득한 커넥션을 사용해 SQL을 데이터베이스에 전달해 실행합니다.
- 비즈니스 로직이 끝나고 커밋/롤백을 통해 트랜잭션을 종료합니다.
- 트랜잭션을 종료하려면 동기화된 커넥션이 필요하므로 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득하여 커밋/롤백 합니다.
- 전체 리소스를 정리합니다. 트랜잭션 동기화 매니저를 정리(ThreadLocal 정리)하고, con.setAutoCommit(true)로 되돌리고, con.close()를 호출해 커넥션을 종료합니다. 커넥션 풀을 사용하는 경우 con.close()를 호출하면 커넥션 풀에 반환됩니다.