이전 글에서 Spring AOP에 대해 다루면서 Spring AOP 기술이 트랜잭션 등에 주로 사용된다고 언급하였습니다. 이번에는 트랜잭션 기술이 무엇이고, 스프링은 트랜잭션에 대한 어떤 기능을 제공하며, AOP가 활용된 방법까지 간단하게 정리해보겠습니다.
데이터베이스를 공부하면 가장 먼저 등장하는 개념 중 하나인 트랜잭션(Transaction)은 사전적으로 데이터베이스 내에서 실행되는 일의 단위를 의미합니다.
단순히 읽고, 쓰고, 저장하는 파일(File)과 달리 데이터베이스에서의 일이란 하나의 데이터를 읽는 것, 하나의 데이터를 추가하는 것에서 그치지 않고, 은행 계좌이체와 같이 이쪽에서 뺀 값을 저쪽에 더해주는 것 등 서로 다른 데이터에 대한 여러 연속적인 읽고 쓰기 작업을 의미합니다.
A와 B라는 가상의 친구들 사이 "계좌이체" 라는 일을 완료하기 위해서는 다음과 같은 일련의 작업이 필요합니다(실제론 더 복잡하겠지만 여기선 생략합니다).
그런데 1번을 실행하고 난 뒤 오류가 났다면 어떨까요? 졸지에 A는 냅다 10000원을 잃어버린 사람이 되고, B는 받아야할 10000원을 못 받은 사람이 되며, 제가 만든 계좌이체 서비스는 망하고, 손해배상까지 해줘야하는 안타까운 상황이 발생합니다.
따라서 데이터베이스가 제공하는 트랜잭션은 이 모든 일이 한 번에 성공하거나, 잘못될 경우 모두 취소하도록 보장해야 합니다(Atomicity).
이렇게 데이터베이스가 안전하게 수행된다는 것을 보장하기 위해서는 위와 같은 몇 가지 조건이 있고 이들의 앞글자를 따서 ACID라고 부릅니다. 참고를 위해 아래 적어두겠습니다.
ACID
Atomicity (원자성)
트랜잭션 내의 모든 작업은 전부 성공하거나, 전부 실패해야 합니다. 중간에 하나라도 실패하면 전체 작업이 취소되어야 합니다.Consistency (일관성)
트랜잭션이 성공적으로 완료되면, 데이터는 항상 일관된 상태를 유지해야 합니다. 예를 들어 A의 계좌에서 10000원이 빠졌다면, B의 계좌에는 반드시 10000원이 추가되어야 합니다.Isolation (독립성)
여러 트랜잭션이 동시에 실행되더라도 서로의 작업에 간섭해서는 안 됩니다. 각 트랜잭션은 마치 자신만이 데이터베이스를 사용하는 것처럼 동작해야 합니다.Durability (지속성)
트랜잭션이 완료되고 나면 그 결과는 영구적으로 저장되어야 합니다. 설령 시스템에 장애가 발생하더라도 완료된 트랜잭션의 결과는 보존되어야 합니다.
한 번만 더 정리하자면 트랜잭션은 즉, 비즈니스 로직의 완전한(그리고 안전한) 수행을 위한 일련의 연산 묶음으로 볼 수 있습니다.
실제 DBMS를 통해 트랜잭션 기능을 활용하기 위해서는 commit과 rollback 등의 명령어를 사용합니다.
set autocommit false; -- 수동 커밋 모드 설정 = 트랜잭션 시작
update accounts set balance = balance - 10000 where name = 'A'; -- A의 계좌에서 10000원 차감
update accounts set balance = balance + 10000 where name = 'B'; -- B의 계좌에 10000원 추가
commit; -- 수동 커밋
set autocommit false; -- 수동 커밋 모드 설정 = 트랜잭션 시작
update accounts set balance = balance - 10000 where name = 'A';
-- 예: 네트워크 오류, 예외 발생 등
rollback; -- 지금까지 수행된 모든 변경사항을 취소하고, 이전 상태로 되돌림
대부분 DBMS가 자동 커밋 모드가 기본으로 설정된 경우가 많기 때문에, 수동 커밋 모드로 설정하는 것을 트랜잭션의 시작으로 볼 수 있습니다. 이러한 경우 작업이 끝나면 꼭 commit 혹은 rollback을 호출해주어야 합니다.
실제 웹 애플리케이션 서버(WAS)에서 데이터베이스를 사용할 때는, 별도의 데이터베이스 서버와의 커넥션(Connection)을 얻고 이를 통해서 요청과 데이터를 주고 받습니다. 이러한 커넥션이 만들어질 때, 데이터베이스 서버는 내부에 세션이라는 것을 만듭니다. 이후 모든 요청은 이 세션을 통해서 실행하게 됩니다. 쉽게 생각하면 은행과 창구 직원으로 표현할 수 있겠습니다.
창구 직원인 세션은 자신과 연결된 클라이언트(WAS 혹은 DB툴)의 요청을 받아 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료합니다. 세션은 커넥션을 닫거나 DBA(DB 관리자)가 강제로 종료하면 세션은 종료됩니다.
서버에 여러 요청이 동시에 들어와 여러 로직을 동시에 수행하면, 데이터베이스 커넥션도(커넥션 풀이라는 것의 크기에 따라) 여러 개가 만들어지고, 각 커넥션에 대응되는 세션도 여러 개가 만들어집니다. 여기가 문제가 됩니다.
세션1이 데이터를 수정하는 동안 세션2가 같은 데이터를 조회하거나 수정하게 되면 아직 저장되지 않은 값을 읽거나, 둘 중 하나의 연산이 무시되는 등 여러 문제가 생길 수 있습니다. 이런 경우 아까 말한 트랜잭션의 원자성이 깨지게 됩니다.
이런 문제를 해결하기 위해서는, 트랜잭션이 한 번 시작되고 나면 커밋이나 롤백 전까지 다른 누가 건드릴 수 없게 해야합니다. 이를 위해 DBMS는 락(자물쇠죠)을 이용합니다. 한 세션에서 트랜잭션을 시작하면 필요한 데이터를 잠궈버리고, 락(이름은 락인데 역할은 열쇠입니다)을 해당 세션에 넘깁니다. 잠긴 데이터는 락을 가진 세션만 접근할 수 있기 때문에 락이 없는 세션은 락이 반납될 때까지 기다리게 됩니다. 쉽게 설명하기 위해 화장실이 예로 자주 등장합니다.
이 때 락을 무한정 기다리는 것은 아니고, 제한시간을 설정하여, 그 시간이 지나면 세션의 트랜잭션을 롤백시켜버리고 락을 반납하도록 합니다.
아무튼 이 작업은 트랜잭션의 생성과 종료에 DBMS가 자동으로 처리해주는 부분입니다. 웹 애플리케이션 서버 개발에서 신경써야할 부분은 데이터베이스 서버와의 연결과 트랜잭션을 시작하고 비즈니스 로직을 수행하고, 커밋하거나 롤백하는 부분입니다.
JDBC에 대한 상세한 설명은 이 글에서 다루지 않겠습니다. 다만, 간략하게 소개하자면, JDBC란 자바에서 데이터베이스에 접속할 수 있도록 하는 API입니다.
여러가지 데이터베이스가 있고, 이들의 커넥션 연결 및 관리 방법, SQL을 전달하는 방법, 결과를 반환하는 방법이 모두 회사마다 조금씩 다르기 때문에, 개발하는 쪽에서 이를 신경 쓰지 않도록 하기 위해 만들어진 표준 인터페이스 입니다.
이제 각 회사는 이 인터페이스(JDBC)에 맞는 드라이버를 구현해서 제공하면, 개발자들은 JDBC에 맞는 코드만 작성하고, 각 회사가 제공한 드라이버가 이를 본인들 데이터베이스에 맞게 동작하도록 해주는 것입니다.
따라서 아래와 같은 모양이 됩니다.
애플리케이션 로직 --> JDBC 표준 인터페이스 <-- 드라이버 <--> 데이터베이스
앞서 언급했던 예시를 계속 활용해서 돈을 송금하는 서비스를 간단하게 JDBC를 활용해 구현해보겠습니다. 우선 트랜잭션 로직이 없는 순수한 상태부터 살펴보겠습니다.
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
private String id;
private int balance;
}
계좌를 의미하는 도메인입니다. 단순히 id와 잔액인 balance만 있습니다.
@Repository
@RequiredArgsConstructor
public class AccountRepositoryV1 {
private final DataSource dataSource;
public Account save(Account account) {
String sql = "insert into account(id, balance) values (?, ?)";
Connection con = null;
PreparedStatement stmt = null;
try {
con = dataSource.getConnection();
stmt = con.prepareStatement(sql);
stmt.setString(1, account.getId());
stmt.setInt(2, account.getBalance());
stmt.executeUpdate();
return account;
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
}
public Account findById(String accountId) {
String sql = "select * from account where id = ?";
Connection con = null;
PreparedStatement stmt = null;
ResultSet rs = null;
try {
con = dataSource.getConnection();
stmt = con.prepareStatement(sql);
stmt.setString(1, accountId);
rs = stmt.executeQuery();
if (rs.next()) {
Account account = new Account();
account.setId(rs.getString("id"));
account.setBalance(rs.getInt("balance"));
return account;
} else {
throw new NoSuchElementException(accountId);
}
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
}
public void update(String accountId, int balance) {
String sql = "update account set balance=? where id=?";
Connection con = null;
PreparedStatement stmt = null;
try {
con = dataSource.getConnection();
stmt = con.prepareStatement(sql);
stmt.setInt(1, balance);
stmt.setString(2, accountId);
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
}
public void delete(String accountId) {
String sql = "delete from account where id=?";
Connection con = null;
PreparedStatement stmt = null;
try {
con = dataSource.getConnection();
stmt = con.prepareStatement(sql);
stmt.setString(1, accountId);
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
}
}
데이터베이스와 SQL로 상호작용하는 리포지토리입니다. 구현은 JDBC를 활용하여 구현되어 있습니다. 전부 자세히 보실 필요는 없고 여기서 중요한 건 리포지토리에서 직접 커넥션을 관리한다는 것입니다. 하나의 쿼리를 실행하기 위해 매번 새로운 커넥션을 만들고, 쿼리를 전달하고, 커넥션을 끊습니다.
💡 해당 코드에서는 DataSource를 사용하고 있습니다.
데이터베이스 커넥션을 얻는 방법은 DriverManager를 통해 매번 새로운 커넥션을 생성하는 방법과 커넥션 풀이라는 것을 이용해 미리 만들어둔 몇 개의 커넥션을 다같이 돌려 쓰는 방법 등 다양한 방법이 있습니다. 이러한 서로 다른 커넥션 획득 방식을 추상화한 것이 DataSource 입니다. 이에 대한 더 자세한 내용은 DataSource에 대해 찾아보시기 바랍니다.
여기서는 DataSource의 내부 동작 방식과 무관하게 커넥션을 만들고(연결하고) 끊는다고 표현하겠습니다.
@Service
@RequiredArgsConstructor
public class TransferService {
private final AccountRepository accountRepository;
public void transfer(String from, String to, int amount) {
Account accountFrom = accountRepository.findById(from);
Account accountTo = accountRepository.findById(to);
// 송금자 계좌에서 빼기
accountFrom.setBalance(accountFrom.getBalance() - amount);
accountRepository.update(accountFrom.getId(), accountFrom.getBalance());
// 예외 발생 조건
if (to.equals("accountEx")) {
throw new IllegalStateException("예외 발생: accountEx는 송금 불가 대상입니다.");
}
// 수령인 계좌에 더하기
accountTo.setBalance(accountTo.getBalance() + amount);
accountRepository.update(accountTo.getId(), accountTo.getBalance());
}
}
송금(transfer) 메서드를 제공하는 클래스입니다. 현재는 비즈니스 로직만 담고 있습니다. 송금하는 사람 계좌에서 돈을 빼고, 받는 사람 계좌에 돈을 더합니다. 예외가 발생하는 경우를 보여주기 위해 받는 사람의 아이디가 accountEx인 경우 예외를 발생시키게 해두었습니다(SQL 실행 중 예외가 발생되었다고 가정합니다).
@SpringBootTest
class TransferServiceTest {
@Autowired
TransferService transferService;
@Autowired
AccountRepository accountRepository;
@BeforeEach
void before() {
// 계좌 초기화
accountRepository.save(new Account("accountA", 100000));
accountRepository.save(new Account("accountB", 50000));
accountRepository.save(new Account("accountEx", 50000));
}
@AfterEach
void after() {
// 계좌 삭제
accountRepository.delete("accountA");
accountRepository.delete("accountB");
accountRepository.delete("accountEx");
}
/**
* A가 B에게 만원을 송금하면 성공한다.
*/
@Test
void transferSuccess() {
transferService.transfer("accountA", "accountB", 10000);
Account accountA = accountRepository.findById("accountA");
Account accountB = accountRepository.findById("accountB");
assertThat(accountA.getBalance()).isEqualTo(90000);
assertThat(accountB.getBalance()).isEqualTo(60000);
}
/**
* A가 B에게 만원을 송금 중 예외가 발생하면
* 만원이 유실된다
*/
@Test
void transferFail() {
assertThatThrownBy(() -> transferService.transfer("accountA", "accountEx", 10000))
.isInstanceOf(RuntimeException.class);
Account accountA = accountRepository.findById("accountA");
Account accountEx = accountRepository.findById("accountEx");
assertThat(accountA.getBalance()).isEqualTo(90000); // A는 만원 유실
assertThat(accountEx.getBalance()).isEqualTo(50000); // Ex는 만원 못 받음
}
}

트랜잭션이 적용되지 않은 코드에서는 예외가 발생하는 경우, 비즈니스 로직이 중간에 실패했음에도 보내는 사람(accountA) 돈은 줄어들고, 받는 사람(accountEx) 돈은 그대로인 것을 확인할 수 있습니다. 이는 원자성이 깨진 사례입니다. 이제 TransferService에 트랜잭션을 적용해보겠습니다.
그러기 위해서는 몇 가지 변화가 필요합니다.
transfer() 내에서 이루어지는 쿼리가 모두 하나의 커넥션(세션)으로 이루어져야 합니다. 따라서 서비스 계층에서 커넥션을 만들어야 합니다.그럼 수정을 해보겠습니다.
@Repository
@RequiredArgsConstructor
public class AccountRepositoryV2 {
private final DataSource dataSource;
public Account save(Account account) {
String sql = "insert into account(id, balance) values (?, ?)";
Connection con = null;
PreparedStatement stmt = null;
try {
con = dataSource.getConnection();
stmt = con.prepareStatement(sql);
stmt.setString(1, account.getId());
stmt.setInt(2, account.getBalance());
stmt.executeUpdate();
return account;
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
}
// 트랜잭션 밖에서 사용
public Account findById(String accountId) {
String sql = "select * from account where id = ?";
try (Connection con = dataSource.getConnection();
PreparedStatement stmt = con.prepareStatement(sql)) {
stmt.setString(1, accountId);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
Account account = new Account();
account.setId(rs.getString("id"));
account.setBalance(rs.getInt("balance"));
return account;
} else {
throw new NoSuchElementException(accountId);
}
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
// Connection을 외부에서 매개변수로 받음
public Account findById(Connection con, String accountId) throws SQLException {
String sql = "select * from account where id = ?";
try (PreparedStatement stmt = con.prepareStatement(sql)) {
stmt.setString(1, accountId);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
Account account = new Account();
account.setId(rs.getString("id"));
account.setBalance(rs.getInt("balance"));
return account;
} else {
throw new NoSuchElementException(accountId);
}
}
}
}
// Connection을 외부에서 매개변수로 받음
public void update(Connection con, String accountId, int balance) throws SQLException {
String sql = "update account set balance=? where id=?";
try (PreparedStatement stmt = con.prepareStatement(sql)) {
stmt.setInt(1, balance);
stmt.setString(2, accountId);
stmt.executeUpdate();
}
}
public void delete(String accountId) {
String sql = "delete from account where id=?";
Connection con = null;
PreparedStatement stmt = null;
try {
con = dataSource.getConnection();
stmt = con.prepareStatement(sql);
stmt.setString(1, accountId);
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
}
}
전과 달리 findById()와 update()가 외부에서 커넥션을 받도록 수정했습니다. findById()의 경우 테스트 편의성을 위해 커넥션을 안 받는 메서드를 따로 남겨두었습니다.
@Service
@RequiredArgsConstructor
public class TransferServiceV2 {
private final DataSource dataSource; // 커넥션 관리를 위해 DataSource가 들어감
private final AccountRepositoryV2 accountRepository;
public void transfer(String from, String to, int amount) {
Connection con = null;
try {
con = dataSource.getConnection();
con.setAutoCommit(false); // 트랜잭션 시작
// 트랜잭션 범위 내에서 같은 Connection 사용
Account accountFrom = accountRepository.findById(con, from);
Account accountTo = accountRepository.findById(con, to);
// 송금자 계좌에서 금액 차감
accountFrom.setBalance(accountFrom.getBalance() - amount);
accountRepository.update(con, accountFrom.getId(), accountFrom.getBalance());
// 예외 발생 조건
if (to.equals("accountEx")) {
throw new IllegalStateException("예외 발생: accountEx는 송금 불가 대상입니다.");
}
// 수신자 계좌에 금액 증가
accountTo.setBalance(accountTo.getBalance() + amount);
accountRepository.update(con, accountTo.getId(), accountTo.getBalance());
con.commit(); // 성공 시 커밋
} catch (Exception e) {
if (con != null) {
try {
con.rollback(); // 실패 시 롤백
} catch (SQLException rollbackEx) {
rollbackEx.printStackTrace();
}
}
throw new RuntimeException(e); // 예외 전파
} finally {
if (con != null) {
try {
con.setAutoCommit(true); // autoCommit 원상복구
con.close(); // 커넥션 반환
} catch (SQLException closeEx) {
closeEx.printStackTrace();
}
}
}
}
}
코드가 상당히 괴랄해진 걸 볼 수 있습니다. 이제 서비스에서 직접 커넥션을 만들고, con.setAutoCommit(false)를 통해 트랜잭션을 시작합니다. 비즈니스 로직은 달라지지 않았습니다. 비즈니스 로직이 정상적으로 끝나고 나면 con.commit()으로 커밋을, 예외 발생 시 con.rollback()로 롤백합니다. 코드의 문제점을 더 살펴보기 전에 기존의 문제가 해결되었는지 확인해보겠습니다.
@SpringBootTest
class TransferServiceV2Test {
@Autowired
TransferServiceV2 transferService;
@Autowired
AccountRepositoryV2 accountRepository;
@BeforeEach
void before() {
// 계좌 초기화
accountRepository.save(new Account("accountA", 100000));
accountRepository.save(new Account("accountB", 50000));
accountRepository.save(new Account("accountEx", 50000));
}
@AfterEach
void after() {
// 계좌 삭제
accountRepository.delete("accountA");
accountRepository.delete("accountB");
accountRepository.delete("accountEx");
}
/**
* A가 B에게 만원을 송금하면 성공한다.
*/
@Test
void transferSuccess() {
transferService.transfer("accountA", "accountB", 10000);
Account accountA = accountRepository.findById("accountA");
Account accountB = accountRepository.findById("accountB");
assertThat(accountA.getBalance()).isEqualTo(90000);
assertThat(accountB.getBalance()).isEqualTo(60000);
}
/**
* A가 B에게 만원을 송금 중 예외가 발생하면 롤백된다.
*/
@Test
void transferFail() {
assertThatThrownBy(() -> transferService.transfer("accountA", "accountEx", 10000))
.isInstanceOf(RuntimeException.class);
Account accountA = accountRepository.findById("accountA");
Account accountEx = accountRepository.findById("accountEx");
assertThat(accountA.getBalance()).isEqualTo(100000); // 트랜잭션이 롤백되어 A의 돈이 그대로 유지
assertThat(accountEx.getBalance()).isEqualTo(50000); // Ex는 만원 못 받음
}
}
이제 예외는 발생하지만 accountA의 돈은 90000원이 아닌 트랜잭션 시작 이전 값인 100000원으로 유지되고 있습니다. 트랜잭션을 도입한 덕분에 원자성을 지키는 서비스 로직을 구현할 수 있었습니다.
그런데 코드를 살펴보면... 서비스 레이어의 코드가 어마어마해진 것을 볼 수 있습니다. 예제는 최대한 단순하게 작성된 것이고 실제로는 여러 서비스 클래스에 걸쳐 수많은 비즈니스 로직이 존재합니다. 때문에 이런 수많은 비즈니스 로직들마다 트랜잭션을 위해 실제 비즈니스 로직과는 무관한 '트랜잭션'이라는 부가 기능 코드가 서비스 코드 곳곳에 섞이게 됩니다.
비즈니스 레이어의 클래스는 최대한 POJO(Plain Old Java Object)의 형태, 즉, 가능한 한 외부 기능에 의존하지 않고 순수한 자바 코드 형태를 유지하는 것이 좋다고 여겨집니다. 이는 확장성과 유지보수성을 위한 것으로, 더 자세한 내용은 Layered Architecture에 대해서 알아보시면 좋습니다.
중복되는 코드를 어떻게 처리할까 쳐다보고 있으면, 어디선가 보던 패턴이 보입니다.
바로 트랜잭션 부가 기능 로직이 순수 비즈니스 로직의 앞뒤로 위치하고 있다는 점입니다. 다른 비즈니스 로직이 늘어나도 트랜잭션을 처리하기 위해 커넥션을 생성하고, 비즈니스 로직을 실행하고, 성공 시 커밋 혹은 실패 시 롤백하는 순서는 바뀌지 않습니다. 따라서 이 앞뒤로 샌드위치처럼 싸고 있는 부가 기능만 모듈화할 수 있으면 진짜 좋겠다는 생각이 듭니다.
하지만 일반적인 객체 지향 프로그래밍으로는 이것을 해결하기 쉽지 않습니다. 그 이유는 객체 지향에서는 메서드 단위로만 기능을 분리할 수 있기 때문입니다. 어찌됐건 트랜잭션과 같은 공통 로직을 순서대로 호출해줘야 한다는 것에는 변함이 없습니다. 이런 한계를 극복하기 위해 등장한 것이 AOP(Aspect Oriented Programming)이며, 이에 대한 자세한 설명은 이전 포스트를 참고해주세요.
AOP를 알고나니, 아! 트랜잭션을 처리하는 Aspect를 만들고 특정 어노테이션을 만들어서, 해당 어노테이션을 가리키는 포인트컷을 트랜잭션 어드바이스에 지정해주면 딱 좋겠다! 생각이 듭니다. Spring은 이미 - Spring에서는 좀 좋겠다 싶은건 정말 왠만하면 다 만들어져 있습니다 - 이것을 준비해두었습니다. 그게 바로 @Transactional입니다.
그럼 @Transactional 어노테이션을 활용해 중복되는 코드 문제를 해결해보겠습니다.
@Repository
@RequiredArgsConstructor
public class AccountRepositoryV3 {
private final DataSource dataSource;
public Account save(Account account) {
String sql = "insert into account(id, balance) values (?, ?)";
Connection con = null;
PreparedStatement stmt = null;
try {
con = dataSource.getConnection();
stmt = con.prepareStatement(sql);
stmt.setString(1, account.getId());
stmt.setInt(2, account.getBalance());
stmt.executeUpdate();
return account;
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
}
public Account findById(String accountId) {
String sql = "select * from account where id = ?";
Connection con = null;
try {
con = DataSourceUtils.getConnection(dataSource); // 트랜잭션 동기화된 커넥션
try (PreparedStatement stmt = con.prepareStatement(sql)) {
stmt.setString(1, accountId);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
Account account = new Account();
account.setId(rs.getString("id"));
account.setBalance(rs.getInt("balance"));
return account;
} else {
throw new NoSuchElementException(accountId);
}
}
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public void update(String accountId, int balance) {
String sql = "update account set balance=? where id=?";
Connection con = null;
try {
con = DataSourceUtils.getConnection(dataSource);
try (PreparedStatement stmt = con.prepareStatement(sql)) {
stmt.setInt(1, balance);
stmt.setString(2, accountId);
stmt.executeUpdate();
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public void delete(String accountId) {
String sql = "delete from account where id=?";
Connection con = null;
PreparedStatement stmt = null;
try {
con = dataSource.getConnection();
stmt = con.prepareStatement(sql);
stmt.setString(1, accountId);
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
}
}
이제 커넥션을 다시 매개변수로 받지 않고, 대신 DataSourceUtils라는 녀석이 나왔습니다. @Transactional을 처리하는 어드바이스(대충 누가 이 어노테이션이 달려있는 메서드를 어딘가로 건져가서 처리한다고 보셔도 됩니다)는 커넥션을 만들어 TransactionSynchronizationManager라는 것에 연결하는데, 이게 ThreadLocal로 동작합니다. ThreadLocal은 또 뭐냐 하면, 자바에서 각 스레드마다 독립된 값을 저장할 수 있게 해주는 저장소입니다. ThreadLocal에 저장된 데이터는 다른 스레드에서 접근할 수 없습니다. DataSourceUtils는 이렇게 저장된 커넥션에 접근할 수 있게 합니다. 커넥션은 직접 매개변수로 넘기지 않아도 되는 것입니다.
@Service
@RequiredArgsConstructor
public class TransferServiceV3 {
private final AccountRepositoryV3 accountRepository;
@Transactional
public void transfer(String from, String to, int amount) {
Account accountFrom = accountRepository.findById(from);
Account accountTo = accountRepository.findById(to);
accountFrom.setBalance(accountFrom.getBalance() - amount);
accountRepository.update(accountFrom.getId(), accountFrom.getBalance());
if (to.equals("accountEx")) {
throw new IllegalStateException("예외 발생: accountEx는 송금 불가 대상입니다.");
}
accountTo.setBalance(accountTo.getBalance() + amount);
accountRepository.update(accountTo.getId(), accountTo.getBalance());
}
}
최초의 순수 비즈니스 로직 버전(V1)과 거의 같아졌습니다. @Transactional 어노테이션 하나만, 트랜잭션 처리가 필요한 메서드 위에 붙어있습니다.
코드는 변하지 않고 주입받는 빈만 각각 V3로 달라졌기 때문에 생략합니다.

이렇게 스프링 AOP 기술을 이용하면 서비스 계층을 트랜잭션 로직으로부터 독립적으로 관리할 수 있습니다.
이제야 드디어 항상 별 생각 없이 쓰던 @Transactional을 이해할 수 있게 되어 기쁩니다. 이 다음에는 스프링 트랜잭션의 좀 더 깊이 있는 내용을 다뤄보려고 합니다.