트랜잭션 이해

고동현·2024년 6월 3일
0

DB

목록 보기
3/13

트랜잭션이란?

데이터 베이스에서 트랜잭션은 하나의 요청을 안전하게 처리하도록 보장해주는것을 뜻한다.
A의 5000원을 B한테 이체
A의 잔고 5000원감소 -> B의 잔고 5000원 증가.
그런데 1번만 성공하고 2번은 실패하면 문제,
고로 트랜잭션기능을 사용하면 1,2 둘다 성공해야 저장,
중간에 하나라도 실패하면 거래전의 상태로 롤백함

ACID

  • Atomicity: 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인것처럼 모두 성공하거나 모두 실패해야한다.
  • Consistency: 모든 트랜잭션은 일관성있는 데이터베이스 상태를 유지해야한다. ex). 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야한다.
  • Isolation: 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. ex). 동시에 같은 데이터를 수정하지 못하도록한다. 격리수준 선택가능
  • Durability: 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야한다. 중간에 시스템에 문제가 발생해도 로그를 통해 성공한 트랜잭션의 내용을 복구해야한다.

참고: 격리수준

  • Read Uncommited
  • Read Committed
  • Repeatable Read
  • Serializable

아래로 갈수록 성능은 나빠지지만 격리성을 보장이 더 좋아짐

데이터베이스 연결구조와 DB세션


사용자가 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

DB 락

세션1이 트랜잭션 시작하고 아직 커밋을 안했는데, 세션2가 동시에 같은 데이터에 접근하게되면 문제발생, 원자성이 깨짐
=> 세션이 트랜잭션시작하고 데이터 접근할때는 커밋이나 롤백전까지 다른 세션이 해당 데이터 접근을 막아야함.

  • 세션이 트랜잭션을 시작하기위해서, 먼저 데이터에대한 Lock을 먼저 획득하고 처리후에 Lock을 풀어준다, 다른 세션은 Lock이 걸려있는 데이터에는 접근하지 못한다.

세션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인터페이스임 -> 다형성 구현

트랜잭션 적용

이제는 트랜잭션을 사용하는 방식을 고려해보겠다.
하나의 비즈니스 로직 전체에 트랜잭션을 걸어야한다.

트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작

  • 비즈니스 로직 오류발생 -> 해당 서비스 전체가 rollback되야함
  • 트랜잭션을 시작하려면, set autocommit false를 해야하므로,
    서비스 계층에서 커넥션을 만들고, 커밋 혹은 rollback 후 커넥션 종료.
  • 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야한다.

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);
        }
    }

기존에 있던 두가지 비즈니스 로직을 변경,

  1. 같은 커넥을 사용하기 위해 파라미터로 넘어온 커넥션을 사용, 따라서 con = getConnection()은 있으면 안된다.
  2. 커넥션 유지가 필요한 두메서드는 리포지토리에서 커넥션을 닫으면 안된다.
    커넥션을 전달받은 리포지토리 뿐만 아니라 이후에도 계속 커넥션을 이어나가기 때문이다.
    이후 서비스 로직이 끝날때 트랜잭션을 종료하고 닫아야한다.

기존에는 하나의 메서드마다 커넥션을 맺고, 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("이체중 예외발생");
        }
    }
}
  1. 서비스에서 dataSource.getConncetion()으로 커넥션 얻음
  2. setAutoCommit(false)로 트랜잭션 시작
  3. 비즈니스 로직수행
  4. 성공시 con.commit()수행
  5. 만약 비즈니스 로직 수행시 오류 발생해서 catch로 이동하면 rollback();
  6. finally를 통해서 서비스에서 connection close;(커넥션 풀에 커넥션이 돌아가므로, setAutoCommit(true)로 해서 돌려준다.)

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를 사용해서 트랜잭션을 처리하는 코드가 더 많아진다.

결국, 문제점

  1. 트랜잭션 문제: JDBC구현기술이 서비스 계층에 누수되는 문제
  2. 예외 누수: SQLException(JDBC 전용기술이므로 JPA나 다른 데이터 접근 기술을 사용하면 그에 맞는 다른 예외로 변경해야하고, 서비스 코드 수정해야함)
  3. JDBC 반복문제: 커넥션을 열고, preparedstatement사용하고 결과 매핑하고, 실행하고, try catch finally등 여러가지 반복되는 코드들이 많다.

이 문제를 스프링을 사용하여서 해결해보자.

트랜잭션 추상화

현재 서비스 계층은 트랜잭션을 사용하기 위해서 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;
 }

트랜잭션 동기화

트랜잭션을 유지하려면, 트랜잭션의 시작부터 끝까지 같은 커넥션을 유지해야한다.
이전에는, 같은 커넥션 사용을 위해서 해당 커넥션을 파라미터로 전달하였다.

