Spring에서 트랜잭션2

hoyong.eom·2023년 8월 13일
0

스프링

목록 보기
30/59
post-thumbnail

Spring

애플리케이션 구조

일반적으로 스프링 프로젝트는 아래와 같은 구조를 갖는다.

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

3가지 계층중에서 가장 중요한곳은 서비스 계층이다. 왜냐하면 프로젝트의 핵심 비지니스 로직이 들어있는 계층이기 때문이다.
시간이 흘러 UI와 데이터 저장 기술은 변경될 수 있어도 비지니스 로직은 변경하지 않고 유지되어야한다.(버그가 없다면!)

이를 위해서는 서비스 계층을 특정 기술에 종속적이지 않게 개발해야한다.
3가지 계층으로 나눈 이유도 서비스 계층을 최대한 순수하게 유지하기 위한 목적이 크다고 한다. 기술에 종속적인 부분은 프레젠테이션 계층, 데이터 접근 계층에서 가져간다.

서비스 계층이 특정 기술에 종속되지 않기 떄문에 비지니스 로직을 유지보수하기도 쉽고 테스트하기도 쉽다.

그리고 트랜잭션은 비지니스 로직이 존재하는 서비스 계층에서 시작을 해야한다. 왜냐하면 비지니스 로직이 수행되다가 실패한다면 그와 연관된 모든 로직을 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);//트랜잭션 시작
            //비즈니스 로직
            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);
        validation(toMember);
        memberRepository.update(con, toId, toMember.getMoney() + money);
    }

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

    private void release(Connection con) {
        if (con != null) {
            try {
                con.setAutoCommit(true); //커넥션 풀 고려
                con.close();
            } catch (Exception e) {
                log.info("error", e);
            }
        }
    }
}

위 코드의 문제를 나열해보면 아래와 같다.

  1. 서비스 계층에서 트랜잭션을 시작하기 위해서 아래와 같은 JDBC 기술에 의존한다.

    javax.sql.DataSource
    java.sql.Connection
    java.sql.SQLException

  2. 트랜잭션을 사용하기 위해서 JDBC 기술에 의존하며 비지니스 로직보다 JDBC를 이용해 트랜잭션을 처리하는 코드가 더 많다.

  3. JDBC에서 JPA 같은 다른 기술로 바꾸어 사용하기 위해서는 서비스 코드를 모드 변경해야한다.(트랜잭션 적용 방법이 다르기 때문에)

  4. 핵심 비지니스 로직과 JDBC 기술이 섞여 있어 유지보수가 어렵다.

4가지 정도의 문제가 존재하는데 이를 좀더 상세하게 설명하면 아래와 같다.

  • 트랜잭션 문제
  • 예외 누수 문제
  • JDBC 반복 문제

트랜잭션 문제

  • JDBC 구현 기술의 누수
    JDBC 구현 기술이 서비스 계층에 누수 된다. 서비스 계층은 순수한 자바 코드로 구성되어야 한다. 즉, 구현 기술을 변경 해도 서비스 계층 코드는 최대한 유지할 수 있어야 한다. 그래서 데이터 접근 계층에 JDBC 코드를 몰아둔다. 그런데 트랜잭션을 적용하면서 서비스 계층에 JDBC 구현 기술의 누수가 발생했다.

  • 트랜잭션 동기화 문제
    같은 트랜잭션을 유지하기 위해서 커넥션을 파라미터로 넘겨한다. 따라서 똑같은 기능도 트랜잭션용 기능과 트랜잭션을 유지하지 않아도 되는 기능으로 분리해야한다. (동일한 함수 2개를 만들어야 한다.)

  • 트랜잭션 적용 반복 코드 문제
    트랜잭션 적용 코드를 보면 반복된 코드가 매우 많다.

예외 누수

  • 데이터 접근 계층의 JDBC 구현 기술 예외가 서비스 계층으로 전파 된다.(SQLException)
  • SQLException은 JDBC 전용 기술이다. 향후 JPA나 다른 데이터 접근 기술을 사용하면 그에 맞는 다른 예외로 변경해야 하고, 결국 서비스 코드도 수정해야한다.

JDBC 반복 코드 문제

