데이터 베이스에서 트랜잭션은 하나의 요청을 안전하게 처리하도록 보장해주는것을 뜻한다.
A의 5000원을 B한테 이체
A의 잔고 5000원감소 -> B의 잔고 5000원 증가.
그런데 1번만 성공하고 2번은 실패하면 문제,
고로 트랜잭션기능을 사용하면 1,2 둘다 성공해야 저장,
중간에 하나라도 실패하면 거래전의 상태로 롤백함
참고: 격리수준
아래로 갈수록 성능은 나빠지지만 격리성을 보장이 더 좋아짐
사용자가 DB 서버에 접근을 해야하면 -> 데이터베이스 서버에 요청을 보내고 커넥션을 맺음 -> 이때 세션을 만든다.
앞으로 해당 커넥션을 통한 모든 요청은 이 세션을 통해서 수행됨
Ex). 만약 클라이언트가 SQL을 전달하면, 현재 커넥션에 연결된 세션이 SQL실행
세션은 트랜잭션 시작, 커밋,롤백을 통해 트랜잭션 종료
사용가 커넥션을 닫거나 DBA가 세션을 강제종료하면 세션 종료됨
커넥션 풀이 10개의 커넥션을 생성하면, 세션도 10개만들어짐
일단 대부분 DB는 autocommit=true로 설정되어있음
자동커밋: 쿼리 하나하나당 commit이 자동으로 됨
수동 커밋: 커밋을 호출해야 반영이 됨
트랜잭션 기능을 수행하기 위해서는 수동 커밋 사용
set autocommit false;
insert into member(member_id,money) values ('data3',1000);
insert into member(member_id,money) values ('data4',1000);
현재 commit안해서 해당 세션에만 반영됨 -> 다른 세션엔 반영 x
commit;
커밋을해야 다른세션에서도 확인가능.
autocommit false에서 예외발생
세션1: 10000,10000
세션2: 10000,10000
set autocommit false;
update member set money=10000 - 2000 where member_id = 'memberA'; //성공
update member set money=10000 + 2000 where member_iddd = 'memberB'; //쿼리 예외 발생
commit을 안했으므로
세션1: 8000,10000
세션2: 10000,10000
if autocommit이면
세션1: 8000,10000 //첫번째 쿼리가 commit되서 DB에 반영됨
세션2: 8000,10000
if rollback하면
세션1: 10000,10000
세션2: 10000,10000
세션1이 트랜잭션 시작하고 아직 커밋을 안했는데, 세션2가 동시에 같은 데이터에 접근하게되면 문제발생, 원자성이 깨짐
=> 세션이 트랜잭션시작하고 데이터 접근할때는 커밋이나 롤백전까지 다른 세션이 해당 데이터 접근을 막아야함.
세션1
set autocommit false;
update member set money=500 where member_id = 'memberA';
아직 커밋을 안함 -> memberA의 data에 lock을 걸어둔 상태
세션2
SET LOCK_TIMEOUT 60000;
set autocommit false;
update member set money=1000 where member_id = 'memberA';
세션2도 memberA의 data를 수정하려고 한다.
세션1이 commit이나 rollback을 하지않아 lock을 풀어주지 않았으므로
쿼리가 수행되지 않고 대기한다.
세션1이 commit을 하면 그제서야 세션2가 다시 lock을 걸고 update를 수행한다.
당연히 세션2가 commit을 해야 해당 내용이 세션1에도 반영된다.
참고: SET LOCK_TIMEOUT < milliseconds> 해당 ms만큼 Lock을 획득하지 못하면 오류 발생
참고: 그냥 조회 할때는 원래 lock을 걸지 않는다.
세션1 에서 update 쿼리를 날리고 commit을 안하더라도
세션2에서 select쿼리를 날리면 commit전의 결과를 그냥 가져올 수 있음
만약, 조회를 하는데도 lock을 걸고 싶다면 => 세션1이 select from for update를 사용하면 commit을 하기 전까지, 세션2가 select가 아닌 update,insert 쿼리를 날리지 못한다.
MemberServiceV1
@RequiredArgsConstructor
public class MemberServiceV1 {
private final MemberRepositoryV1 memberRepositoryV1;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepositoryV1.findById(fromId);
Member toMember = memberRepositoryV1.findById(toId);
memberRepositoryV1.update(fromId,fromMember.getMoney()-money);
validation(toMember);
memberRepositoryV1.update(toId,toMember.getMoney()+money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")){
throw new IllegalArgumentException("이체중 예외발생");
}
}
}
처음 update메서드 fromId 돈 감소
toMember에 대한 validation메서드 호출하고
toMember에 대한 돈 증가.
if 트랜잭션 적용 x => validation에서 Exception을 던지면, toMember의 update메서드 호출 x -> fromMember의 돈만 차감
class MemberServiceV1Test {
public static final String Member_A = "memberA";
public static final String Member_B = "memberB";
public static final String Member_EX = "ex";
private MemberRepositoryV1 memberRepositoryV1;
private MemberServiceV1 memberServiceV1;
@BeforeEach
void before(){
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL,USERNAME,PASSWORD);
memberRepositoryV1 = new MemberRepositoryV1(dataSource);
memberServiceV1 = new MemberServiceV1(memberRepositoryV1);
}
@AfterEach
void after() throws SQLException {
memberRepositoryV1.delete(Member_A);
memberRepositoryV1.delete(Member_B);
memberRepositoryV1.delete(Member_EX);
}
@Test
@DisplayName("정상 이체")
void accountTransfer() throws SQLException {
//given
Member memberA = new Member(Member_A,10000);
Member memberB = new Member(Member_B,10000);
memberRepositoryV1.save(memberA);
memberRepositoryV1.save(memberB);
//when
memberServiceV1.accountTransfer(memberA.getMemberId(),memberB.getMemberId(),2000);
//then
Member findMemberA = memberRepositoryV1.findById(memberA.getMemberId());
Member findMemberB = memberRepositoryV1.findById(memberB.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
}
@Test
@DisplayName("오류 이체")
void accountTransferEX() throws SQLException {
//given
Member memberA = new Member(Member_A,10000);
Member memberEx = new Member(Member_EX,10000);
memberRepositoryV1.save(memberA);
memberRepositoryV1.save(memberEx);
//when
assertThatThrownBy(() -> memberServiceV1.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
.isInstanceOf(IllegalArgumentException.class);
//then
Member findMemberA = memberRepositoryV1.findById(memberA.getMemberId());
Member findMemberEX = memberRepositoryV1.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberEX.getMoney()).isEqualTo(10000);
}
}
오류이체 부분: memberEx의 id는 ex임, Service의 accountTransfer메서드에서 validation메소드 통과 x -> 결국 fromMember의 돈만 까임
참고: BeforeEach부분에서 DriverMangerDataSource를 통해서 커넥션으 ㄹ얻고 이걸, MemberRepository의 생성자에 주입해주는데, 이 생성자의 파라미터가 DataSource인터페이스임 -> 다형성 구현
이제는 트랜잭션을 사용하는 방식을 고려해보겠다.
하나의 비즈니스 로직 전체에 트랜잭션을 걸어야한다.
트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작
MemberRespositoryV2
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);
}finally {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(pstmt);
}
return null;
}
public void update(Connection con,String memberId,int money){
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);
int resultSize = pstmt.executeUpdate();
log.info("resultSize = {}",resultSize);
} catch (SQLException e) {
log.error("db error",e);
throw new RuntimeException(e);
}finally {
JdbcUtils.closeStatement(pstmt);
}
}
기존에 있던 두가지 비즈니스 로직을 변경,
기존에는 하나의 메서드마다 커넥션을 맺고, close할때 JdbcUtils.closeConnection(con);로 커넥션을 끊었음
MemberServiceV2
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {
private final DataSource dataSource;
private final MemberRepositoryV2 memberRepositoryV2;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
try{
con.setAutoCommit(false);
bizLogic(con,fromId,toId,money);
}catch (Exception e){
con.rollback();
throw new IllegalStateException(e);
}finally {
release(con);
}
}
private void release(Connection con) {
if (con != null){
try{
con.setAutoCommit(true);
con.close();
} catch (Exception e) {
log.info("error",e);
}
}
}
private void bizLogic(Connection con, String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepositoryV2.findById(con,fromId);
Member toMember = memberRepositoryV2.findById(con,toId);
memberRepositoryV2.update(con,fromId,fromMember.getMoney()-money);
validation(toMember);
memberRepositoryV2.update(con,toId,toMember.getMoney()+money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")){
throw new IllegalArgumentException("이체중 예외발생");
}
}
}
MemberServiceV2Test
@Test
@DisplayName("이체중 예외 발생")
void accountTransferEx() throws SQLException {
//given
Member memberA = new Member("memberA", 10000);
Member memberEx = new Member("ex", 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
//when
assertThatThrownBy(() ->
memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(),
2000))
.isInstanceOf(IllegalStateException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
//memberA의 돈이 롤백 되어야함
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
우선 acccountTransfer에서 IllegalStateException발생
-> memberEx의 update메서드 실행 x, 현재 memberA의 돈만 남감
-> catch구문에서 rollback 실행, memberA의 돈 복구
남은 문제: 애플리케이션에서 DB트랜잭션을 적용하면, 서비스 계층이 매우 지저분해지고, 복잡한 코드를 요구한다. 추가로 커넥션을 유지하도록 코드를 변경하는것도 어렵다. 다음시간에는 스프링을 사용하여서 이런문제를 해결할 것이다.
애플리케이션 구조
역할에 따라 3가지 계층으로 나누는것이 좋다.
그중, 서비스 계층은 비즈니스 로직이 들어있으므로, UI와 관련된 부분이 변하거나, 데이터저장 기술이 변해도, 비즈니스 로직은 최대한 변경없이 유지되야한다.
즉, 서비스 계층을 특정 기술에 종속적이지 않게 개발해야한다.
트랜잭션 적용전에 코드를 보면,
@RequiredArgsConstructor
public class MemberServiceV1 {
private final MemberRepositoryV1 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws
SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money);
}
}
이런식으로, 특정기술에 종속적이지 않았다.
커넥션을 맺고 try catch finally하는 부분은 전부 repository에 JDBC기술을 밀어넣었다.
이렇게 순수한 계층을 만들라했는데, 트랜잭션을 사용하게 되면서,
@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);
memberRepository.update(con, toId, toMember.getMoney() + money);
}
}
javax.sql.DataSource,java.sql.Connection,java.sql.SQLException등 JDBC기술에 의존해야되는 문제가 생기다.
결과적으로 비즈니스 로직보다 JDBC를 사용해서 트랜잭션을 처리하는 코드가 더 많아진다.
결국, 문제점
이 문제를 스프링을 사용하여서 해결해보자.
현재 서비스 계층은 트랜잭션을 사용하기 위해서 JDBC기술에 의존하고 있다.
그러나, 애초에 JDBC를 안사용하고 다른 데이터 접근기술을 사용 할수도 있다. JPA같은거, 그러면 당연히 JPA에서도 Connection을 얻을때 당연히 DataSource 인터페이스도 없고, DriverManger,HikariCP등 커넥션 풀 구현체도 다를것이다.
JDBC 트랜잭션코드
JPA 트랜잭션코드
그림과 같이 Repository에서 JDBC 기술을 사용하고, JDBC 트랜잭션에 의존했는데
Repository에서 이제 JPA 기술을 사용하려고 바꾸면, Service에서는 당연히 JDBC 트랜잭션에 의존했으므로, Service코드를 JPA트랜잭션에 의존하도록 전부 수정해야한다.
트랜잭션 추상화
트랜잭션은 단순하다, 트랜잭션 시작하고, 비즈니스 로직 수행이 끝나면 커밋, 롤백하면된다.
public interface TxManager {
begin();
commit();
rollback();
}
고로, 인터페이스를 만들고 각각 기술에 맞는 구현체를 만들면 된다.
그림처럼 서비스는 트랜잭션 추상화 인터페이스에만 의존하면되고,
심지어 스프링이 앞에 그림처럼, JPA 트랜잭션 코드와, JDBC 트랜잭션 코드가 다른데 이 해당 코드까지 이미 구현을 다 해두었다.
그러면, 우리는 DataSourceTransactionManager,JpaTransactionManager등 원하는 것을 갈아 끼우면 된다.
PlatformTransactionManager
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
트랜잭션을 유지하려면, 트랜잭션의 시작부터 끝까지 같은 커넥션을 유지해야한다.
이전에는, 같은 커넥션 사용을 위해서 해당 커넥션을 파라미터로 전달하였다.
스프링은, 트랜잭션 동기화 매니저를 제공한다. -> 쓰레드 로컬을 사용해서 커넥션 동기화
쓰레드 로컬: 쓰레드마다 별도의 저장소가 부여된다. 해당 쓰레드만 데이터 접근가능-> 동시에 쓰레드가 같은 커넥션을 사용하는 문제가 발생하지 않는다.
동작방식
트랜잭션 매니저를 사용할때는 DataSourceUtils를 사용한다.
Repository
package hello.jdbc.repository;
import hello.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(?, ?)";//sql쿼리문
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection(); // 커넥션맺고
pstmt = con.prepareStatement(sql); //PreparedStatement로 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 {
//항상 finally에 connection종료
close(con,pstmt,null);
}
}
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
PreparedStatement pstmt = null;
ResultSet rs = null;
Connection con = 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);
}finally {
close(con,pstmt,rs);
}
return null;
}
public void update(String memberId,int money){
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);
int resultSize = pstmt.executeUpdate();
log.info("resultSize = {}",resultSize);
} catch (SQLException e) {
log.error("db error",e);
throw new RuntimeException(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){
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;
}
}
close와 커넥션 부분에 DataSourceUtils를 사용하고 있다.
Service
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.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인터페이스를 두고, 해당 구현체를 주입받는다. 지금은 JDBC를 쓰므로 DataSourceTransactionManager를 주입받지만, JPA같은 기술로 변경시 JpaTransactionManager를 주입받으면 된다.
트랜잭션 시작: getTransaction메서드를 호출
커밋: transactionManager.commit(status)
롤백: trasactionManager.rollback(status)
Test
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
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 static org.assertj.core.api.Assertions.assertThat;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.sql.SQLException;
class MemberServiceV3_1Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
private MemberRepositoryV3 memberRepository;
private MemberServiceV3_1 memberService;
@BeforeEach
void before() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
PlatformTransactionManager transactionManager = new
DataSourceTransactionManager(dataSource);
memberRepository = new MemberRepositoryV3(dataSource);
memberService = new MemberServiceV3_1(transactionManager,
memberRepository);
}
@AfterEach
void after() throws SQLException {
memberRepository.delete(MEMBER_A);
memberRepository.delete(MEMBER_B);
memberRepository.delete(MEMBER_EX);
}
@Test
@DisplayName("정상 이체")
void accountTransfer() throws SQLException {
//given
Member memberA = new Member(MEMBER_A, 10000);
Member memberB = new Member(MEMBER_B, 10000);
memberRepository.save(memberA);
memberRepository.save(memberB);
//when
memberService.accountTransfer(memberA.getMemberId(),
memberB.getMemberId(), 2000);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberB.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberB.getMoney()).isEqualTo(12000);
}
@Test
@DisplayName("이체중 예외 발생")
void accountTransferEx() throws SQLException {
//given
Member memberA = new Member(MEMBER_A, 10000);
Member memberEx = new Member(MEMBER_EX, 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
//when
assertThatThrownBy(() ->
memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(),
2000))
.isInstanceOf(IllegalStateException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
//memberA의 돈이 롤백 되어야함
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
로직수행
트랜잭션 종료
★★★★★정리
1. 애플리케이션 서버가 DB에 접근할때, mysql oracle Db마다 접근방식이 다름
2. 커넥션을 맺고 세션을 통해서 DB에서 요청이 수행되는데 결국 DB에 접근하기 위해서는 커넥션을 얻어야함
3. 이것을 해결하기 위해서 Java 표준 JDBC 기술 등장, 애플리케이션 서버 -> JDBC -> DB접근(우리는 DB의 종류에 관계없이 JDBC만 의존하여, 각 디비마다 코드 고칠 필요 x)
4. JDBC를 사용한다면, DriverManger통해 직접 커넥션, 커넥션 풀 이용 두가지 경우 있음
커넥션 풀을 사용 -> 히카리, DriverManger등 다양한 방식이 있으므로, DataSource인터페이스를 만듬
5. 그런데 데이터 접근기술을 JDBC가 아니라 JPA를 사용할수도 있음, 당연히 JPA에서 트랜잭션 적용 코드 등이 다름, 서비스 계층의 코드를 전부다 바꿔야함
6. 서비스가 직접적으로 구현체에 의존하는것을 방지하기 위해서 이제 Platform TransactionManager인터페이스에 의존, 스프링이 구현체까지 다 만들어 놓음
7. 실제 PlatFormTransactionManager 작동과정은 위에 1~10
우리 서비스 로직에서 보면, try catch가 반복되는것을 볼 수 있다.
트랜잭션은 commit과 rollback을 해야하므로 당연한 수순이다.
중복되는 코드를 제거하기 위해서 트랜잭션 템플릿을 사용해보자.
TransactionTemplate
execute(): 응답값이 있을때
executeWithoutResult(): 응답값이 없을때
MemberServiceV3_2
@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);
}
});
}
...
}
PlatformTransactionManager를 한번 감싸서 Transactiontemplate에 주입시켜준다.
TransactionTemplate 기본동작: 비즈니스 로직이 정상 수행되면 커밋한다. 언체크 예외가 발생하면 롤백한다. 그외의 경우 커밋한다.
accountTransfer -> void -> executeWithoutResult메서드 사용
bizLogic호출만하면, getConnection, commit, rollback 다 해준다.
try catch를 쓴 이유: bizLogic이 throw SQLException 하므로,
만약 bizLogic에서 Exception을 던지지 않는다면 try catch도 안써도 된다.
but,이곳은 서비스 로직인데 비즈니스로직 + 트랜잭션 처리 기술로직도 함께 있다.
만약, 트랜잭션을 사용하고 싶지 않다면, 해당 서비스로직을 또 손대야만 한다.
고로, 서비스 로직은 비즈니스 로직만 수행해야하는 단일 책임원칙이 깨진 상태이다.
우선, 지금은 스프링 AOP와 프록시에 대해서 자세히 이해하지 않아도 된다.
@Transactional을 사용하면 스프링이 AOP를 사용해서 트랜잭션을 편리하게 처리해준다. 요정도만 이해해도 괜찮다.
스프링 AOP를 통해 프록시를 도입하면,
그림과 같이 트랜잭션만을 처리하는, 트랜잭션 프록시 객체가 트랜잭션 처리 로직을 모두 가져간다.
그리고 트랜잭션을 시작한 후에 실제 서비스를 대신 호출한다.
고로, 템플릿을 사용할때는 Service단에 private final TransactionTemplate txTemplate; 같이 트랜잭션 기술에 의존적인 부분이 있었다.
그러나 이제는 Service에서는 딱 비즈니스 로직만 있고, 트랜잭션을 처리해야하는 부분은 트랜잭션 프록시가 처리하고, 실제 서비스를 호출하는 구조를 띄게 된다.
@Slf4j
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_3(MemberRepositoryV3 memberRepository) {
this.memberRepository = 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("이체중 예외 발생");
}
}
}
이제 서비스 로직에 트랜잭션 관련 코드는 없다.
오직 트랜잭션이 필요한 메서드에 @Transactional을 붙여주면 된다.
Test
@Slf4j
@SpringBootTest
class MemberServiceV3_3Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
@Autowired
private MemberRepositoryV3 memberRepository;
@Autowired
private MemberServiceV3_3 memberService;
@TestConfiguration
static class TestConfig{
@Bean
DataSource dataSource() {
return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSource());
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource());
}
@Bean
MemberServiceV3_3 memberServiceV3_3() {
return new MemberServiceV3_3(memberRepositoryV3());
}
}
@AfterEach
void after() throws SQLException {
memberRepository.delete(MEMBER_A);
memberRepository.delete(MEMBER_B);
memberRepository.delete(MEMBER_EX);
}
@Test
@DisplayName("정상 이체")
void accountTransfer() throws SQLException {
//given
Member memberA = new Member(MEMBER_A, 10000);
Member memberB = new Member(MEMBER_B, 10000);
memberRepository.save(memberA);
memberRepository.save(memberB);
//when
memberService.accountTransfer(memberA.getMemberId(),
memberB.getMemberId(), 2000);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberB.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberB.getMoney()).isEqualTo(12000);
}
@Test
@DisplayName("이체중 예외 발생")
void accountTransferEx() throws SQLException {
//given
Member memberA = new Member(MEMBER_A, 10000);
Member memberEx = new Member(MEMBER_EX, 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
//when
assertThatThrownBy(() ->
memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(),
2000))
.isInstanceOf(IllegalStateException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
//memberA의 돈이 롤백 되어야함
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
@SpringBootTest를 사용하자.
스프링 AOP가 대신 트랜잭션을 실행해준다. 스프링이 제공하는 트랜잭션 AOP는 스프링 빈에 등록된 트랜잭션 매니저를 찾아서 사용한다.
빈으로 등록하기 위해서 @TestConfiguration을 사용하고,
DataSourceTransactionManager 트랜잭션 매니저를 빈으로 등록한다.
해당 트랜잭션 매니저가 사용해야하는 Service, 리포지토리, dataSource도 빈으로 등록해준다.
AOP 프록시 적용확인
@Test
void AopCheck(){
log.info("memberService class ={}",memberService.getClass());
log.info("memberRepository class = {}",memberRepository.getClass());
assertThat(AopUtils.isAopProxy(memberService)).isTrue();
assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
}
결과
Started MemberServiceV3_3Test in 1.548 seconds (process running for 3.576)
2024-06-09T15:28:23.409+09:00 INFO 12632 --- [jdbc] [ Test worker] h.jdbc.service.MemberServiceV3_3Test : memberService class =class hello.jdbc.service.MemberServiceV3_3$$SpringCGLIB$$0
2024-06-09T15:28:23.412+09:00 INFO 12632 --- [jdbc] [ Test worker] h.jdbc.service.MemberServiceV3_3Test : memberRepository class = class hello.jdbc.repository.MemberRepositoryV3
Repository에는 당연히 @Transactional같은 AOP사용 없음 -> 그대로 나옴
Service에서 보면
-> SpringCGLIB가 있는것을 볼 수 있음
-> 우리가 보는 서비스는 실제가 아니고 트랜잭션 프록시 코드이다.
-> 해당 트랜잭션 프록시 코드에 try catch 부분에 commit과 rollback에 해당하는 코드가 있고(트랜잭션 처리 로직)
-> 실제 서비스에 타깃을 호출하는 코드도 내부에서 다 포함하고있다.
@Transactional 이거 왜썼냐?
고로 개발자는 트랜잭션이 필요한곳에 @Transactional 애노테이션 하나만 추가하면된다. 나머지는 스프링 트랜잭션 AOP가 자동적으로 처리한다.
근데, 이전에 생각해보면 우리가 dataSource나 tansactionManager를 등록해준적이 없다.
application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
스프링부트는 application.properties에 있는 속성을 사용해서 DataSource를 생성한다.
트랜잭션 매니저 - 자동드록
스프링 부트가 적절한 트랜잭션 매니저(PlatformTransactionManager)를 자동으로 등록한다.
어떤 트랜잭션 매니저를 선택할지 현재 등록된 라이브러리를 보고 판단
그럼 실제로 쓸때는 dataSource를 빈으로 등록한걸 가져다 쓰면된다.
@SpringBootTest
class MemberServiceV3_4Test {
@TestConfiguration
static class TestConfig {
private final DataSource dataSource;
//Bean에 등록된걸 가져다 사용
public TestConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource);
}
@Bean
MemberServiceV3_3 memberServiceV3_3() {
return new MemberServiceV3_3(memberRepositoryV3());
}
}
...
}