Spring/Spring boot

[JDBC] 트랜잭션 2

챛채 2023. 8. 17. 20:11

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

1. 문제점들

애플리케이션 구조

 

여러가지 애플리케이션 구조가 있지만 가장 많이 사용하는 방법은 역할에 따라서 3가지 계층으로 나누는 것이다. 

프레젠테이션 계층에서는 UI 관련된 처리를 담당하며 웹 요청과 응답을 다루고 사용자의 요청을 검증하고 주로 서블릿과 HTTP 같은 웹 기술, 스프링 MVC를 사용한다.

 

서비스 계층에서는 비즈니스 로직을 담당하며 가급적 특정 기술에 의존하지 않고 순수 자바 코드로 작성을 한다.

 

데이터 접근 계층에서는 실제 DB에 접근하는 코드를 작성하며 JDBC, JPA, Redis 등 다양하다.

 

이 계층들 중에서 가장 중요한 계층은 핵심 비즈니스 로직이 들어가 있는 서비스 계층이다. 다른 계층 기술들이 변해도 비즈니스 로직은 최대한 변경 없이 유지되어야하기  때문이다.

이렇게 유지가 되려면 서비스 계층을 특정 기술에 종속적이지 않게 개발해야 한다. 계층을 나눈 이유도 서비스 계층을 최대한 순수하게 유지하기 위한 목적이 크다.

 

만약 데이터 접근 계층에서 JDBC를 사용하다가 JPA로 변경해도 서비스 계층은 변경하지 않아도 되는데 서비스 계층에서 데이터 접근 계층을 직접 접근하는 것이 아닌 인터페이스를 제공하고 서비스 계층은 인터페이스에 의존하는 것이다. 그러면 서비스 코드 변경 없이 JDBCRepository를 JpaRepository로 변경할 수 있다.

 

[MemberServiceV1.java]

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

위 코드는 특정 기술에 종속적이지 않고 순수한 비즈니스 로직만 존재한다. 그래서 코드가 깔끔하고 유지보수 하기 쉽다. 나중에 비즈니스 로직 변경이 필요하면 이 부분을 변경하면 된다.

 

하지만 사실 아예 종속적이진 않다 SQLException이라는 JDBC 기술에 의존을 하고 있기 때문인데 이 부분은 memberRepository에서 올라오는 예외이기 때문에 memberRepository에서 해결해야 한다. 

 

그리고 MemberRepositoryV1이라는 구체 클래스에 직접 의존을 하고 있다. MemberRepository 인터페이스를 도입하면 후에 MemberService의 코드 변경 없이 다른 구현 기술로 손쉽게 변경할 수 있다.

 

다음 코드는 트랜잭션을 적용한 코드이다.

 