try,catch,finally와 같은 유사한 코드가 많이 반복된다.
커넥션을 열고, PreparedStatement를 사용하고 결과 맵핑 및 실행 등의 반복된 코드가 매우 중복된다.

지금까지 나열된 문제들은 스프링에서 모두 해결되었다!!!


스프링에서 문제 해결

트랜잭션 추상화

서비스 계층은 트랜잭션을 사용하기 위해서는 JDBC 기술에 의존하고 있다. 향후 JDBC에서 JPA 같은 다른 데이터 접근 기술로 변경하기 위해서 서비스 계층의 트랜잭션 관련 코드도 모두 수정되어야 한다.

이 문제를 해결하려면 트랜잭션 기능을 추상화하면된다.

  • 서비스는 특정 트랜잭션 기술에 직접 의존하는게 아니라, TxManager라는 추상화된 인터페이스에 의존한다. 이제 원하는 구현체를 DI를 통해서 주입하면 된다.
  • 클라이언트인 서비스는 인터페이스에 의존하고 DI를 사용한덕분에 OCP 원칙을 지키게 된다. 이제 트랜잭션을 사용하는 서비스 코드를 전혀 변경하지 않고 트랜잭션 기술을 마음껏 변경할 수 있다.

물론, 트랜잭션 추상화는 스프링이 이미 제공한다. 따라서 스프링이 제공하는 트랜잭션 추상화 기술을 사용하면 된다. 구현체도 이미 존재한다.

  • 스프링 트랜잭션 추상화의 핵심은 PlatformTransactionManager 인터페이스이다.
package org.springframework.transaction;

import org.springframework.lang.Nullable;

public interface PlatformTransactionManager extends TransactionManager {

	TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
			throws TransactionException;

	void commit(TransactionStatus status) throws TransactionException;

	void rollback(TransactionStatus status) throws TransactionException;

}
  • getTransaction() : 트랜잭션을 시작한다. 이미 진행중인 트랜잭션이 있는 경우에는 해당 트랜잭션에 참여한다.
  • commit() : 트랜잭션을 커밋한다.
  • rollback() : 트랜잭션을 롤백한다.

트랜잭션 동기화

스프링이 제공하는 트랜잭션 매니저는 크게 2가지 역할을 수행한다.

  • 트랜잭션 추상화
  • 리소스 동기화

트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터 베이스 커넥션을 유지해야한다. 결국 같은 커넥션을 동기화하기 위해서 이전에는 파라미터로 커넥션을 전달하는 방법을 사용했다.
파라미터로 커넥션을 전달하는 방법은 코드가 지저분해지는 것은 물론이고, 커넥션을 넘기는 메서드와 넘기지 않는 메서드를 중복해서 만들어야하는 단점들이 있다.

  • 스프링은 트랜잭션 동기화 매니저를 제공한다. 이것은 쓰레드로컬을 사용해서 커넥션을 동기화해준다. 트랜잭션 매니저는 내부에서 이 트랜잭션 동기화 매니저를 사용한다.
  • 트랜잭션 동기화 매니저를 쓰레드 로컬을 사용하기 떄문에 멀티 쓰레드 상황에서 안전하게 커넥션을 동기화할 수 있다. 따라서 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득하면 된다. 따라서 이전처럼 파라미터로 커넥션을 전달하지 않아도 된다.

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

참고)
트랜잭션 매니저가 알아서 이전 코드들에서 수행했던 release 함수의 역할 까지 수행한다. 즉 커밋이나 롤백을 하는 경우 트랜잭션 매니저 내부에서 알아서 커넥션과 리소스를 정리해준다.

리포지토리 코드
@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) {
            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);
        }

    }

    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(dataSource);
DataSourceUtils.releaseConnection(con, dataSource);

DataSourceUtils.getConnection()은 아래와 같이 동작한다.

  • 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을
  • 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해서 반환한다. 따라서 트랜잭션을 사용하지 않아도 사용할 수 있다.

