[Spring DB] 트랜잭션 매니저와 AOP

Rupee·2023년 1월 23일
0

스프링

목록 보기
8/16
post-thumbnail

☁️ 기존 트랜잭션 구현 방식 문제점

서비스 계층(비즈니스 로직)은 특정 기술에 종속적이지 않고 순수한 자바 코드로만 작성되어있어야 한다.

하지만, 이전에 서비스 계층에서 트랜잭션을 도입한 코드를 봐보면 다음과 같은 세가지 문제가 존재한다.

public class MemberServiceV2 {

    private final DataSource dataSource;
    private final MemberRepositoryV2 memberRepository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {   // 계좌 이체
        // 1. 커넥션 생성
        Connection con = dataSource.getConnection();
        try {
            con.setAutoCommit(false);  // 트랜잭션 시작
            // 2. 비즈니스 로직 수행
            bizLogic(fromId, toId, money, con);
            // 3. 로직 성공 시 커밋
            con.commit();
        } catch (Exception ex) {
            // 4. 로직 실패 시 롤백
            con.rollback();
            throw new IllegalStateException(ex);
        } finally {
            // 5. 사용한 커넥션 릴리즈
            releaseConnection(con);
        }
    }
    ...
    private void releaseConnection(Connection con) {
            if (con != null) {
                try {
                    con.setAutoCommit(true);  // 주의 : 기본이 자동 커밋 모드이므로 커넥션 풀에 반납할때는 true로 변경
                    con.close();
                } catch (Exception ex) {
                    log.info("error", ex);
                }
            }
     }
}

🔒 트랜잭션 문제

1. JDBC 구현 기술이 서비스 계층에 누수

기존에는 레파지토리와 같은 데이터 접근 계층에서 JDBC와 관련한 로직을 처리했었다.

하지만, 트랜잭션을 사용하기 위해 서비스에서 DataSource, Connection, SQLException와 같은 JDBC 기술을 도입하게 되었다. 만약 JPA 로 변경한다면? 모든 수십개의 비즈니스 로직들을 고쳐야 하기 때문에 유지보수가 어려워진다.

2. 트랜잭션 동기화

같은 트랜잭션을 유지하기 위해 커넥션을 파라미터로 넘겼지만, 똑같은 기능도 트랜잭션용 기능과 트랜잭션을 유지하지 않아도 되는 기능 두개로 쪼개지는 문제가 발생한다.

3. 트랜잭션 적용 코드의 반복

try, catch, finally와 같은 코드가 반복된다.

🔒 예외 누수

데이터 접근 계층의 JDBC 구현 기술 예외가 서비스 계층으로 전파된다. SQLException 와 같은 체크 예외는 반드시 처리하거나 명시적으로 throws 를 통해서 다시 밖으로 던져야하기 때문이다.

즉, 이는 서비스 계층에서 JDBC 전용 기술인 SQLException에 종속적이게 되고 다른 데이터 접근 기술을 사용하면 그에 맞는 예외로 변경 및 서비스 코드의 수정이 일어난다.

🔒 JDBC 반복 문제

레파지토리에 순순한 JDBC를 적용했더니, 같은 패턴이 반복된다.

☁️ 트랜잭션 추상화

트랜잭션 추상화란, 서비스 계층이 트랜잭션으로 인해 JDBC에 의존하게 되는 문제를 해결하는 방안이다. 단순하게 말하자면, 인터페이스를 만들고 원하는 구현체를 DI(Dependency Injection)을 통해 구현체를 주입받는 방식이다.

트랜잭션 인터페이스

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

스프링의 트랜잭션 추상화

하지만 직접 인터페이스를 만들지 않아도, 스프링에서는 PlatformTransactionManager라는 인터페이스 뿐만 아니라 각종 데이터베이스 접근 기술에 따른 구현체들도 제공해준다.

🔖 참고
스프링 5.3부터는 JDBC 트랜잭션을 관리할 때 DataSourceTransactionManager 를 상속받아서 약간의
기능을 확장한 JdbcTransactionManager 를 제공한다.

스프링 트랜잭션 매니저 인터페이스

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;
}
  • getTransaction() : 트랜잭션을 시작한다. 단 이미 진행중인 트랜잭션이 있는 경우, 해당 트랜잭션에 참여할 수 있다.

적용

@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());   // 1. 트랜잭션 시작

        try {
            bizLogic(fromId, toId, money);       // 2. 비즈니스 로직 수행
            transactionManager.commit(status);   // 3. 로직 성공 시 커밋
        } catch (Exception ex) {
            transactionManager.rollback(status);  // 4. 로직 실패 시 롤백
        }
        // 5. 사용한 커넥션 릴리즈는 트랜잭션 매니저가 처리
    }
    ...
}

JDBC에 의존적인 DataSource를 주입 받고 직접 getConnection, commit, rollback 하는 부분이 사라지고 하나로 추상화 된 것을 볼 수 있다. 이로써 문제 하나는 해결이 되었다:)