[MemberServiceV2.java]

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {

    private final MemberRepositoryV2 memberRepository;
    private final DataSource dataSource;

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

트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작하는 게 좋지만 트랜잭션 사용을 위해서는 JDBC 기술에 의존을 해야 한다.

그래서 이 코드는 비즈니스 로직보다 트랜잭션 처리 코드가 더 많아서 유지보수 하기 어렵다.

 

2. 문제 정리

지금까지 개발해온 애플리케이션에는 트랜잭션, 예외 누수, JDBC 반복 이 세가지 문제가 있다.

하지만 이 중에서도 가장 큰 문제는 트랜잭션을 적용하며 생긴 문제들이다.

 

트랜잭션 문제

  • JDBC 구현 기술이 서비스 계층에 누수되는 문제
    • 서비스 계층은 특정 기술에 종속되지 않아야 하는데 트랜잭션을 적용하면서 서비스 계층에 JDBC 구현 기술의 누수가 발생했다.
  • 트랜잭션 동기화 문제 
    • 같은 트랜잭션을 유지하기 위해서 커넥션을 파라미터로 넘겨야하는데 이때 파생되는  문제들도 있다. 똑같은 기능도 트랜잭션용 기능과 트랜잭션을 유지하지 않아도 되는 기능으로 분리해야 한다.
  • 트랜잭션 적용 반복 문제
    • try, catch, finally 등 반복되는 코드가 많다.

예외 누수

SQLException은 JDBC 전용 기술로 이후 JPA나 다른 데이터 접근 기술을 사용하면 그에 맞는 다른 예외로 변경해야 하고 결국엔 서비스 코드도 수정해야 한다.

 

JDBC 반복 문제

try, catch, finally 등 반복되는 코드가 많다.

 

3. 트랜잭션 추상화

구현 기술에 따른 트랜잭션 사용법

JDBC : con.setAutoCommit(false)

JPA : transaction.begin()

 

만약 JDBC 트랜잭션에 의존하다가 JPA 기술로 변경을 하게 되면 서비스 계층의 트랜잭션을 처리하는  코드도 모두 함께 변경해야 하는데 이를 해결하기 위해선 트랜잭션 기능을 추상화 하면 된다. 즉, 인터페이스를 만들어 사용을 하면 된다.

public interface TxManager {
    begin();
    commit();
    rollback();
}

다음과 같이 TxManager 인터페이스 기반으로 각각 기술에 맞는 구현체를 만들면 된다.

 

이러면 이제 서비스는 특정 트랜잭션 기술에 직접 의존하는 것이 아닌 TxManager라는 추상화된 인터페이스에 의존 한다. 앞으로는 원하는 구현체를 DI를 통하여 주입하면 된다.

 

스프링 트랜잭션 추상화

스프링은 이미 이런 고민을 다 해둔 상태라 그저 스프링이 제공하는 트랜잭션 추상화 기술을 사용하면 된다. 

스프링 트랜잭션 추상화의 핵심은 PlatformTransactionManager 인터페이스이다.

 

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

PlatformTransactionManager 인터페이스와 구현체를 포함하여 트랜잭션 매니저로 줄여서 언급하려한다. 

 

4. 트랜잭션 동기화

트랜잭션 매니저는 트랜잭션 추상화와 리소스 동기화 크게는 2가지 역할을 한다.

 

리소스 동기화

트랜잭션 유지를 위해서 같은 데이터베이스 커넥션을 유지해야한다고 언급한 적이 있는데 결국 같은 커넥션을 사용하기 위해 전에는 파라미터로 커넥션을 전달하는 방법을 사용했다.

하지만 이 방법은 커넥션 넘기는 메서드와 넘기지 않는 메서드를 중복해서 만들어야 하는 등 여러 단점들이 있다.

 

이를 해결하기 위해 스프링은 트랜잭션 동기화 매니저를 제공하는데 내부에서 쓰레드 로컬(ThreadLocal)을 사용해서 커넥션을 동기화 해준다. 

이전처럼 파라미터로 커넥션을 전달하지 않아도 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득하면 된다.

 

트랜잭션을 시작하려면 커넥션이 필요한데 트랜잭션 매니저는 데이터 소스를 통해 커넥션을 만들고 트랜잭션을 시작한다.

후에 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관을 하고 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 트랜잭션이 종료된 후에는 트랜잭션 매니저가 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션도 닫는다.

 

5. 트랜잭션 매니저1

[MemberRepositoryV3.java]

/*
*  트랜잭션 - 트랜잭션 매니저
* DataSourceUtils.getConnection() 사용
* DataSourceUtils.releaseConnection()
* */
@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;
        /*
        * PreparedStatement는 SQL Injection을 방어할 수 있다. -> ?로 바인딩
        * */

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId()); //sql에 대한 파라미터 바인딩
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally{
            //시작과 역순으로 close
            /*
            pstmt.close(); //만약 Exception이 터지면 다음 코드도 호출 안 될 수 있음
            con.close();
            */
            close(con, pstmt, null); //리소스 정리는 반드시 필수로 해줘야함!
        }
    }


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

        Connection con = null;
        PreparedStatement pstmt =null;
        ResultSet rs = null;

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

            //실행
            rs = pstmt.executeQuery();
            if (rs.next()) {
                //최초의 커서는 데이터를 가리키고 있지 않기 때문에 최초 한번은 호출을 해야 데이터 조회 가능
                Member member = new Member();
                member.setMemberId(rs.getString("member_id"));
                member.setMoney(rs.getInt("money"));
                return member;
            } else {
                throw new NoSuchElementException("member not found memberId=" + memberId);
            }


        } catch (SQLException e) {
            log.error("db error",e);
            throw e;
        }finally {
            close(con, pstmt, rs);
        }
    }
    /*
     * 회원 수정
     * */
    public void update(String memberId, int money) throws SQLException {
        String sql ="update member set money=? where member_id=?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setInt(1, money);
            pstmt.setString(2, memberId);
            int resultSize = pstmt.executeUpdate();
            log.info("resultSize={}", resultSize);
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }


    /*
     * 회원 삭제
     * */
    public void delete(String memberId) throws SQLException {
        String sql = "delete from member where member_id=?";
        Connection con = null;
        PreparedStatement pstmt = null;
        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    /*
    * 해제는 rs -> pstmt -> con 으로
    * */
    private void close(Connection con, Statement stmt, ResultSet rs) {

        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        //주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
        DataSourceUtils.releaseConnection(con, dataSource);

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

DataSourceUtils.getConnection()

트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환한다. 관리하는 커넥션이 없는 경우는 새로운 커넥션을 생성해서 반환한다.

 

DataSourceUtils.releaseConnection()

커넥션을 con.close()를 사용해서 직접 닫아버리면 커넥션이 유지되지 안흔 문제가 발생한다. 이 커넥션은 트랜잭션 종료까지 살아 있어야한다. 

DataSourceUtils.releaseConnection()을 사용하면 커넥션을 바로 다는 것은 아니다. 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다. 

 

[MemberServiceV3_1.java]

/*
* 트랜잭션 - 트랜잭션 매니저
* */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {

    //    private final DataSource dataSource;
    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 static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

transactionManager.getTransaction()

트랜잭션을 시작하고 TransactionStatus status를 반환하는데 여기에는 현재 트랜잭션의 상태 정보가 포함되어 있다. 트랜잭션을 커밋, 롤백할 때 필요하다.

 

new DefaultTransactionDefinition()

트랜잭션과 관련된 옵션 지정이 가능하다.

 

[MemberServiceV3_1Test.java]

/*
* 트랜잭션 - 트랜잭션 매니저
* */
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);
        memberRepository = new MemberRepositoryV3(dataSource);
        PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        /*
        new DataSourceTransactionManager(dataSource)는 JDBC 기술을 사용하므로 JDBC용 트랜잭션 매니저를 선택해서 서비스에 주입
        데이터소스를 통해 커넥션을 생성하므로 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);
    }
}

 

 

6. 트랜잭션 매니저2

  1. 서비스 계층에서 transactionManager.getTransaction()을 호출하여 트랜잭션 시작 하는데
  2. 트랜잭션 매니저는 내부에서 데이터 소스를 사용해 커넥션을 생성한다.
  3. 커넥션을 수동 커밋 모드로 변경하여 실제 DB 트랜잭션을 시작한다.
  4. 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  5. 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. 

 

6. 이후 서비스는 비즈니스 로직을 실행하면서 리포지토리 메소드를 호출한다. (커넥션을 파라미터로 전달 X)

7. 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하므로 리포지토리는 DataSourceUtils.getConnection()을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내 사용한다.

8. 획득한 커넥션을 사용해 SQL을 DB 전달해 실행한다.

 

 

9. 비즈니스 로직이 끝난 후 트랜잭션을 종료한다.

10. 트랜잭션을 종료하기 위해서는 동기화된 커넥션이 필요한데 트랜잭션 동기화 매니저를 통해서 동기화된 커넥션을 획득한다.

11. 획득한 커넥션 통해서 DB에 트랜잭션 커밋하거나 롤백한다.

12. 전체 리소스 정리하는데 트랜잭션 동기화 매니저를 정리한다. (쓰레드 로컬은 사용 후 반드시 정리) -> 자동 커밋으로 되돌려 놓고 con.close() 호출해 종료한다.

 

 

7. 트랜잭션 템플릿

트랜잭션을 사용할 때 마다 try, catch, finally 등 코드가 반복되는 게 많다. 이럴 땐 템플릿 콜백 패턴을 활용하면 깔끔하게 해결이 가능하다.

 

트랜잭션 템플릿

콜백 패턴을 적용하려면 템플릿을 제공하는 클래스를 작성해야 하는데 스프링은 TransactionTemplate라는 클래스를 제공한다.

 

[MemberServiceV3_2.java]

/*
* 트랜잭션 - 트랜잭션 템플릿
* */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_2 {

//    private final PlatformTransactionManager transactionManager;
    private final TransactionTemplate txTempalte; 
    private final MemberRepositoryV3 memberRepository;
	
    //생성자 주입
    public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
        this.txTempalte = new TransactionTemplate(transactionManager);
        this.memberRepository = memberRepository;
    }

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

        txTempalte.executeWithoutResult((status) -> {
            try {
                bizLogic(fromId,toId,money);
            } catch (SQLException e) {
                throw new IllegalStateException(e);
            }
        });
    }
    private void bizLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(fromId);
        Member toMember = memberRepository.findById(toId);

        memberRepository.update(fromId, fromMember.getMoney() - money);
        validation(toMember); //검증
        memberRepository.update(toId, toMember.getMoney() + money);
    }
    private static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