DataSourceUtils.releaseConnection()

  • 커넥션을 con.clse()를 사용해서 직접 닫아버리면 커넥션이 유지되지 않는 문제가 발생한다. 이 커넥션은 이후 로직은 물론이고 트랜잭션을 종료할떄 까지 살아있어야한다.
  • DataSourceUtils.releaseConnection()을 사용하면 커넥션을 바로 닫지 않는다. 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다. 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.

아래의 코드는 트랜잭션 매니저를 이용한 서비스 계층 코드이다.

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

}
  • private final PlatformTransactionManager transactionManager : 트랜잭션 매니저 인터페이스를 이용해서 DI를 활용한다.
  • transactionManager.getTransaction() : 트랜잭션을 시작한다. TransanctionStatus를 반환하는데, 현재 트랜잭션의 상태 정보가 포함되어 있다. 이후에 트랜잭션을 커밋, 롤백할때 필요하다.
  • new DefaultTransactionDefinition() : 트랜잭션과 관련된 옵션을 지정할 수 있다.
  • transactionManager.commit(status) : 트랜잭션 커밋
  • transactionManager.rollback(status) : 트랜잭션 롤백
테스트 코드 
  @BeforeEach
    void before() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV3(dataSource);
        PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        memberService = new MemberServiceV3_1(transactionManager, memberRepository);
    }

위 테스트코드를 데이터소스 객체를 생성하고 트랜잭션 매니저는 DataSourceTransactionManager 구체 클래스로 생성하는걸 확인할 수 있다.
트랜잭션 매니저는 데이터소스를 이용해 커넥션을 생성하므로 DataSource가 필요하다.

트랜잭션 매니저의 전체 동작 흐름을 정리해보면 아래와 같다.

클라이언트의 요청으로 서비스 로직을 실행한다.

  1. 서비스 계층에서 transactionManager.getTransaction()을 호출해서 트랜잭션을 시작한다.

  2. 트랜잭션을 시작하려면 먼저 데이터베이스 커넥션이 필요하다. 트랜잭션 매니저는 내부에 데이터소스를 사용해서 커넥션을 생성한다.

  3. 커넥션을 수동 커밋모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.

  4. 커넥션을 트랜잭션 동기화 매니저 보관한다.

  5. 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. 따라서 멀티 쓰레드 환경에서 안전하게 커넥션 보관이 가능하다.

  6. 서비스는 비지니스 로직을 실행하면서 리포지토리의 메서드들을 호출한다.이때 커넥션을 파라미터로 전달하지 않는다.

  7. 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하다. 리포지토리는 DataSourceUtils.getConnection()을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이 과정을 통해서 자연스럽게 같은 커넥션을 사용하고 트랜잭션도 유지된다.

  8. 획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해 실행한다.

  9. 비지니스 로직이 끝나고 트랜잭션을 종료한다. 트랜잭션은 커밋하거나 롤백하면 종료된다.

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

  11. 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.

  12. 전체 리소르를 정리한다.
    -> 트랜잭션 동기화 매니저를 정리한다.
    -> con.setAutoCommit(true)로 되돌린다.
    -> con.close()를 호출해서 커넥션을 종료한다. 커넥션풀을 사용하는 경우 con.close()를 호출하면 커넥션 풀에 반환된다.

트랜잭션 추상화를 사용해서 서비스 코드는 더이상 JDBC 기술에 의존하지 않는다.

  • JDBC에서 JPA로 변경해도 서비스코드는 수정할 필요가 없다.
  • 기술 변경시에 의존관계 주입만 변경해주면 된다.
  • java.sql.SQLException이 아직 남아 있다....

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

트랜잭션 추상화를 사용하는 로직들을 보면 아직 같은 패턴이 반복된다.

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

    }
  • 트랜잭션을 시작하고, 비지니스 로직을 실행하고 ,성공하면 커밋하고 예외가 발생해서 실패하면 롤백한다.
  • 다른 서비스에서 트랜잭션을 시작하려면 try,catch,fianlly를 포함한 성공시 커밋, 실패시 롤백 코드가 반복된다.
  • 다르게 작성되는 부분은 비지니스 로직뿐이다.
  • 템플릿 콜백 패턴을 활용하면 반복 문제를 깔끔하게 해결가능하다.

트랜잭션 템플릿

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

