Spring

[Spring] 스프링의 트랜잭션 기술

이덩우 2023. 12. 16. 01:07

 

이전 포스팅과 이어지는 내용입니다.

https://dong-woo.tistory.com/148

 

[Spring] 트랜잭션에 대한 이해와 과거의 활용

데이터를 저장할 때 단순 파일에 저장해도 되지만 현대의 웹 애플리케이션은 왜 데이터베이스에 저장할까? 이는 데이터베이스가 트랜잭션이라는 개념을 지원하기 때문이다. 현대의 우리는 스

dong-woo.tistory.com

 


직접 구현한 트랜잭션의 문제점

이전 포스팅에서는 커넥션을 파라미터로 넘겨, 직접 트랜잭션을 구현했다.

하지만 현재 단계는 문제점이 상당히 많다. 

 

  1. 트랜잭션 관련 문제
    • 만약 JDBC에서 JPA로 데이터 접근 기술을 변경한다면 서비스 계층의 트랜잭션 코드도 변경되어야 한다.
    • 커넥션을 동기화하기 위해서 파라미터로 계속 커넥션을 넘겨준다.
    • 서비스 계층이 순수 비지니스 로직으로 구성되어야 하는데, 트랜잭션 관련 코드가 더 많다.
  2. 예외 누수 문제
    • 리포지토리의 데이터 접근 기술때문에 생긴 예외가 서비스 계층까지 올라온다.
    • 현재는 순수 JDBC를 사용하고있어 SQLException이 서비츠 계층에 누수된다.
  3. JDBC 반복 문제
    • 트랜잭션에 관한 문제는 아니지만 리포지토리에서 JDBC 코드가 너무 중복된게 많고 복잡하다. 

본 포스팅에서는 *트랜잭션 관련 문제*를 먼저 해결해볼 것이다!

 


트랜잭션 추상화, TransactionManager

첫 번째 문제 상황이다.

만약 JDBC에서 JPA로 데이터 접근 기술을 변경한다면 서비스 계층의 트랜잭션 코드도 변경되어야 한다.

 

현재 리포지토리에서는 모두 순수 JDBC 기술로 데이터베이스에 접근하도록 구현해놨기 때문에, 서비스 계층에 있는 트랜잭션 관련 코드도 모두 JDBC 기술에 종속적으로 구현되어있다.

 

이 상태에서 JPA를 사용하도록 리포지토리를 변경한다면 트랜잭션 관련 코드역시 변경되어야 할 것이다.

스프링을 다룰 때 항상 이와 비슷한 문제를 마주했었다. 자연스럽게 *추상화*를 떠올릴 수 있어야한다.

 

직접 추상화된 인터페이스를 만들 수 있겠지만, 트랜잭션은 전 세계 모든 개발자들이 사용하는 기능이다!

스프링은 트랜잭션 코드에 관해 추상화된 기능을 제공한다. 바로 *TransactionManager*이다.

TransactionManager

 

 

우선 인터페이스 이름은 PlatformTransactionManager이다.

해당 인터페이스의 코드를 살펴보자. (일부 발췌)

public interface PlatformTransactionManager extends TransactionManager {

	TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
	void commit(TransactionStatus status) throws TransactionException;
	void rollback(TransactionStatus status) throws TransactionException;

}

 

메소드는 단순하다. 트랜잭션을 시작하고, 커밋하고, 롤백하는 기능을 담당한다.

트랜잭션에 필요한 기능은 위 세 가지가 전부이므로 Jdbc를 사용하던, JPA를 사용하던 적절한 구현체를 선택해 구현하면 된다.

 

 


트랜잭션 동기화, TransactionSynchronizationManager

두 번째 문제 상황이다.

커넥션을 동기화하기 위해서 파라미터로 계속 커넥션을 넘겨준다.

 

 

트랜잭션을 위해서는 트랜잭션의 시작부터 종료까지 같은 커넥션을 유지해야만 한다.

이전 실습에서는 같은 커넥션을 트랜잭션 간 맞추기위해 파라미터로 커넥션을 계속 전달하는 방식을 사용했다.

이러한 방식은 코드가 지저분해지고, 커넥션을 넘겨받는 메소드와 넘겨받지 않는 메소드를 중복해서 만들어야하는 단점이 있었다.

 

