트랜잭션
데이터베이스 트랜잭션(Database Transaction)은 데이터베이스 관리 시스템 또는 유사한 시스템에서 상호작용의 단위이다. 여기서 유사한 시스템이란 트랜잭션이 성공과 실패가 분명하고 상호 독립적이며, 일관되고 믿을 수 있는 시스템을 의미한다. - 위키백과
트랜잭션이란 하나의 상호작용을 안전하게 처리해주는 것이다.
트랜잭션은 ACID를 보장해야 하는데
Atomicity(원자성)
Consistency(일관성)
Isolation(격리성)
Durability(지속성)
을 뜻한다.
운영체제의 임계구역과 상호배제를 생각하면 편할 듯 하다!
사용법은 commit/rollback 만 알면 된다.
데이터를 변경하고 그 결과를 반영하려면 commit,
반영하고 싶지 않으면 rollback을 호출하면 된다.
다른 세션에서 조회를 할때 commit이 되지 않는 이상 변경한 데이터로 조회되지 않는다.
(트랜잭션 작업을 할때는 데이터베이스의 autocommit 모드를 false로 해줘야 한다!)
DB Lock
아까 트랜잭션에서는 원자성을 보장해야 한다고 했었다.
만약 한 세션이 데이터를 수정하고 commit을 아직 하지 않았는데 다른 세션에서 해당 데이터를 수정하면 원자성이 깨지게 된다.
그래서 한 세션이 트랜잭션을 시작하면 데이터를 수정하는 도중에는 다른 세션에서 해당 데이터를 수정하는 것을 막아야 한다.
운영체제의 뮤텍스를 생각하면 편하다.
락은 SET LOCK_TIMEOUT <milliseconds> 로 설정할 수 있다.
지난번 Spring JDBC(2) 의 MemberRepository에 커넥션 유지가 필요한 부분을 추가해보면
package com.example.jdbc.repository;
import com.example.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;
import org.springframework.jdbc.support.JdbcUtils;
@Slf4j
public class MemberRepositoryV2 {
private final DataSource dataSource;
public MemberRepositoryV2(DataSource dataSource) {
this.dataSource = dataSource;
}
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
if (rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" +
memberId);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, rs);
}
}
public Member findById(Connection con, String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
if (rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" +
memberId);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(pstmt);
}
}
public void update(String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public void update(Connection con, String memberId, int money) throws
SQLException {
String sql = "update member set money=? where member_id=?";
PreparedStatement pstmt = null;
try {
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
JdbcUtils.closeStatement(pstmt);
}
}
public void delete(String memberId) throws SQLException {
String sql = "delete from member where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
private Connection getConnection() throws SQLException {
Connection con = dataSource.getConnection();
log.info("get connection={} class={}", con, con.getClass());
return con;
}
}
커넥션 유지가 필요한 findById 메서드와 update 메서드를 추가하였다.
이제 비즈니스 로직을 작성해보면
package com.example.jdbc.service;
import com.example.jdbc.domain.Member;
import com.example.jdbc.repository.MemberRepositoryV2;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {
private final DataSource dataSource;
private final MemberRepositoryV2 memberRepository;
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);
}
}
private void bizLogic(Connection con, String fromId, String toId, int money)
throws SQLException {
Member fromMember = memberRepository.findById(con, fromId);
Member toMember = memberRepository.findById(con, toId);
memberRepository.update(con, fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(con, toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
private void release(Connection con) {
if (con != null) {
try {
con.setAutoCommit(true);
con.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
}
이렇게 트랜잭션을 시작하려면 커넥션이 필요하도록 작성하고
성공시 커밋, 실패시 롤백을 해준다.
그런데 어떤 의문이 들 것이다.
'서비스 계층이 이래도 되나..?'
서비스 계층은 오직 '비즈니스 로직만' 들어있는게 좋다. (순수 자바 코드만)
특정 기술에 종속적이게 되면 그 기술을 변경하게 되면 서비스 로직을 변경해야 하기 때문이다.
기술을 얼마든지 변경해도 서비스 로직은 바뀌지 않게 구현하는 것이 좋다.
우리의 서비스 계층은 Connection이나 DataSource, SQLException 같은 JDBC 기술에 너무 의존해있다.
또한 MemberRepository도 반복적인 코드가 많고 예외도 누수된다.
해결을 해보도록 하자
문제 해결 - 트랜잭션 매니저
먼저 의존을 피하는 제일 먼저 떠오르는 방법은 저번에 한 추상화 방법이다.
TxManager을 통해 트랜잭션을 추상화할 수 있다.
코드에 적용을 해보면
package com.example.jdbc.repository;
import com.example.jdbc.domain.Member;
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
public class MemberRepositoryV3 {
private final DataSource dataSource;
public MemberRepositoryV3(DataSource dataSource) {
this.dataSource = dataSource;
}
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
if (rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" +
memberId);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, rs);
}
}
public void update(String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public void delete(String memberId) throws SQLException {
String sql = "delete from member where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() throws SQLException {
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={} class={}", con, con.getClass());
return con;
}
}
파라미터에 커넥션을 전달하는 부분이 없어졌다.
- DataSourceUtlis.getConnection() 은 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환한다.
- DataSourceUtils.releaseConnection() 은 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다.
서비스 로직도 다시 작성해보면
package com.example.jdbc.service;
import com.example.jdbc.domain.Member;
import com.example.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
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;
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {
private final PlatformTransactionManager transactionManager;
private final MemberRepositoryV3 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
TransactionStatus status = 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) throws
SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
- PlatformTransactionManager transactionManager 를 통해 트랜잭션 매니저를 주입받는다.
트랜잭션 템플릿
Spring의 TransactionTemplate을 통해 템플릿 콜백 패턴을 활용하여 try-catch 커밋 롤백 반복 문제를 해결할 수 있다.
package com.example.jdbc.service;
import com.example.jdbc.domain.Member;
import com.example.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.SQLException;
@Slf4j
public class MemberServiceV3_2 {
private final TransactionTemplate txTemplate;
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_2(PlatformTransactionManager transactionManager,
MemberRepositoryV3 memberRepository) {
this.txTemplate = new TransactionTemplate(transactionManager);
this.memberRepository = memberRepository;
}
public void accountTransfer(String fromId, String toId, int money) throws
SQLException {
txTemplate.executeWithoutResult((status) -> {
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
} catch (SQLException e) {
throw new IllegalStateException(e);
}
});
}
private void bizLogic(String fromId, String toId, int money) throws
SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
- transactionManager을 주입 받으면서 TransactionTemplate 생성
이렇게 반복 코드를 제거할 수 있지만 service 계층에서 트랜잭션을 처리하는 로직과 비즈니스 로직이 같이 있는 문제는 해결되지 않았다.
트랜잭션 AOP
서비스 계층에 순수한 비즈니스 로직만 남길 수 있는 방법이다.
@Transactional 어노테이션을 사용하여 프록시를 도입한다.
package com.example.jdbc.service;
import com.example.jdbc.domain.Member;
import com.example.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import java.sql.SQLException;
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
@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) throws
SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
순수한 비즈니스 로직만 남게 되었다.
이를 선언적 트랜잭션 관리라고 한다.
하지만 SQLException은 JDBC 기술에 의존하지 않는가?
이는 다음 게시글에서 해결하도록 하겠다.
'Back-end > Java&Spring' 카테고리의 다른 글
Spring JPA (2) - JPA 동작 과정 (4) | 2024.09.24 |
---|---|
Spring JPA (1) - JPA 이해 (0) | 2024.09.23 |
Spring JDBC (4) - 예외 처리 (2) | 2024.09.12 |
Spring JDBC (2) - 커넥션 풀과 데이터 소스 (0) | 2024.09.11 |
Spring JDBC(1) - JDBC 이해 (1) | 2024.09.11 |