스프링/5. 스프링 DB-1

25. 트랜잭션 매니저

sdafdq 2023. 10. 1. 17:50

저번 코드는 DB와 소통하는 리포지토리의 커넥터가 서비스까지 올라왔고, 

그로 인해 서비스가 특정 기술에 영향을 받게 되었다. 

서비스는 특정 기술에 의존 하면 안된다.

 

그러므로 트랜잭션 추상화에 대해 생각해 보았다.

편리하게도, 이미 스프링이 트랜잭션에 대한 인터페이스를 제공해 주고,

각 회사들이 구현체를 만들어 놨다.

 

이제 실제로 코드로 사용 해 볼 것이다.

 

 

@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){
            throw e;
        }finally {
            close(con, pstmt, rs);
        }
    }

    private void close(Connection con, Statement stmt, ResultSet rs){
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        DataSourceUtils.releaseConnection(con, dataSource);
    }

    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 result = pstmt.executeUpdate();
            log.info("result = {}",result);
        }catch(SQLException e){
            log.info("error",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);
        }
    }

     public Connection getConnection() throws SQLException {
        Connection con = DataSourceUtils.getConnection(dataSource);

        return con;
    }
}

다 비슷하다. 다른 점은 Close와 getConnection 할 때 DataSourceUtils로 사용한 다는 점이다.

무슨 차이가 있냐면, 먼저 close()의 

DataSourceUtils.relaseConnection() 했을 때 만약 내가 이전에 getConnection()을 했을 때 그것이 트랜잭션 동기화 매니저(쓰레드 로컬)에서 가져온 커넥션 이라면 커넥션을 닫지 않고 그냥 패스 한다.

 

그 다음 DataSourceUtils.getConnection(dataSource)

public static Connection doGetConnection(DataSource dataSource) throws SQLException {
    Assert.notNull(dataSource, "No DataSource specified");

    ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource);
    if (conHolder != null && (conHolder.hasConnection() || conHolder.isSynchronizedWithTransaction())) {
        conHolder.requested();
        if (!conHolder.hasConnection()) {
            logger.debug("Fetching resumed JDBC Connection from DataSource");
            conHolder.setConnection(fetchConnection(dataSource));
        }
        return conHolder.getConnection();
    }

이것은 실제 DataSourceUtils의 일부분이다.

보면 먼저 트랜잭션 동기화 매니저에서 데이터 소스를 넘겨서 커넥션을 가져와 본다(데이타 소스를 넘기는 이유는 어떤 커넥션 설정에 대한 정보? 를 알아야 하기 때문에 그런 것 같다. 뭐 그런 것도 있고, 그 이전에 더 중요한 이유는 이제 트랜잭션을 생성할 때 DataSourceUtils를 통해 생성하게끔 하기 위한 것 같다. 그래야 덧씌운다고 할까? 커넥션을 생성 하면서 생성 뿐 아니라 여러 설정들을 추가로 더 한다던지 등, 여튼 전역인 트랜잭션 동기화 매니저에 접근함.)

 

그리고, 만약 트랜잭션 동기화 매니저에 트랜잭션 매니저가 넣은 커넥션이 없다? (굳이 트랜잭션 안쓸수도 있으니까.)

그러면 그냥 커넥션 생성해서 반환 함(위의 코드들은 아직 커넥션 풀 이용하는 것은 아닌 듯. 하긴, 커넥션 풀에 대한 설정 한 부분은 없었음. 그냥 DrivaerManagerDataSource 로 JDBC 표준화 된 커넥션 얻는 걸 사용 하는 거임.)

 

@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{
            transferLogic(fromId, toId, money);
            transactionManager.commit(status);
        }catch(Exception e){
            transactionManager.rollback(status);
            throw new IllegalStateException();
        }finally {

        }
    }

    private void transferLogic(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("이체 중 예외 발생");
        }
    }
}