하지만 스프링은 *트랜잭션 동기화 매니저*를 제공한다.

트랜잭션 시작
로직 실행
트랜잭션 종료

 

이제 더 이상 커넥션을 파라미터로 넘겨받지 않고, 스프링이 제공해주는 트랜잭션 동기화 매니저를 사용하면 된다.

내부적으로 쓰레드 로컬(Thread Local)을 사용해 멀티쓰레드 상황에서도 안전하게 커넥션을 동기화해준다!

따라서 커넥션이 필요하면 이제는 DataSource에서 직접 꺼내는 것이 아니라 트랜잭션 동기화 매니저를 통해 커넥션을 획득하면 된다.

-> *DataSourceUtils.getConnection()*을 통해 획득한다.

 

하지만 트랜잭션 동기화 매니저를 통해 커넥션을 획득할 때, 트랜잭션이 아닌 단순 요청에서 커넥션을 획득할 수 도 있다.

이때 내부적으로 트랜잭션 매니저 및 트랜잭션 동기화 매니저는 다음과 같이 동작한다.

 

트랜잭션 상황 시 
  • 서비스 계층에서 getTransaction()을 호출하며 트랜잭션을 시작한다.
  • 트랜잭션을 위해서는 커넥션이 필요하다. 트랜잭션 매니저는 내부에서 DataSource를 통해 커넥션을 획득한다.
  • 수동 커밋 모드로 변경하며 실제 DB 트랜잭션을 시작하고, *커넥션을 트랜잭션 동기화 매니저에 보관한다.*
  • 트랜잭션 동기화 매니저는 커넥션을 쓰레드 로컬에 보관한다.
  • 비지니스 로직을 수행하며, 관련된 리포지토리의 메소드를 호출한다. 이 때 메소드는 동기화된 커넥션이 필요하다. 이전처럼 DataSource에서 꺼내지 않고, *DataSourceUtils.getConnection()*을 사용해 쓰레드 로컬에 보관된 커넥션을 사용한다.
  • 비지니스 로직이 모두 수행된 뒤, 트랜잭션을 종료하기 위해서 트랜잭션 동기화 매니저에 있던 커넥션을 다시 트랜잭션 매니저가 획득한다.
  • 획득한 커넥션을 통해 트랜잭션을 커밋하거나 롤백하며 트랜잭션을 종료한다.
  • 전체 리소스를 정리한다. 트랜잭션 동기화 매니저의 쓰레드 로컬은 사용 후 꼭 정리해야하고, 수동 커밋 모드를 다시 자동 커밋 모드로 되돌리고 con.close()를 호출한다. -> 커넥션 풀을 사용한다면 풀에 다시 반환되기 때문에 자동 커밋 모드로 꼭 전환해서 close()를 호출 해야한다.

 

트랜잭션 상황이 아닐 시 
  • 트랜잭션 상황이 아니라면, 트랜잭션 동기화 매니저가 쓰레드 로컬에 보관 중인 커넥션은 없을 것이다.
  • 그렇다면 새로운 커넥션을 트랜잭션 동기화 매니저가 생성해 반환해준다.
  • 이 때는 작업이 끝나면 즉시 커넥션을 폐기한다.

 


트랜잭션 매니저 활용

커넥션을 획득하는 부분이 변경되었다.

private static Connection getConnection(DataSource dataSource) throws SQLException {
    //DataSourceUtils 에서 꺼내면 트랜잭션 동기화 매니저에서 동기화된 커넥션을 가져옴
    Connection con = DataSourceUtils.getConnection(dataSource);
    log.info("connection={}, class={}", con, con.getClass());
    return con;
}

 

 

커넥션을 닫는 부분도 변경되었다.

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

 

이전처럼 JdbcUtils를 통해 닫지 않고, DataSourceUtils.releaseConnection을 활용해 닫아준다.

이렇게 하면 트랜잭션을 위해 동기화된 커넥션은 닫지 않고 그대로 유지하고,

*트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우에만* 해당 커넥션을 닫는다.

 

 

이제 실제 서비스 계층이 어떻게 달라졌는지 확인해보자.

private final PlatformTransactionManager transactionManager;

