framework/spring

4. 스프링과 문제해결 - 트랜잭션

wooweee 2023. 9. 23. 14:59
728x90

1. 문제점

  • application의 구조

 

  • 프레젠테이션 계층
    • UI와 관련된 처리 담당
    • 웹 요청과 응답
    • 사용자 요청을 검증
    • spring MVC, web Servlet 기술
  • 서비스 계층
    • 비즈니스 로직 담당
    • 주 사용 기술 : 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성
  • 데이터 접근 계층
    • 실제 db에 접근하는 code
    • 주 사용 기술 : JDBC, JPA, File, Redis, Mongo ...

 

1.1. 서비스 계층

  • 가장 중요한 계층
  • UI 와 db 저장 기술들은 다른 기술로 변경 가능성이 높지만 비즈니스 로직은 최대한 변경없이 유지 되어야한다.
  • 서비스 계층은 특정 기술에 종속적이지 않게 개발되어야한다.
  • 서비스 계층을 최대한 순수하게 유지하기 위한 목적이 크다

  • service 계층은 가급적business logic만 구현하고 특정 구현 기술에 직접 의존해서는 안된다.
  • 이렇게 해야 향후 구현 기술이 변경될 때 변경의 영향 범위를 최소화 할 수 있다.

 

1.2. 실제 서비스 코드 비교

  • serviceV1 code
@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);
        validation(toMember); // 중간에 예외 발생
        memberRepository.update(toId, fromMember.getMoney() + money);

        // 이 부분에서 commit or rollback이 수행
    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")){
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

 

  • 특징
    • 특정 기술에 종속적이지 않고, 순수한 비즈니스 로직만 존재
    • 향후 비즈니스 로직의 변경이 필요하면 이 부분을 변경하면 된다.
    • 코드가 깔끔하고 유지보수하기 쉽다
  • 참고
    SQLException 이라는 JDBC 기술에 의존하지만 memberRepository에서 올라오는 예외이기 때문에 memberRepository에서 해결을 할 것이다.

 

  • serviceV2 code
package hello.jdbc.service;

/* *
 * 트랜잭션 - params 연동, pool을 고려한 close()
 * transaction == connection 시작 종료를 service에서 관리
 * service 핵심 로직에서 예외가 발생했을 경우 connection을 롤백을 해줘야 하기 때문
 * connection의 역할이 수동 commit 설정, commit, rollback
 *
 */