이제 트랜잭션 매니저로 commit 등을 하게 바뀜.

TransactionManager를 상속받아 표준화된 구현체라 나중에 주입 시에 바꿀 필요가 있다면 갈아 끼우기만 하면 됨.

transactionManager.getTransaction()이 트랜잭션 시작한다, 커넥션 만들고, 트랜잭션을 위한 설정(setAutoCommit 등)을 설정 해 주고, 트랜잭션 동기화 매니저(쓰레드로컬)에 커넥션을 넣어 준다는 얘기.

트랜잭션을 이렇게 얻게 됨.

 

그 다음 비즈니스 로직 수행.

수행하면서 커넥션 얻을 때 도, 

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 result = pstmt.executeUpdate();
        log.info("result = {}",result);
    }catch(SQLException e){
        log.info("error",e);
    }finally {
        close(con, pstmt,null);
    }
}

 public Connection getConnection() throws SQLException {
    Connection con = DataSourceUtils.getConnection(dataSource);

    return con;
}

커넥션을 DataSourceUtils에서부터 얻는 것 이기 때문에, 이건 아까 말 한대로 트랜잭션 동기화 매니저로 부터 얻기 때문에, transactionManager.getTransaction() 하면 커넥션 얻고, 트랜잭션에 관한 설정을 커넥션으로 쿼리 날려 그 DB세션에 해주고, 트랜잭션 동기화 매니저에 저장해 주기 때문에 연동된다.

 

그렇게 비즈니스 로직을 수행하고,

transactionManager.commit() 혹은 rollback() 해주면 커넥션을 닫고, 트랜잭션 동기화 매니저 에서도 삭제시켜 준다.

 

아까 내가 DataSourceUtils.relaseConnection() 했을 때 만약 그게 트랜잭션 동기화 매니저에서 가져온 커넥션이라면 닫지 않는다고 했는데, 이 commit()하거나 rollback() 했을 때 직접적으로 닫고 트랜잭션 동기화 매니저 에서도 삭제하는 것이다.

그래서, 서비스에서 트랜잭션이 끝난 후 내가 따로 release() 해 줄 필요가 없다.

 

 

 TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

이 부분에 대해서 설명하겠다.

먼저 new DefaultTransactionDefinition() 이 부분은,

말 그대로 기본 트랜잭션 정의이다.

이게 뭐냐면 트랜잭션에 관한 설정(예를 들어 타임아웃 몇초로 할건지 등)을 정할 수 있는데, 그거를 getTransaction() 해서 트랜잭션 얻을 때 같이 넣어주는 거다. 이번에는 그냥 기본 설정으로 넣어줬다.(뭐 기본 설정이니 setAutoCommit false로 하고 기타 등등 그런 거 겠지.)

그렇게 그러한 상태의 트랜잭션을 얻을 수(시작할 수) 있고, 그런 트랜잭션에 관한 상태를 반환받을 수 있다.

 

commit()하거나 rollback()할 때 status를 넣어 주는데, 이건 현재 트랜잭션의 상태와 설정 등을 가지고 있는 객체이다.

이미 트랜잭션 동기화 매니저와, 트랜잭션 매니저를 생성할 때 dataSource를 넣어주기 때문에 어떤 커넥션을 닫거나 롤백해야 할 지는 충분히 transactionManager가 알고 있을 것 같은데, 왜 status를 넣어주는지 모르겠다.

 

뭐 트랜잭션 전파나 그런 이유 때문이라는 것 같은데, 나중에 배운다고 한다.

 

 

 

public class MemberServiceV3Test {
    private static final String MEMBER_A = "memberA";
    private static final String MEMBER_B = "memberB";
    private static final String MEMBER_EX = "ex";

    private MemberRepositoryV3 memberRepository;
    private MemberServiceV3_1 memberService;

    @BeforeEach
    private void before() throws SQLException {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        memberRepository = new MemberRepositoryV3(dataSource);
        memberService = new MemberServiceV3_1(transactionManager, memberRepository);
    }