참고)
템플릿 콜백 패턴은 말그대로 일정한 템플릿을 만들어놓고 템플릿 내부에서 호출하는 특정 함수만 각 구현체에서 구현하여 제공하는걸 의미한다.

public class TransactionTemplate {
 private PlatformTransactionManager transactionManager;
 public <T> T execute(TransactionCallback<T> action){..}
 void executeWithoutResult(Consumer<TransactionStatus> action){..}
}
  • exeucte() : 응답 값이 있을 때 사용한다.
  • executeWithoutResult() : 응답 값이 없을 때 사용한다.

따라서 우리는 execute() 또는 executeWithoutResult()에서 수행될 비지니스 로직만 구현해주면 된다.

executeWithoutResult 또는 execute 함수안에서 트랜잭션이 시작되고 종료된다. 그래서 반복된 코드가 제거된다.(로직안에서 트랜잭션도 시작된다.)

트랜잭션 템플릿을 사용해서 반복코드를 제거해보면 아래와 같다.

/**
 * 트랜잭션 - 트랜잭션 템플릿
 */
@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);
            }
        });
    }

    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("이체중 예외 발생");
        }
    }
}
  • TransactionTemplate를 사용하려면 transactionManager가 필요하다. 새엉자에서 transactioonManager를 주입받으면서 TransactionTemplate를 생성한다.
  • 트랜잭션 템플릿 덕분에(내부에서 템플릿 시작, 커밋, 롤백을 모두 수행해줌) 커밋하거나 롤백하는 코드가 모두 제거 되었다.
  • 트랜잭션 템플릿의 기본 동작은 아래와 같다
    - 비지니스 로직이 정상 수행되면 커밋한다.
    • 언체크 예외가 발생하면 롤백한다. 그외의 경우는 커밋한다.

정리
1. 트랜잭션 템플릿으로 트랜잭션을 사용할때 반복되는 코드들을 제거할 수 있다.
2. 그러나, 서비스 로직인데 비지니스 로직 뿐만 아니라 트랜잭션을 처리하는 로직이 섞여있다.


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

지금까지 트랜잭션을 편리하게 사용하고 처리하기 위해서 트랜잭션 추상화(트랜잭션 매니저)와 반복저인 트랜잭션 로직을 해결하기 위해 트랜잭션 템플릿을 도입했다.

하지만, 아직 서비스 계층에서 트랜잭션 로직과 비지니스 로직이 섞여있는 문제가 있다.
스프링 AOP를 통해 프록시를 도입하면 이 문제도 해결할 수 있다.

아래의 2가지 그림은 프록시 도입을 통해서 비지니스 로직과 트랜잭션 로직을 분리하는 모습이다.

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

아래의 코드는 이해를 돕기 위한 트랜잭션 프록시 예시 코드다

// 프록시 코드
public class TransactionProxy {
 private MemberService target;
 public void logic() {
 //트랜잭션 시작
 TransactionStatus status = transactionManager.getTransaction(..);
 try {
 //실제 대상 호출
 target.logic();
 transactionManager.commit(status); //성공시 커밋
 } catch (Exception e) {
 transactionManager.rollback(status); //실패시 롤백
 throw new IllegalStateException(e);
 }
 }
}
// 서비스 코드
public class Service {
 public void logic() {
 //트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
 bizLogic(fromId, toId, money);
 }
}
  • 프록시 도입 전 : 서비스에 비지니스 로직과 트랜잭션 처리 로직이 함께 섞여 있다.
  • 프록시 도입 후 : 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져 간다. 그리고 트랜잭션을 시작한 후에 실제 서비스를 대신 호출한다. 트랜잭션 프록시 덕분에 서비스 계층에는 순수한 비지니스 로직만 남길 수 있다.

참고
스프링이 제공하는 AOP기능을 사용해서 프록시를 편리하게 만들 수 있다.
AOP 기능으로 트랜잭션을 처리해도 되지만, 트랜잭션을 전세계 누구나 사용하는 기능으로 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공해준다. 스프링부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링빈들도 자동으로 등록해준다.
따라서, 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.

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

어드바이저: BeanFactoryTransactionAttributeSourceAdvisor
포인트컷: TransactionAttributeSourcePointcut
어드바이스: TransactionInterceptor