TransactionTemplate을 사용하려면 transactionManager가 필요한데 생성자에서 transactionManager를 주입 받으면서 TransactionTemplate을 생성했다.

 

accountTransfer메서드를 보면 커밋, 롤백하는 코드가 모두 제거 되었다. 트랜잭션 템플릿의 기본 동작은 비즈니스 로직이 정상 수행되면 커밋하고 언체크 예외가 발생하면 롤백을 한다. 

원래는 코드에서 예외 처리를 위해서 try ~ catch가 들어갔는데 bizLogic()을 호출하면 SQLException 체크 예외를 넘겨준다. 즉 언체크 예외로 바꾸어 던지도록 예외를 전환했다.

 

[MemberServiceV3_2Test.java]

/*
* 트랜잭션 - 트랜잭션 템플릿
* */
class MemberServiceV3_2Test {

    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_2 memberService;

    @BeforeEach
    void before() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV3(dataSource);
        PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        memberService = new MemberServiceV3_2(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);
    }
}

테스트 내용은 기존과 같다.

 

정리

트랜잭션 템플릿으로 인해서 반복 코드 제거가 가능해졌다. 하지만 서비스 로직인데 비즈니스 로직 뿐만 아니라 기술 로직이 함께 포함되어 있다. 

서비스 로직엔 가급적이면 핵심 비즈니스 로직만 있어야 하는데 이럴 땐 다음의 스프링 AOP를 통해 프록시를 도입하면 문제를 해결할 수 있다.

 