@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); // transaction 시작
            bizLogic(con, fromId, toId, money); // 비즈니스 로직 최대한 분리
            con.commit(); // 성공시, commit

        }catch (Exception e){
            con.rollback(); // 실패시, rollback
            throw new IllegalStateException(e);
        }finally {
            release(con); // connection 끊기
        }
        // 이 부분에서 commit or rollback이 수행
    }

    /* *connection 종료 method
    * setAutoCommit(true) 원복
    * 1. 원래 autoCommit은 true가 default값
    * 2. DriverMangerDatasource와 같은 connection Pool을 사용하지 않는 connection은 close()시, connection이 완전 제거되어
    *    자동으로 autoCommit으로 돌아가서 신경 쓸 필요 X
    * 3. HikariCP같이 connectionPool을 사용하는 dataSource는 connection이 해제되지 않으므로 수동으로 autoCommit true를 설정 필요
    * */
    private static void release(Connection con) {
        try{
            if (con != null){
                con.setAutoCommit(true); // 다음에 쓰는 사람을 위해서 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 = memberRepository.findById(con, fromId);
        Member toMember = memberRepository.findById(con, toId);

        memberRepository.update(con, fromId, fromMember.getMoney() - money);
        validation(toMember); // 중간에 예외 발생
        memberRepository.update(con, toId, fromMember.getMoney() + money);
    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")){
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

 

  • 특징
    • 트랜잭션이 비즈니스 로직이 있는 서비스 계층에서 시작하는 것이 좋다.
    • 하지만 트랜잭션을 사용하기 위해서 DataSource, Connection, SQLException 같은 jdbc 기술에 의존한다.
    • 향후 JDBC에서 JPA 혹은 MongoDB 같은 다른 기술로 변경시 서비스 코드도 모두 함께 변경되는 문제 발생한다.
    • 유지보수가 힘들게 된다.

 

1.3. 문제정리

  1. 트랜잭션 문제
  2. 예외누수 문제
  3. JDBC 반복 문제

 

  • 트랜잭션을 적용하면 생긴 문제들
    1. JDBC 구현 기술이 서비스 계층에 누수되는 문제
      • 트랜잭션 적용을 위해 JDBC 기술이 서비스 계층에 누수되었다.
      • 서비스 계층에 순수 서비스 코드만 넣으려고 repository 등 분리를 해서 최대한 노력했지만 transaction 적용을 하면서 서비스 계층에 JDBC 구현 기술의 누수가 발생했다.
    2. 트랜잭션 동기화 문제
      • 동일 트랜잭션 유지하기 위해 connection(con)을 params로 넘겼다.
      • params로 넘기는 바람에 동일 트랜잭션 유지 코드 아닌 코드 거의 중복 코드가 발생
    3. 트랜잭션 적용 반복 문제
      • try, catch, finally를 작성해야한다.
      • 서비스 계층이 늘어나면 늘어날 수록 중복코드가 늘어날 것이다.
  • 예외 누수
    • SQLException은 체크 예외이기 때문에 데이터 접근 계층을 호출한 서비스 계층에서 해당 예외를 처리해야한다.
    • 그런데 위의 예외는 JDBC 전용기술로 향후 JPA나 다른 data 접근 기술을 사용하면, 그에 맞는 다른 예외를 변경해야 하고, 결국 서비스 코드를 건들여야한다.
  • JDBC 반복문제
    • 반복이 너무 많다. try-catch-finally

 

  • 내가 느낀점
    • 의존성에 관한 문제들은 계속 발생한다.
    • 그 동안 해결 방안을 생각해보면 controller - service - repository 로 큰 맥락으로 구분하면 각각에 맞는 코드 위치로 최대한 몰아넣고 서로 연결을 시킬 경우 DI를 이용해서 연결을 한다.
    • 그리고 DI 까지 써서 각각의 코드로 옮겼는데 어쩔수 없이 다른 계층에 꼭 존재하는 경우 filter나 aop 같이 제일 앞단 혹은 제일 뒷단의 숨겨진 곳에서 처리하도록 한다.

    • 의존하지 않는다의 기준
      • 다른 곳의 코드를 받아오는 것자체가 문제 되는 것이 아니라 A class 변경이 이루어질 때 A를 쓰고 있던 B class 와 C class가 같이 코드를 바꾸는 것이 아니라 A class만 변경을 하면 되도록 작성된것이 의존되지 않은 것이다.
      • 이를 지키기 위해서 DI, interface를 이용한 추상화를 활용

    • transaction(==connection 생성 || 획득) 이 service 계층에 있어야 하는 이유
      • 논리적으로 service계층에서 connection을 생성 || 획득 하는 이유를 알지만 의존성 문제 및 코드가 복잡해지니깐  repository에 넣고 transaction을 처리하는 것도 방법이지 않나?
      • 맞는 말이지만, 그렇게 되면 transaction을 해야하는 sql 관련 코드를 계속 중복적으로 만들어내야 하고 repository 자체에서도 해당 목적에 맞지 않는 transaction 관심사가 들어가게 된다.
      • 그래서 service층에서 목적에 맞게 일단은 code를 구성했고 앞으로 해결해 나갈 것이다.
      • transaction을 하기 위해서는 connection이 service 층에서 시작되고 종료되야함이 핵심이다.

 

 

2. 트랜잭션의 추상화

  • 트랜잭션을 사용하는 방식은 JDBC, JPA마다 접근 기술(method())이 다르다.

 

 

2.1. 트랜잭션 추상화

  • 트랜잭션 : connection 시작(=트랜잭션 시작)하고, commit || rollback 하고, 종료하는 단순한 기능
  • 추상화 인터페이스를 만든다면 아래와 같을 것이다.
public interface TxManager {
    begin();
    commit();
    rollback();
}

 

  • 해당 interface에 맞는 구현체를 만들면 된다.
    • JdbcTxManager: JDBC 트랜잭션 기능을 제공하는 구현체
    • JpaManager: JPA 트랜잭션 기능을 제공하는 구현체

 

 

 

2.2. 스프링의 트랜잭션 추상화

 

 

  • 트랜잭션매니저라는 interface를 이용해서 트랜잭션을 추상화한다.
  • 해당 interface 명이 PlatformTransactionManger인 이유는 이미 ejb에서 TransactionManger class를 사용하고 있기 때문

 

  • PlatformTransactionManager 인터페이스
package org.springframework.transaction;

public interface PlatformTransactionManager extends TransactionManager {
    
    TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
    
    void commit(TransactionStatus status) throws TransactionException; 
    
    void rollback(TransactionStatus status) throws TransactionException;
}

 

  • method()
    1. getTransaction() : 트랜잭션을 시작
      기존에 이미 진행중인 트랜잭션이 있는 경우 해당 transaction에 참여 가능

    2. commit() : 트랜잭션 커밋
    3. rollback() : 트랜잭션 롤백

    4. close()는 따로 작성하지 않고 알아서 해준다.
      -  try-catch-finally에서 finally가 필요없어짐

 

3. 트랜잭션 동기화

  • 트랜잭션 매니저 역할 2가지
    1. 트랜잭션 추상화
    2. 리소스 동기화 : 트랜잭션의 시작과 끝까지 동일한 connection을 유지해야하는 것

 

 

3.1. 트랜잭션 동기화 매니저

 

  • 트랜잭션 동기화 매니저는 쓰레드 로컬을 사용해서 connection을 동기화 한다.
  • 트랜잭션 매니저는 내부적으로 트랜잭션 동기화 매니저 사용한다.

 

  • 동작방식
    1. 트랜잭션을 시작하려면 커넥션이 필요.
    2. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 만들고 트랜잭션을 시작
    3. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관
    4. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용
        따라서 파라미터로 커넥션을 전달하지 않아도 된다.
    5. 트랜잭션이 종료되면 트랜잭션 매니저는
      1. 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고, 
      2. 커넥션도 닫는다

 

 

4. 트랜잭션 문제 해결 - 트랜잭션 매니저1

 

4.1. MemberRepositoryV3

  • params로 con을 받는 method 모두 제거
package hello.jdbc.repository;

/* * 트랜잭션 - 트랜잭션 매너저
 * service에서 tx매니저에 저장한 connection을 사용하던가, 없으면 repository에서 connection을 만들어서 사용
 * DataSourceUtils.getConnection() // transaction 용 connection 연결
 * DataSourceUtils.releaseConnection(con, dataSource); // transaction 용 connection 끊기

 * * cf) JdbcUtils
 * JdbcUtils.closeResultSet(rs);
 * JdbcUtils.closeStatement(stmt);
 */
@Slf4j
public class MemberRepositoryV3 {

    // data source 생성
    // DI -> 이말은 설정 파일을 만들어서 그 안에 DataSource를 따로 보관하는 곳이 존재한다는 뜻
    private final DataSource dataSource;

    // 생성자 생성
    public MemberRepositoryV3(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    // 1. 등록
    public Member save(Member member) throws SQLException {
        String sql = "insert into member(member_id, money) values(? , ?)";

        Connection con = null;
        PreparedStatement pstmt = null; // sql params를 바인딩 받는 class

        try {
            // 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
            con = getConnection(); // 1. connection 연결

            pstmt = con.prepareStatement(sql); // 2. db랑 소통
            pstmt.setString(1, member.getMemberId()); // sql string values의 첫번째 ?
            pstmt.setInt(2, member.getMoney()); // sql string values의 두번째 ?
            pstmt.executeUpdate(); // db에서 실행됨

            return member;

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null); // close하는 과정에서 예외발생시 뒤에것도 close안되니깐 method로 분리
        }

    }

    // 2. 조회
    public Member findById(String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행
        ResultSet rs = null; // select query 문의 결과를 담고 있는 통

        try {
            // 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
            con = getConnection();

            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);

            rs = pstmt.executeQuery(); // 조회시에는 Query() 사용,  등록시 update 사용

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

    // transaction 용 findById 제거

    // 3. 수정
    public void update(String memberId, int money) throws SQLException {
        String sql = "update member set money=? where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행

        try {
            // 이제 getConnection하면 트렌잭션 동기화 매니저에 보관되어있는 connection을 이용한다.
            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 e;
        } finally {
            close(con, pstmt, null);
        }
    }

    // transaction 용 findById 제거

    // 4. 삭제
    public void delete(String memberId) throws SQLException {
        String sql = "delete from member where member_id = ?";

        Connection con = null; // db와 연결
        PreparedStatement pstmt = null; // sql문에 추가 값 넣고 실행

        try {
            con = getConnection();

            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);

            int resultSize = pstmt.executeUpdate();
            log.info("resultSize={}", resultSize);

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    // close() 예외 처리
    private void close(Connection con, Statement stmt, ResultSet rs) {
        // jdbc에서 제공하는 util
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
//        JdbcUtils.closeConnection(con);
        // 트랜젝션 동기화 매니저를 사용하려면 DataSourceUtils를 사용해야한다.
        DataSourceUtils.releaseConnection(con, dataSource);
    }

    private Connection getConnection() throws SQLException{
        // Connection con = dataSource.getConnection();
        // 트랜젝션 동기화 매니저를 사용하려면 DataSourceUtils를 사용해야한다.
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get connection={}, class={}", con, con.getClass());
        return con;
    }
}

 

  • DataSourceUtils.getConnection()
    • 트랜잭션 동기화 매너지의 connection을 사용하기 위한 접근 class
    • 트랜잭션 동기화 매니저가 관리하는 connection이 있을 시, 해당 connection 반환
    • 트랜잭션 동기화 매니저가 관리하는 connection이 없을 시, new connection 반환
      - transaction을 사용하지 않는 method의 경우에도 repository 자체적으로 connection 연결이 가능해졌다.
  • DataSourceUtils.releaseConnection()
    • con.close()로 인한 connection 유지(리소스 유지) 문제를 해결
    • 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지
    • 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다

 

 

4.2. MemberServiceV3_1

  • close()는 transaction manger가 알아서 해주기 때문에 finally{} 필요 없다
  • con을 params로 안넘겨도 된다.

  • transactionManger interface가 주입 받는 구현체
    1. JDBC : DataSourceTransactionManger
    2. JPA : JpaTransactionManger

  • TransactionManger method()
    • private final PlatformTransactionManager transactionManager; // 인터페이스 변수 사용
    • TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); // 트랜잭션 시작
    • transactionManager.commit(status);   // 트랜잭션 commit
    • transactionManager.rollback(status);   // 트랜잭션 rollback
package hello.jdbc.service;

/**
 * 트랜잭션 - 트랜젝션 매니저
 */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {
    // private final DataSource dataSource;
    // dataSource에 의존하지 않고 transactionManager라는 interface를 이용해서 다형성 유지, spring Tx 추상화 인터페이스
    private final PlatformTransactionManager transactionManager; // datasourceTransactionManager 구현체를 config file로 부터 DI
    private final MemberRepositoryV3 memberRepository;


    public void accountTransfer(String fromId, String toId, int money) throws SQLException {

        // Connection con = dataSource.getConnection();
        // transaction 시작 - transctionManger 내부에서 connection 생성
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

        try{
            // transaction 시작
            // con.setAutoCommit(false);
            bizLogic(fromId, toId, money); // 비즈니스 로직 최대한 분리
            // con.commit();
            transactionManager.commit(status);

        }catch (Exception e){
            // con.rollback();
            transactionManager.rollback(status);
            throw new IllegalStateException(e);
        }
        finally {
            // transaction manger가 알아서 close() 시킴
        }
    }
    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, fromMember.getMoney() + money);
    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")){
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

 

4.3. test code

package hello.jdbc.service;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class MemberServiceV3_1Test {

    private MemberRepositoryV3 memberRepository;
    private MemberServiceV3_1 memberService;

    @BeforeEach
    void before(){
        // 설정으로 관리한 것
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);

        // transaction manager은 내부에 dataSource를 params로 가져와야지 해당 dataSource를 통해서 connection을 만들던가 획득(connectionPool)할 수 있다.
        DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);

        memberRepository = new MemberRepositoryV3(dataSource);
        memberService = new MemberServiceV3_1(transactionManager, memberRepository); // transaction 사용 위해서 dataSource 넘김
    }

    @AfterEach
    void after() throws SQLException {
        memberRepository.delete("memberA");
        memberRepository.delete("memberB");
        memberRepository.delete("ex");
    }

    @Test
    @DisplayName("정상 거래")
    void accountTransfer() throws SQLException {
        //given
        Member memberA = new Member("memberA", 10000);
        Member memberB = new Member("memberB", 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("memberA", 10000);
        Member memberEx = new Member("ex", 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);
        //when
        // accountTransfer으 실제 내부적의 business logic인 bizLogic()에서 params를 4개를 받지만
        // accountTransfer은 3개의 params만 받으면 된다.
        assertThatThrownBy(()->memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);
        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}

 

 

5. 트랜잭션 문제 해결 - 트랜잭션 매니저2

  • transactionManger 와 Transaction 동기화 Manger 작동 원리

 

 

6. 트랜잭션 문제 해결 - 트랜잭션 템플릿

 

6.1. transactionTemplate

  • 템플릿 콜백 패턴 활용
  • connection - business logic - commit || rollback 을 하는 try-catch-finally 반복의 문제를 해결
  • transactionTemplate 내부에 transactionManger를 넣어서 내부적으로 반복 구문 수행
public class TransactionTemplate {
    private PlatformTransactionManager transactionManager;
    public <T> T execute(TransactionCallback<T> action){..}
    void executeWithoutResult(Consumer<TransactionStatus> action){..}
    }
}

 

  • execute() : 응답 값이 있을 때 사용
  • executeWithoutResult() : 응답 값이 없을 때 사용

 

6.2. MemberServiceV3_2

  • 반복부분 제거
  • 템플릿이 transaction 실행, comit, rollback 코드 모두 제거
package hello.jdbc.service;
/**
 * 트랜잭션 - 트랜젝션 템플릿
 */
@Slf4j
public class MemberServiceV3_2 {
    // private final DataSource dataSource; // dataSource에 의존하지 않고 transactionManager라는 interface를 이용해서 다형성 유지
    // private final PlatformTransactionManager transactionManager; // datasource를 config file에서 주입 받아야한다.

    private final TransactionTemplate txTemplate;
    private final MemberRepositoryV3 memberRepository;

    // 생성자
    public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
        // transaction template를 사용하려면 transactionManger를 주입 받아야한다.
        // transactionManger를 bean으로 등록 가능하지만 class여서 유연성이 없고 관례상 이렇게 많이 한다.
        this.txTemplate = new TransactionTemplate(transactionManager);
        this.memberRepository = memberRepository;
    }

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        /* *TxTemplate
        * 동일 람다식의 status랑 위의 status는 동일한 것
        * txTemplate 내부에 TxManager가 들어가 있다.
        * txTemplate에서 jdbc 관련 commit, rollback, connection 반복 code를 내부적으로 수행
        * bizlogic 성공시 commit, 실패시 rollback 수행
        * */
        txTemplate.executeWithoutResult((status) -> { // 반환 type이 void여서 WithoutResult 사용
            try {
                bizLogic(fromId, toId, money);
            } catch (SQLException e){
                throw new IllegalStateException(e);
            }
        });

        /* transaction 시작
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try{
            // transaction 시작
            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, fromMember.getMoney() + money);
    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")){
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

 

6.3. test code

package hello.jdbc.service;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class MemberServiceV3_2Test {

    private MemberRepositoryV3 memberRepository;
    private MemberServiceV3_2 memberService;

    @BeforeEach
    void before(){
        // 설정으로 관리한 것
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        // transaction manager은 내부에 dataSource를 가지고 있다. 그래서 service에서 transaction을 시작할 수 있다.
        DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        memberRepository = new MemberRepositoryV3(dataSource);
        memberService = new MemberServiceV3_2(transactionManager, memberRepository); // transaction 사용 위해서 dataSource 넘김
    }

    @AfterEach
    void after() throws SQLException {
        memberRepository.delete("memberA");
        memberRepository.delete("memberB");
        memberRepository.delete("ex");
    }

    @Test
    void accountTransfer() throws SQLException {
        //given
        Member memberA = new Member("memberA", 10000);
        Member memberB = new Member("memberB", 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
    void accountTransferEx() throws SQLException {
        //given
        Member memberA = new Member("memberA", 10000);
        Member memberEx = new Member("ex", 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);
        //when
        // accountTransfer으 실제 내부적의 business logic인 bizLogic()에서 params를 4개를 받지만
        // accountTransfer은 3개의 params만 받으면 된다.
        assertThatThrownBy(()->memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);
        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}

 

  • 정리
    • 트랜잭션 템플릿 덕분에, 트랜잭션을 사용할 때 반복하는 코드를 제거할 수 있었다.
      하지만 아직도 서비스 로직에 비즈니스 로직 뿐만 아니라 트랜잭션을 처리하는 기술 로직이 함께 포함되어 있다.

    • 애플리케이션을 구성하는 로직을 핵심 기능과 부가 기능으로 구분하자면 서비스 입장에서 비즈니스 로직은 핵심 기능이고, 트랜잭션은 부가 기능이다.

    • 이렇게 비즈니스 로직과 트랜잭션을 처리하는 기술 로직이 한 곳에 있으면 두 관심사를 하나의 클래스에서 처리하게 되어 유지보수하기 어려움

 

7. 트랜잭션 문제 해결 - 트랜잭션 AOP 이해

  • spring AOP를 통해 proxy를 도입하면 문제를 해결
  • spring이 @Transactional을 통해서 Proxy를 만들어서, 트랜잭션 처리 기술과 비즈니스 처리 기술을 분리했다는 것이 핵심

 

 

  • 프록시를 사용해서 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리
  • spring이 @Transactional annotation을 보고 내부적으로 transacation 코드와 비즈니스 로직이 합쳐지 프록시 객체를 생성

 

7.1. MemberServiceV3_3

package hello.jdbc.service;
/**
 * 트랜잭션 - @Transactional
 */
@Slf4j
//@Transactional class와 method 둘다 붙여도 된다.
public class MemberServiceV3_3 {
    /* transaction 관련 code 제거
    * private final TransactionTemplate txTemplate;
    *
    * public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
    *   this.txTemplate = new TransactionTemplate(transactionManager);
    *   this.memberRepository = memberRepository;
    * }
    *
    * txTemplate.executeWithoutResult((status) -> { // 반환 type이 void여서 WithoutResult 사용
    *   try {
    *       bizLogic(fromId, toId, money);
    *   } catch (SQLException e){
    *      throw new IllegalStateException(e);
    *   }
    * }
    * */

    private final MemberRepositoryV3 memberRepository;

    public MemberServiceV3_3(MemberRepositoryV3 memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional // spring AOP - proxy 사용 - 해당 어노태이션으로 transactionTemplate,manger 역할 다 수행
    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, fromMember.getMoney() + money);
    }

    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

 

 

7.2. 스프링이 제공하는 transaction AOP

 

  • 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공

  • 스프링부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록

  • 개발자는 트랜잭션 처리가 필요한 곳에  @Transactional  애노테이션만 붙여주면 된다.
    class, method 아무 곳에 어노테이션 작성 가능

  • 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용

 

7.3. @Transactional

 

  • org.springframework.transaction.annotation.Transactional

  • 참고
    • 스프링 AOP를 적용하려면 어드바이저, 포인트컷, 어드바이스가 필요
    • 스프링은 트랜잭션 AOP 처리를 위해 다음 클래스를 제공한다. 
    • 스프링 부트를 사용하면 해당 빈들은 스프링 컨테이너에 자동으로 등록
      1. 어드바이저:  BeanFactoryTransactionAttributeSourceAdvisor
      2. 포인트컷:  TransactionAttributeSourcePointcut
      3. 어드바이스:  TransactionInterceptor

 

7.4. 사용자 bean 등록 test

package hello.jdbc.service;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
@SpringBootTest // spring이 springContainer를 띄운다. - @Transactional 사용시, proxy에서 @bean을 사용하기 때문
class MemberServiceV3_3Test {

    @Autowired
    private MemberRepositoryV3 memberRepository;
    @Autowired
    private MemberServiceV3_3 memberService;

    // 이전 의존관계 주입을 하려는 memberRepository, memberService를 bean으로 등록해야 DI 가 가능해짐
    @TestConfiguration // testCode 내부에서 bean 등록하는 방법
    static class TestConfig{
        @Bean
        DataSource dataSource() { return new DriverManagerDataSource(URL,USERNAME,PASSWORD); }
        // transactionManger는 proxy(@Transactional)로부터 내부적으로 사용해야하기 때문에 bean 등록
        @Bean
        PlatformTransactionManager transactionManager(){
            return new DataSourceTransactionManager(dataSource());
        }
        @Bean
        MemberRepositoryV3 memberRepositoryV3() {
            return new MemberRepositoryV3(dataSource());
        }
        @Bean
        MemberServiceV3_3 memberServiceV3_3() {
            return new MemberServiceV3_3(memberRepositoryV3());
        }
    }

    /* *
    @BeforeEach
    void before(){
        // 설정으로 관리한 것
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        memberRepository = new MemberRepositoryV3(dataSource);
         memberService = new MemberServiceV3_3(memberRepository); // transaction 사용 위해서 dataSource 넘김
    }
    */

    @AfterEach
    void after() throws SQLException {
        memberRepository.delete("memberA");
        memberRepository.delete("memberB");
        memberRepository.delete("ex");
    }

    @Test
    void accountTransfer() throws SQLException {
        //given
        Member memberA = new Member("memberA", 10000);
        Member memberB = new Member("memberB", 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
    void accountTransferEx() throws SQLException {
        //given
        Member memberA = new Member("memberA", 10000);
        Member memberEx = new Member("ex", 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);
        //when
        // accountTransfer으 실제 내부적의 business logic인 bizLogic()에서 params를 4개를 받지만
        // accountTransfer은 3개의 params만 받으면 된다.
        assertThatThrownBy(()->memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);
        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }

    // proxy 적용 확인 test
    @Test
    void AopCheck() {
        log.info("memberService class={}", memberService.getClass());
        log.info("memberRepository class={}", memberRepository.getClass());
        Assertions.assertThat(AopUtils.isAopProxy(memberService)).isTrue();
        Assertions.assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
    }
}

 

 

  •  @SpringBootTest
    • 스프링 AOP를 적용하려면 스프링 컨테이너가 필요
    • 테스트시 스프링 부트를 통해 스프링 컨테이너를 생성
    • 테스트에서  @Autowired등을 통해 스프링 컨테이너가 관리하는 빈들을 사용 가능
  • @TestConfiguration
    • 테스트 안에서 내부 설정 클래스
    • 스프링 부트가 자동으로 만들어주는 빈 추가
    • 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 가능
  • TestConfig
    • DataSource  스프링에서 기본으로 사용할 데이터소스를 스프링 빈으로 등록
    • 추가로 트랜잭션 매니저에서도 사용
    • DataSourceTransactionManager  트랜잭션 매니저를 스프링 빈으로 등록
    • 스프링이 제공하는 트랜잭션 AOP는 Proxy 객체 생성 시, 스프링 빈에 등록된 트랜잭션 매니저를 찾아서 사용하기 때문에 트랜잭션 매니저를 스프링 빈으로 등록해두어야 한다. 
      - 참고로 springboot에서 자동 설정기능을 통해서 트랜잭션 매너저가 등록된다. properties에서 수정하면 된다.

 

  • proxy 적용 확인 test
    • memberService class=class hello.jdbc.service.MemberServiceV3_3$ $EnhancerBySpringCGLIB$$.
    • AOP 프록시가 적용 되었다.

 

7.5. 트랜잭션 AOP 정리

 

 

8. 스프링 부트의 자동 리소스 등록

 

8.1. 데이터소스 - 자동 등록

  • 스프링 부트는 데이터소스( DataSource )를 스프링 빈에 자동으로 등록

  • 자동으로 등록되는 스프링 빈 이름:  dataSource  , transactionManger

  • 참고로 개발자가 직접 데이터소스를 빈으로 등록하면 스프링 부트는 데이터소스를 자동으로 등록하지 않는다.

  • 스프링 부트는 application.properties에 있는 속성을 사용해서  DataSource를 생성하고 스프링 빈에 등록

  • application.properties
# data source springboot bean 등록
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=

 

  •  스프링 부트가 기본으로 생성하는 데이터소스 :  HikariDataSource(connection pool 제공)
  • 커넥션풀과 관련된 설정도  application.properties를 통해서 지정가능

  • spring.datasource.url  속성이 없으면 내장 데이터베이스(메모리 DB)를 생성하려고 시도

 

 

8.2. 트랜잭션 매니저 - 자동 등록  

  • 스프링 부트는 적절한 트랜잭션 매니저( PlatformTransactionManager )를 자동으로 스프링 빈에 등록
  • 자동으로 등록되는 스프링 빈 이름:  transactionManager  

  • 참고로 개발자가 직접 트랜잭션 매니저를 빈으로 등록하면 스프링 부트는 트랜잭션 매니저를 자동으로 등록하지 않는다.

  • 어떤 트랜잭션 매니저를 선택할지는 현재 등록된 라이브러리를 보고 판단
    • JDBC를 기술을 사용하면 DataSourceTransactionManager 를 빈으로 등록
    • JPA를 사용하면  JpaTransactionManager 를 빈으로 등록
    • 둘다 사용하는 경우  JpaTransactionManager를 등록

 

8.3. 자동 bean 등록 test

package hello.jdbc.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
 * 예외 누수 문제 해결
 * SQLException 제거
 * MemberRepository 인터페이스 의존
 */
@Slf4j
@SpringBootTest // spring이 springContainer를 띄운다.
class MemberServiceV4Test {

    @Autowired
    private MemberRepository memberRepository;
    @Autowired
    private MemberServiceV4 memberService;
    
    // 이전 @Before의 설정 들도 bean으로 등록 필요
    @TestConfiguration
    static class TestConfig {
        // spring이 자동으로 만들어줄 dataSource - dataSource 생성자 주입
        private final DataSource dataSource;
        public TestConfig(DataSource dataSource) { this.dataSource = dataSource; }
        @Bean
        MemberRepository memberRepository() { return new MemberRepositoryV5(dataSource); }
        @Bean
        MemberServiceV4 memberServiceV3_3() { return new MemberServiceV4(memberRepository()); }
    }

    @AfterEach
    void after() {
        memberRepository.delete("memberA");
        memberRepository.delete("memberB");
        memberRepository.delete("ex");
    }

    @Test
    void accountTransfer() {
        //given
        Member memberA = new Member("memberA", 10000);
        Member memberB = new Member("memberB", 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
    void accountTransferEx() {
        //given
        Member memberA = new Member("memberA", 10000);
        Member memberEx = new Member("ex", 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberEx);
        //when
        // accountTransfer으 실제 내부적의 business logic인 bizLogic()에서 params를 4개를 받지만
        // accountTransfer은 3개의 params만 받으면 된다.
        assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);
        //then
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
    // proxy 적용 확인 test
    @Test
    void AopCheck() {
        log.info("memberService class={}", memberService.getClass());
        log.info("memberRepository class={}", memberRepository.getClass());
        Assertions.assertThat(AopUtils.isAopProxy(memberService)).isTrue();
        Assertions.assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
    }
}