☁️ 트랜잭션 동기화

트랜잭션을 유지하려면, 시작부터 끝까지 같은 커넥션을 유지하는 것이 필수이다.

스프링 트랜잭션 매니저는, 앞서서의 파라미터 전달방식의 여러 문제점들을 해결하고 깔끔하게 리소스를 동기화해주는 기능을 한다.

트랜잭션 동기화 과정

트랜잭션 매니저는 스레드 로컬(Thread Local)을 통해 멀티 스레드 환경에서 안전하게 동기화를 적용하고 있다. 트랜잭션 매니저가 대신 생성해준 커넥션을 트랜잭션 동기화 매니저 라는 곳에서 보관해두고, 레포지토리에서 보관해둔 커넥션을 꺼내 쓰는 방식이다.

🔖 스레드 로컬이란?
각각의 쓰레드마다 별도의 저장소가 부여되기 때문에, 해당 쓰레드만 해당
데이터에 접근하는게 가능해진다.

1. 트랜잭션 시작

  1. 서비스 계층에서 getTransaction() 을 호출해서 트랜잭션을 시작한다.
  2. 초기화 과정에서 주입받은 DataSource 를 사용해서 내부에서 커넥션을 생성한다.
  3. 트랜잭션 시작을 위해 setAutoCommit 설정 값을 false로 지정해 수동 커밋 모드로 변경한다.
  4. 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  5. 트랜잭션 동기화 매니저는 스레드 로컬에 커넥션을 안전하게 보관한다.

2. 비즈니스 로직 실행

  1. 서비스가 비즈니스 로직을 실행하면서 레파지토리의 메서드들을 호출하지만, 커넥션을 파라미터로 전달하지 않는다.
  2. 레파지토리에서 트랜잭션이 시작되었던 그 커넥션을 트랜잭션 동기화 매니저에써 획득한다.
  3. 획득한 커넥션을 사용해서 SQL 을 데이터베이스에 전달해서 실행한다.
public class MemberRepositoryV3 {
    ...
    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        DataSourceUtils.releaseConnection(con, dataSource);
    }

    private Connection getConnection() throws SQLException {
       // 트랜잭션 동기화 매니저를 통해 보관된 커넥션 반환
        Connection connection = DataSourceUtils.getConnection(dataSource);
        log.info("get connection={} class={}", connection, connection.getClass());
        return connection;
    }
}

앞서서 달라진점은, 반드시 트랜잭션 동기화를 사용하려면 DataSource가 아닌 DataSourceUtils를 통해 커넥션을 조회하고 반환해야 한다는 것이다.

🔖 DataSourceUtils

  • DataSourceUtils.getConnection : 트랜잭션 동기화 매니저가 관리하는 커넥션이 있다면 반환하고, 없다면 새로 생성해서 반환한다.
  • DataSourceUtils.releaseConnection : 트랜잭션을 사용하기 위해 동기화된 커넥션은 닫지 않고 유지하지만, 트랜잭션 동기화 매니저가 관리하는 커넥션이 없다면 바로 닫는다.

3. 트랜잭션 종료

비즈니스 로직이 끝나고 커밋이나 롤백이 되면 트랜잭션이 종료된다.

  1. 트랜잭션 종료를 위해 트랜잭션 동기화 매니저로부터 동기화된 커넥션을 획득한다.
  2. 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
  3. 트랜잭션 동기화 매니저와 쓰레드 로컬을 정리하고, 수동 모드에서 다시 자동 커밋 모드로 되돌린다. con.close() 를 호출해셔 커넥션을 종료 혹은 풀을 사용하는 경우 반환한다.

☁️ 트랜잭션 템플릿

만약 서비스가 100개라면, 트랜잭션을 사용하는 로직 또한 100개 만큼 반복되는 문제가 발생한다.

//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
    //비즈니스 로직
    bizLogic(fromId, toId, money); 
    transactionManager.commit(status); //성공시 커밋
} catch (Exception e) { 
	transactionManager.rollback(status); //실패시 롤백
    throw new IllegalStateException(e);
}

템플릿 콜백 패턴을 사용한다면, 이러한 코드 중복의 문제를 해결할 수 있다.

스프링에서는 TransactionTemplate 을 통해 이미 구현된
트랜잭션 관련 코드를 한 곳
에 몰아놓으면 되기 때문에, 우리는 비즈니스 로직만 작성해서 주입해주면 되는 것이다.

TransactionTemplate 골격

public class TransactionTemplate {
      private PlatformTransactionManager transactionManager;
      
      public <T> T execute(TransactionCallback<T> action){..}
      void executeWithoutResult(Consumer<TransactionStatus> action){..}
}

함수형 인터페이스인 action 인자를 통해 람다를 넘겨주면 간단하게 비즈니스 로직만 작성하여 전달해줄 수 있다.