아래의 코드는 트랜잭션 AOP를 활용한 예시 코드이다.

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

}
  • 순수한 비지니스 로직만 남기고 트랜잭션 관련 코드는 모두 제거되었다.
  • @Transactional 애노테이션은 메서드에 붙여도 되고 클래스에 붙여도된다. 클래스에 붙이면 외부에서 호출 가능한 public 메서드가 AOP 적용 대상이 된다.

아래는 테스트 코드다.

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

트랜잭션 AOP를 사용하기 위해서는 필요한 재료들(DataSource, TransactionManager) 를 스프링빈으로 등록해놔야한다.

  • @SpringBootTest : 스프링 AOP를 적용하려면 스프링 컨테이너가 필요하다. 이 애노테이션이 있으면 테스트시에 스프링부트를 통해 스프링 컨테이너를 생성한다. 그리고 테스트에서 @Autowired 등을 통해 스프링 컨테이너가 관리하는 빈들을 사용할 수 있다.
  • @TestConfiguration : 테스트 안에서 내부 설정 클래스를 만들어서 사용하면서 이 에노테이션을 붙이면,
    스프링 부트가 자동으로 만들어주는 빈들에 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 수
    있다.

정리
트랜잭션 AOP를 통해서 비지니스 로직과 트랜잭션 로직을 분리할 수 있게 되었고, 개발자는 트랜잭션이 필요한곳에 @Transactional 애노테이션만 추가해주면 된다.
프록시는 스프링이 알아서 만들어준다. 그리고 스프링빈으로도 등록해준다.
트랜잭션 AOP를 사용하려면 스프링이 제공하는 시스템을 사용해야함. 즉 스프링빈으로 등록해야함.
@Transactional 애노테이션이 붙어있으면 프록시 클래스를 만들어서 스프링빈으로 등록함.

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

스프링 부트가 등장하기 이전에는 데이터소스와 트랜잭션 매니저를 개발자가 직접 스프링빈으로 등록해서 사용했다. 그런데 스프링부트로 개발을 시작한 개발자라면 데이터소스나 트랜잭션 매니저를 직접 등록해본적이 없을것이다.
왜냐하면 스프링부트에서 자동으로 등록해주기 때문이다!

데이터소스 - 자동 드록

  • 스프링부트는 데이터소스(DataSource)를 스프링 빈에 자동으로 등록한다.
  • 자동으로 등록되는 스프링빈 이름은 dataSource이다.
  • 개발자가 직접등록하게 되면 스프링부트는 데이터소스를 자동으로 등록하지 않는다.

이때 스프링부트는 다음과 같이 application.properties에 있는 속성을 사용해서 DataSource를 생성하고 스프링빈으로 등록한다.

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
  • 스프링부트가 기본적으로 생성하는 데이터소스는 커넥션풀을 제공하는 HikariDataSource이다. 커넥션 풀과 관련된 설정도 application.properties를 통해서 지정할 수 있다.
  • spring.datasource.url이 없으면 내장 데이터베이스(메모리 DB)를 생성하려고 시도한다.

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

  • 스프링 부트는 적절한 트랜잭션 매니저( PlatformTransactionManager )를 자동으로 스프링 빈에
    등록한다.
  • 자동으로 등록되는 스프링 빈 이름: transactionManager
  • 참고로 개발자가 직접 트랜잭션 매니저를 빈으로 등록하면 스프링 부트는 트랜잭션 매니저를 자동으로
    등록하지 않는다.

어떤 트랜잭션 매니저를 선택할지는 현재 등록된 라이브러리를 보고 판단하는데, JDBC를 기술을 사용하면
DataSourceTransactionManager 를 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager 를
빈으로 등록한다. 둘다 사용하는 경우 JpaTransactionManager 를 등록한다. 참고로
JpaTransactionManager 는 DataSourceTransactionManager 가 제공하는 기능도 대부분 지원한다.

상세 docs : 자동등록

참고

해당 포스트팅은 아래의 강의를 공부하여 정리한 내용입니다.
김영한님의 SpringDB1-스프링과문제해결-트랜잭션

0개의 댓글