8. 트랜잭션 AOP 

 

프록시를 사용하면 트랜잭션 처리 객체와 비즈니스 로직 처리하는 서비스 객체를 분리할 수 있다.

스프링 AOP를 적용하려면 어드바이저, 포인트컷, 어드바이스가 필요한데 스플이 부트를 사용하면 해당 빈들은 스프링 컨테이너에 자동으로 등록되고 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다.

 

트랜잭션 AOP 적용

 

[MemberServiceV3_3.java]

/*
* 트랜잭션 - @Transactional AOP
* */
@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 static void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}

@Transactional 애노테이션은 메서드에 붙여도 되고 클래스에 붙여도 되는데 클래스에 붙이면 외부에서 호출 가능한 public 메서드가 AOP 적용 대상이 된다.

 

 

[MemberServiceV3_3Test.java]

/*
* 트랜잭션 - @Transactional AOP
* */
@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
    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();
    }

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

 

@SptringBootTest 

스프링 AOP를 적용하려면 스프링 컨테이너가 필요한데 이 애노테이션을 사용하면 테스트시 스프링 부트를 통해서 스프링 컨테이너를 생성하고 @Autowired를 통해 스프링 컨테이너가 관리하는 빈들을 사용할 수 있다.

 

@TestConfiguration

테스트 안에서 내부 설정 클래스 만들어 애노테이션을 붙이면 스프링 부트가 자동으로 만들어주는 빈들에 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 수 있다.

 