서비스에 트랜잭션 템플릿 적용

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) {   // 계좌 이체
            // 템플릿 내부에서 로직 수행이 성공적이라면 커밋, 예외가 발생한다면 롤백 처리를 자동으로 해줌
            txTemplate.executeWithoutResult((status) -> {
                try {
                    bizLogic(fromId, toId, money);
                } catch (SQLException e) {
                    throw new IllegalStateException(e);
                }
            });
        }
    }
}

트랜잭션 시작 및 커밋, 롤백하는 코드가 모두 제거된 것을 볼 수 있다.

🔖 TransactionTemplate 동작 과정

그렇다면 어떻게 동작하고 있길래 코드 조각만 넘겨줘도 트랜잭션이 작동하는 것일까?

우선 스프링에서는 트랜잭션을 사용하고 사용하지 않는 함수를 구분하기 위해, TransactionOperations 인터페이스로 콜백 패턴을 한단계 더 추상화하였다. 구현체는 다음과 같이 두가지가 존재한다.

public interface TransactionOperations {
	@Nullable
	<T> T execute(TransactionCallback<T> action) throws TransactionException;

	default void executeWithoutResult(Consumer<TransactionStatus> action) throws TransactionException {
		execute(status -> {
			action.accept(status);
			return null;
		});
	}
    
	static TransactionOperations withoutTransaction() {
		return WithoutTransactionOperations.INSTANCE;
	}

}
  1. TransactionTemplate: 트랜잭션을 사용해서 로직을 수행

  1. WithoutTransactionOperations : 트랜잭션을 사용하지 않고 로직을 수행

하지만 아직도 핵심 로직인 비즈니스 로직과 부가 기능인 트랜잭션 로직이 섞여있는 문제가 존재한다. (TransactionTemplate 관련 코드들) 아예 서비스 계층에서 트랜잭션 관련 코드를 제거하려면? 바로 스프링 AOP 를 통해 프록시를 적용하면 된다.

☁️ 트랜잭션 AOP

트랜잭션 프록시

서비스가 직접 트랜잭션을 처리하는 것이 아니라, 대리인인 트랜잭션 프록시가 대신 트랜잭션 관련 로직들을 모두 처리해준다. 따라서 이제 서비스에는 순수한 비즈니스 로직만 남길 수 있게 된다.

@Aspect , @Advice , @Pointcut 을 통해 직접 AOP를 구현해도 되지만, 스프링에서는 @Transactional트랜잭션 처리가 필요한 함수에 붙이면 애노테이션을 인식해서 자동으로 AOP를 적용해준다.

@Transactional로 AOP 적용

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_3 {

    @Transactional  // 어노테이션 추가
    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        bizLogic(fromId, toId, money);
    }
    ...
}

혹은 메서드 말고 클래스에 붙인다면, 클래스 내부의 모든 public API에 대해 트랜잭션이 적용된다.

@SpringBootTest  // 반드시 붙여야 AOP가 동작
class MemberServiceV3_3Test {

    @Autowired
    private MemberRepositoryV3 repository;

    @Autowired
    private MemberServiceV3_3 service;
    
    @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());
        }
    }
    ...
}

주의할 것은, 테스트시에 @SpringbootTest를 붙여줘야 한다는 것이다.

@Transactional 어노테이션을 제대로 인식하고 하기 위해서는 스프링 컨테이너의 도움이 필요하기 때문에, 스프링 빈으로 올리고 의존 관계 주입을 받아서 레포지토리와 서비스를 사용해야 한다.

AOP를 추가한 트랜잭션 전체 과정

서비스 메서드인 accountTransfer() 가 호출되면, 애플리케이션 실행 시점에 스프링 컨테이너에는 MemberService 가 아닌 자동으로 생성한 프록시가 빈으로 등록되어 있을 것이므로 프록시 로직이 실행된다. (조건이 하나라도 만족하면 프록시 적용 대상이므로 빈 후처리기가 프록시로 바꿔치기)

나머지 스프링 컨테이너에 등록된 트랜잭션 매니저를 획득해서 커넥션을 생성, 보관, 획득하는 로직들은 모두 같다.

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

스프링 부트 이전

직접 DataSourceTransactionManager 를 빈으로 등록해주었어야 했다.

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

스프링 부트 등장 이후

스프링 부트는 위 두 가지를 빈으로 등록하는 과정을 자동화 해주었다.

application.properties 에 데이터 소스에 필요한 정보들을 작성하기만 하면, 스프링 부트가 지정된 속성을 참고해서 데이터소스와 트랜잭션 매니저를 자동으로 생성해주는 것이다.

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

그러면 테스트용 설정 파일이 아래와 같이 바뀐다. 레파지토리에 필요한 데이터 소스를 컨테이너에서 의존 관계 주입을 통해 넣어주면 된다.

@TestConfiguration
static class TestConfig {
          private final DataSource dataSource;
          
          public TestConfig(DataSource dataSource) {
              this.dataSource = dataSource;
          }
          
          @Bean
          MemberRepositoryV3 memberRepositoryV3() {
              return new MemberRepositoryV3(dataSource);
          }
          ...
 }
profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글