    @AfterEach
    private void after() throws SQLException {
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);
        int transferMoney = 2000;
        memberRepository.save(memberA);
        memberRepository.save(memberB);

        memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), transferMoney);

        Member findedMemberA = memberRepository.findById(memberA.getMemberId());
        Member findedMemberB = memberRepository.findById(memberB.getMemberId());

        assertThat(findedMemberA.getMoney()).isEqualTo(memberA.getMoney() - transferMoney);
        assertThat(findedMemberB.getMoney()).isEqualTo(memberB.getMoney() + transferMoney);
    }

    @Test
    @DisplayName("이체 중 예외 발생")
    void accountTransferEx() throws SQLException {
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_EX, 10000);
        int transferMoney = 2000;
        memberRepository.save(memberA);
        memberRepository.save(memberB);

        assertThatThrownBy(()->memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), transferMoney))
                .isInstanceOf(IllegalStateException.class);

        Member findedMemberA = memberRepository.findById(memberA.getMemberId());
        Member findedMemberB = memberRepository.findById(memberB.getMemberId());

        assertThat(findedMemberA.getMoney()).isEqualTo(memberA.getMoney());
        assertThat(findedMemberB.getMoney()).isEqualTo(memberB.getMoney());
    }
}

테스트.

같다. 그런데 이제는 트랜잭션 매니저를 주입시킨다.

 

 

 

 

 

동작을 정리하자면 다음과 같다.

1. getTransaction()을 시작하면, TransactionManager 생성할 때 데이터 소스를 줬으므로, 그걸 통해서 커넥션을 생성한다.

2, 3, 4, 5 : 생성하고, 트랜잭션을 위해 오토커밋모드를 false로 설정하고, 트랜잭션 동기화 매니저에 커넥션을 보관한다.

트랜잭션을 이용할 준비가 끝났다.

 

 

6. 저렇게 트랜잭션을 셋팅해 놓은 다음에, 로직을 수행하며 DB를 수정할 때에는,

7. DataSourceUtils.getConnection()을 이용하기 때문에, 먼저 트랜잭션 동기화 매니저에 보관된 커넥션을 사용하게 되는 것이다. 그럼 같은 커넥션(세션)을 사용하는 거기 때문에, 세션을 유지할 수 있다.

8. 계속해서 그 커넥션을 이용해서, 그 DB세션에 쿼리를 날린다.

 

 

9. TransactionManager를 commit을 실행 하거나 rollback 실행 한다. 

10. 그럼 먼저, 어떤 커넥션을 commit하거나 rollback할 지 그 커넥션을 꺼내와야 한다. 트랜잭션 동기화 매니저에서 꺼내온다.

11. 제대로 commit 혹은 rollback을 한다.

12. 이 때 제대로 커넥션을 닫고, 트랜잭션 동기화 매니저에서도 그 커넥션을 삭제한다.

 

 

 

 

트랜잭션 추상화 덕에 이제 JDBC기술에 의존하지 않아도 된다.

JPA면 JPA로 트랜잭션 매니저를 바꿔 끼우면 그만인 것이다.

 

아직 서비스 부분에서 SQLException, 예외가 JDBC기술을 의존하고 있긴 하지만, 예외부분은 나중에 알아볼 것이다.

 

DataSourceTransactionManager도 트랜잭션 매니저의 한 종류이며, 다른 트랜잭션 매니저 사용 시 그 기술에 맞게 사용하면 된다.

'스프링 > 5. 스프링 DB-1' 카테고리의 다른 글

27. 트랜잭션 AOP  (0) 2023.10.01
26. 트랜잭션 템플릿  (0) 2023.10.01
24. 트랜잭션 동기화  (0) 2023.09.30
23. 트랜잭션 추상화  (0) 2023.09.30
22. 기존 트랜잭션의 문제  (0) 2023.09.30