@TestConfig

DataSource 스프링에서 기본으로 사용할 데이터소스를 스프링 빈으로 등록한다. 추가로 트랜잭션 매니저에서도 사용한다.

DataSourceTransactionManager 트랜잭션 매니저를 스프링 빈으로 등록한다. 왜냐하면 트랜잭션 AOP는 스프링 빈에 등록된 트랜잭션 매니저를 찾아 사용하기 때문에 등록해두어야 한다.

 

정리

 

선언적 트랜잭션 관리(Declarative Transaction Management)

@Transactional 애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 선언적 트랜잭션 관리라 한다. 

 

프로그래밍 방식 트랜잭션 관리(Programmatic transaction management)

트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용하여 트랜잭션 관련 코드를 직접 작성하는 것을 프로그래밍 방식 트랜잭션 관리라고 한다.

 

보통 실무에서는 선언적 트랜잭션 관리가 간편하고 실용적이라 주로 쓰인다. 프로그래밍 방식은 테스트 시에 가끔 사용되는 편이다.

 

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

        @Bean
        DataSource dataSource(){
            return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        }

        @Bean
        PlatformTransactionManager transactionManager() {
            return new DataSourceTransactionManager(dataSource());
        }

스프링 부트 이전에는 데이터소스의 트랜잭션 매니저를 직접 스프링 빈으로 등록해서 사용했어야 한다. 하지만 스프링 부트를 쓰면서 많은 부분이 자동화되었다. 

 

데이터 소스 - 자동 등록

스프링 부트는 DataSource를 스프링 빈에 자동으로 등록하는데 

application.properties에 있는 속성을 사용해서 DataSource를 생성하고 등록한다.

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=

 

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

스프링 부트는 적절한 트랜잭션 매니저(PlateformTransactionManager)를 자동으로 스프링 빈에 등록한다. JDBc를 사용하면 DataSourceTransactionManager를 빈으로 등록하고 JPA를 사용하면 JpaTransactionManager를 빈으로 등록한다. 

 

[MemberServiceV3_4Test.java]

/*
* 트랜잭션 - DataSource, transactionManager 자동 등록
* */
@Slf4j
@SpringBootTest
class MemberServiceV3_4Test {

    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{
    	//데이터 소스와 트랜잭션 매니저를 스프링 빈으로 등록하는 코드 삭제
        private final DataSource dataSource;

        public TestConfig(DataSource dataSource) {
            this.dataSource = dataSource;
        }
        @Bean
        MemberRepositoryV3 memberRepositoryV3(){
            return new MemberRepositoryV3(dataSource);
        }

        @Bean
        MemberServiceV3_3 memberServiceV3_3() {
            return new MemberServiceV3_3(memberRepositoryV3());
        }
    }
    ...

 

application.properties에 지정된 속성을 참고해서 데이터 소스와 트랜잭션 매니저를 자동으로 생성해준다. 생성자를 통해서 스프링 부트가 만들어준 데이터소스 빈을 주입 받을 수도 있다.

자세한 설정 속성은 아래를 참고하면 된다.

https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html

 

Common Application Properties

 

docs.spring.io