스프링은, 트랜잭션 동기화 매니저를 제공한다. -> 쓰레드 로컬을 사용해서 커넥션 동기화
쓰레드 로컬: 쓰레드마다 별도의 저장소가 부여된다. 해당 쓰레드만 데이터 접근가능-> 동시에 쓰레드가 같은 커넥션을 사용하는 문제가 발생하지 않는다.

동작방식

  1. 트랜잭션 매니저가 datasource를 통해 컨넥션을 만들고 트랜잭션 시작
  2. 트랜잭션 매니저가 해당 커넥션을 트랜잭션 동기화 매니저에 보관
  3. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용. 파라미터로 전달 x
  4. 트랜잭션 종료시, 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고, 커넥션을 닫는다.

트랜잭션 매니저로 문제해결

트랜잭션 매니저를 사용할때는 DataSourceUtils를 사용한다.

  • Connection: DataSourceUtils.getConnection();
  • Release: DataSourceUtils.releaseConnection();

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. 서비스 계층에서 transactionManager.getTransaction() 호출해서 트랜잭션 시작
  2. 트랜잭션을 시작하기 위해서 데이터베이스 커넥션이 필요, 트랜잭션 매니저가 내부에서 데이터 소스를 사용해서 커넥션 생성
  3. 커넥션을 autocommit false로 변경
  4. 해당 커넥션을 트랜잭션 동기화 매니저에 보관
  5. 트랜잭션 동기화 매니저가 쓰레드 로컬에 커넥션 보관

로직수행

  1. 서비스가 리포지토리의 메서드 호출 -> 이때 커넥션을 파라미터로 전달 x
  2. 트랜잭션 동기화 매니저가 관리하는 쓰레드를 꺼내서 리포지토리가 사용
  3. 획득한 커넥션을 사용하여 SQL을 DB에 전달

트랜잭션 종료

  1. 커밋,롤백시 트랜잭션 종료
  2. 트랜잭션 동기화 매니저를 통해서 동기화된 커넥션 획득
  3. 획득한 커넥션을 통해 DB에 트랜잭션을 커밋 또는 롤백함
  4. 전체 리소스 종료 autocommit을 true로 만들고 con.close 호출로 커넥션 종료-> 커넥션 풀에 반환

지금까지 총정리

★★★★★정리
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 이해

우선, 지금은 스프링 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에 해당하는 코드가 있고(트랜잭션 처리 로직)
-> 실제 서비스에 타깃을 호출하는 코드도 내부에서 다 포함하고있다.

트랜잭션 문제 해결 - AOP 정리


@Transactional 이거 왜썼냐?

  1. 트랜잭션을 사용하려면 set autocommit을 false해줘야한다.
  2. userA가 userB한테 계좌이체할때 오류를 설명, 즉 비즈니스 로직 전체에 commit 또는 rollback을 걸어야한다.
  3. 만약 Service에 트랜잭션을 설정하는 try catch finally를 사용하게 되면 서비스가 트랜잭션의 기술에 의존적이게 된다. -> 단일 책임 원칙이 깨진다. 서비스는 핵심 비즈니스 로직만 기술해야한다.
  4. JDBC와 JPA의 트랜잭션의 구현 try catch finally가 다르고, 또한 트랜잭션이 사용하고 싶지 않을 수도 있다. -> 이러면 Service코드를 다 뜯어 고쳐야한다.
  5. 그래서 해당 트랜잭션 기술을 스프링 AOP에 위임을 하게 된다. 스프링 AOP가 트랜잭션을 처리하기 위해서, 트랜잭션 매니저, datasource, serivce, repository등 설정정보를 빈으로 등록시켜줘야한다.
  6. 그러면 우리가 서비스에서 메서드가 호출되는것 같지만 -> 우리는 프록시에 요청을 하게 된다. 그러면 프록시가 빈에서 트랜잭션 매니저를 획득하고, 커넥션을 생성해서, 프록시가 실제 서비스를 호출하게 된다.

고로 개발자는 트랜잭션이 필요한곳에 @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)를 자동으로 등록한다.

어떤 트랜잭션 매니저를 선택할지 현재 등록된 라이브러리를 보고 판단

  • JDBC 사용하면 -> DataSourceTransactionManager
  • JPA 사용하면 -> JpaTransactionManager
  • 둘다 사용 -> JpaTransactionManager(JDBC기술 지원)

그럼 실제로 쓸때는 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());
        }
    }
    ...
 }
profile
항상 Why?[왜썻는지] What?[이를 통해 무엇을 얻었는지 생각하겠습니다.]

0개의 댓글