public void accountTransfer(String fromId, String toId, int money) throws SQLException {
    // 트랜잭션 시작
    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
    try {
        //실행할 비지니스 로직
        bizLogics(fromId, toId, money);
        transactionManager.commit(status);
    } catch (Exception e) {
        transactionManager.rollback(status);
        throw new IllegalStateException(e);
    }
}

 

직접 트랜잭션을 구현했을 때는 동기화된 커넥션을 파라미터로 전달하기 위해 서비스 계층에서 직접 커넥션을 획득하고, 자동 커밋 모드로 설정하는 등 트랜잭션 관련 코드가 많았다.

이제는 트랜잭션 매니저를 통해 단순히 getTransaction()을 실행하며 트랜잭션을 시작하도록 코드가 많이 단축되었다.

 


코드 반복 문제 해결, TransactionTemplate

세 번째 문제 상황이다.

서비스 계층이 순수 비지니스 로직으로 구성되어야 하는데, 트랜잭션 관련 코드가 더 많다.

 

트랜잭션 매니저를 통해 커넥션을 동기화하고, 자동 커밋 모드로 설정하는 등 많은 코드를 줄일 수 있었지만 여전히 반복되는 패턴이 존재한다.

트랜잭션을 시작하고, 비지니스 로직을 수행하며 성공하면 커밋, 예외가 발생하면 롤백하는 패턴 자체가 남아있다.

 

이런 패턴 자체는 다른 서비스 계층에서도 존재할텐데.. 어떻게 없앨 수 있을까?

바로 템플릿 콜백 패턴을 활용하면 이런 반복 문제를 해결할 수 있다!

 

스프링은 이런 반복에 대한 템플릿 클래스를 제공한다. 바로 *TransactionTemplate*이다.

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

 

응답값이 있다면 execute()를, 없다면 executeWithoutResult()를 사용하면 된다.

실제로 적용해보자.

public void accountTransfer(String fromId, String toId, int money) throws SQLException {
    /**
     * 이제 트랜잭션 템플릿이 알아서 트랜잭션 시작하고, 비지니스로직 끝나면 커밋이나 롤백 알아서 해주고 다한다.
     */
    txTemplate.executeWithoutResult((status) -> {
        try {
            bizLogics(fromId, toId, money);
        } catch (SQLException e) {
            throw new IllegalStateException(e);
        }
    });
}

 


트랜잭션 AOP, @Transactional

위에서 트랜잭션 관련 문제를 해결하기 위해 트랜잭션 추상화, 트랜잭션 매니저, 트랜잭션 템플릿을 적용해봤다.

이를 통해 많은 반복 문제를 해결할 수 있었지만, 서비스 계층에 순수하게 비지니스 로직만 남길 순 없었다.

여전히 트랜잭션 관련 코드와 try-catch 구조가 남아있기 때문이다.

 

이 문제를 완전히 해결할 수 있도록 스프링은 AOP를 활용한 @Transactional 애노테이션을 제공한다.

프록시를 도입해 트랜잭션 관련 코드와 순수 비지니스 로직을 완전히 분리시킨 것이다.

트랜잭션 프록시 도입

 

개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다.

메소드 단위에 붙이면 해당 메소드에 트랜잭션이 적용되고, 클래스 단위에 붙으면 외부에서 접근 가능한 모든 public 메소드에 트랜잭션이 적용된다. 얼마나 편리한가!

 

@Transactional 애노테이션을 적용한 최종 버전의 코드를 살펴보자.

@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
    bizLogics(fromId, toId, money);
}

 

참고로, 이렇게 @Transactional 애노테이션을 통해 트랜잭션 처리를 하는 서비스 계층을 생성했다면, 테스트 코드를 작성할 때 몇 가지 유의할 점이 있다. 
첫 번째로 스프링 AOP를 적용하기 때문에 스프링 컨테이너가 필요하다. 테스트 클래스에 @SpringBootTest 애노테이션을 붙여주자.
두 번째로 연결되어 있는 모든 서비스와 리포지토리 계층, 데이터소스 등을 전부 스프링 빈으로 등록해줘야한다.

 

 

마지막으로 트랜잭션 AOP가 적용된 최종 흐름을 살펴보자.

 

 

 

 

 

 

출처 : 인프런, 김영한의 스프링 DB 1편 - 데이터 접근 핵심 원리