스프링 DB-트랜잭션2

트랜잭션 문제점

비즈니스 로직을 담당하는 서비스 계층은 가급적 순수한 자바 코드로만 이루어져 있는 것이 좋다. 특정 기술에 종속적이게 서비스 코드를 구현하면 후에 DB 종류를 변경한다던지, 기술의 변경이 있을 때 변경의 여파가 서비스 코드까지 미치게 되기 때문이다.

그런데 우리가 현재 트랜잭션을 적용한 서비스 코드를 보자. 커넥션을 받기 위해서 JDBC 기술에 의존하고 있고, 비즈니스 로직을 처리하는 코드보다도 트랜잭션을 처리하기 위한 코드가 더 많다.
이렇게 되면 향후 JDBC에서 JPA로 변경할 시 서비스 코드까지 모두 변경해야 한다.
또한 예외 누수 문제가 있다. JDBC 기술에서 나오는 SQLException이 서비스 계층으로 전파되고 있다.
그 외에는 순수 JDBC를 사용함으로써 try, catch 등 반복되는 구문이 많은 문제가 있다.

이제 이러한 문제들을 스프링의 도움을 받아 하나씩 해결해보자.

트랜잭션 추상화

같은 트랜잭션이라도 기술에 따라 쓰는 방법이 다르다. 우리는 지금 JDBC를 쓰기 때문에 con.setAutoCommit(false) 로 수동 커밋으로 전환하여 트랜잭션을 시작하지만, JPA는 또 형태가 다르다.
이렇게 되면 JDBC 기술을 사용하다 JPA로 바꾸면 코드를 전부 변경해야 하는데, 이러한 상황을 막기 위해 트랜잭션 추상화를 사용한다.

트랜잭션 추상화

트랜잭션 추상화는 우리가 DataSource를 통해 커넥션을 얻는 방식을 추상화한 것과 같다. 트랜잭션 기술에 직접 의존하는 것이 아닌, 추상화된 인터페이스에 의존하고 구현체는 기술에 맞게 갈아끼우는 식이다.

트랜잭션 추상화2

우리는 스프링에서 제공하는 트랜잭션 추상화 기술을 사용하면 된다. 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;
}

구조는 위 코드와 같은데, getTransaction() 메서드로 트랜잭션을 시작하고 commit(), rollback() 메서드로 각각 커밋과 롤백을 수행한다.

트랜잭션 매니저는 트랜잭션 추상화 외에도 한 가지 기능을 수행하는데, 리소스 동기화이다.
기존에는 파라미터를 통해 커넥션을 전달하는 방식을 사용했는데, 이러면 코드가 지저분해질뿐더러 커넥션을 넘기는 메서드와 커넥션을 넘기지 않는 메서드 두 가지를 만들어야 하는 등의 문제가 발생한다.

트랜잭션 동기화

스프링은 트랜잭션 동기화 매니저를 제공한다. ThreadLocal을 이용하여 커넥션을 동기화해주기 때문에 멀티쓰레드 환경에서도 안전하게 작동한다. 트랜잭션 매니저는 내부에서 이 트랜잭션 동기화 매니저를 사용한다.
MVC에서 컨트롤러와 뷰 사이를 모델로 중개하던 것을 생각하면 쉽다. 자세한 작동 방식은 다음과 같다.

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

이제 서비스 코드에서 사용하는 트랜잭션 매니저와 리포지토리는 트랜잭션 동기화 매니저를 사이에 두고 커넥션을 주고받는다. 파라미터로 직결되어있지 않기 때문에 위에서 설명한 여러 문제점들이 해결된다.

이 트랜잭션 매니저를 코드에 적용시켜보자.

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

기존 연결을 받아오고 닫는 코드가 DataSourceUtilsgetConnection(), releaseConnection()을 사용하도록 변경되었다. 트랜잭션 동기화를 사용하기 위해선 DataSourceUtils를 사용해야 한다.
getConnection()은 트랜잭션 동기화 매니저가 관리하는 커넥션이 있다면 해당 커넥션을 받아오고, 없다면 새로운 커넥션을 생성하여 반환한다. 따라서 트랜잭션과 트랜잭션이 아닌 코드 둘 다 사용이 가능하다.
releaseConnection()은 트랜잭션을 위해 동기화된 커넥션은 닫지 않고 유지하고, 트랜잭션 매니저가 관리하는 커넥션이 아니라면 커넥션을 닫는다. 이 또한 트랜잭션을 사용하던 안하던 사용이 가능하다.

리포지토리를 수정했으니 이젠 서비스 코드도 트랜잭션 매니저를 사용하도록 수정하자.

  
private final PlatformTransactionManager transactionManager;

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

트랜잭션 매니저를 외부에서 주입받아 사용하는 코드이다. JDBC를 사용중이므로 DataSourceTransactionManager 구현체를 주입받아야 할 것이다.
트랜잭션을 시작하고, 커밋 또는 롤백하는 코드가 굉장히 간결해졌다. 또한 파라미터를 통해 커넥션을 주고받지 않으므로 이제 서비스 코드에서 커넥션을 받고 해제하는 코드가 더이상 필요가 없어졌다.
그리고 더이상 JDBC 기술이 아닌 트랜잭션 매니저에 의존하므로 기술에 종속적이지 않게 되었다.

트랜잭션 매니저의 동작 흐름을 그림으로 알아보자.

트랜잭션 매니저1

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

  1. 서비스 계층에서 transactionManager.getTransaction() 을 호출해서 트랜잭션을 시작한다.
  2. 트랜잭션을 시작하려면 먼저 데이터베이스 커넥션이 필요하다. 트랜잭션 매니저는 내부에서 데이터소스를 사용해서 커넥션을 생성한다.
  3. 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
  4. 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  5. 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. 따라서 멀티 쓰레드 환경에 안전하게 커넥션을 보관할 수 있다.

트랜잭션 매니저2

  1. 서비스는 비즈니스 로직을 실행하면서 리포지토리의 메서드들을 호출한다. 이때 커넥션을 파라미터로 전달하지않는다.
  2. 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하다. 리포지토리는 DataSourceUtils.getConnection() 을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이 과정을 통해서 자연스럽게 같은 커넥션을 사용하고, 트랜잭션도 유지된다.
  3. 획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해서 실행한다.

트랜잭션 매니저3

  1. 비즈니스 로직이 끝나고 트랜잭션을 종료한다. 트랜잭션은 커밋하거나 롤백하면 종료된다.
  2. 트랜잭션을 종료하려면 동기화된 커넥션이 필요하다. 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.
  3. 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
  4. 전체 리소스를 정리한다.
    • 트랜잭션 동기화 매니저를 정리한다. 쓰레드 로컬은 사용후 꼭 정리해야 한다.
    • con.setAutoCommit(true) 로 되돌린다. 커넥션 풀을 고려해야 한다.
    • con.close() 를 호출해셔 커넥션을 종료한다. 커넥션 풀을 사용하는 경우 con.close() 를 호출하면 커넥션 풀에 반환된다.

트랜잭션 템플릿

기존 여러 문제점들은 해결이 됬지만, try-catch 구문 등 반복적인 부분이 있는 문제점은 아직 해결되지 않았다.
이 문제는 템플릿 콜백 패턴을 사용하여 해결이 가능한데, 템플릿 콜백 패턴을 사용하려면 템플릿을 제공하는 클래스가 필요하다.
스프링은 TransactionTemplate이라는 클래스를 제공하여 템플릿을 용이하게 사용할 수 있게 해준다.

  
public class TransactionTemplate {
    private PlatformTransactionManager transactionManager;  

    public <T> T execute(TransactionCallback<T> action){..}
    void executeWithoutResult(Consumer<TransactionStatus> action){..}
}

execute()는 응답값이 있을 때, executeWithoutResult()는 응답값이 없을 때 사용한다. 이 트랜잭션 템플릿을 사용하여 기존 코드를 리팩토링해보자.

  
    final MemberRepositoryV3 memberRepository;
    final TransactionTemplate txTemplate;

    public MemberServiceV3_2(MemberRepositoryV3 memberRepository, PlatformTransactionManager transactionManager) {
        this.memberRepository = memberRepository;
        this.txTemplate = new TransactionTemplate(transactionManager);
    }

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        txTemplate.executeWithoutResult((status)-> {
            try {
                bizLogic(fromId, toId, money);
            } catch (SQLException e) {
                throw new IllegalArgumentException(e);
            }
        });
    }

트랜잭션 템플릿을 사용하면서 이제 트랜잭션을 커밋하고, 롤백하는 코드가 사라졌다. 트랜잭션 템플릿은 비즈니스 로직이 정상 수행되면 자동으로 커밋하고, 언체크 예외가 발생하면 롤백해주는 역할을 한다. 체크예외는 커밋하는데, 체크 예외, 언체크 예외에 대해서는 다음 포스팅에서 제대로 알아보자.

트랜잭션 AOP

코드가 처음보다 많이 간결해지긴 했지만, 아직도 서비스 코드에서 트랜잭션을 처리한다는 문제는 남아있다. 트랜잭션 파트와 서비스 파트를 완전히 분리할 수는 없을까?
이전에 잠깐 배웠던 스프링의 AOP를 통해 트랜잭션 파트를 공통 관심사항으로써 분리하면 가능하다. 또한 스프링은 강력한 어노테이션 기능을 제공하는 만큼 이 부분도 어노테이션으로 아주 간단하게 처리할 수 있도록 되어있다.

AOP는 알다시피 프록시 기술을 사용하는데, 프록시 처리 이전의 처리 흐름은 다음 그림과 같았다.

트랜잭션 AOP1

프록시를 도입한 이후의 처리 흐름은 다음과 같이 바뀐다.

트랜잭션 AOP2

스프링의 CGLIB 기술로 만들어진 프록시가 기존 서비스 코드를 상속받아 위아래에 트랜잭션 처리를 붙인 객체를 새로 만들어 처리해주므로 서비스의 비즈니스 로직과 트랜잭션 처리 파트를 완전히 분리할 수 있다.
또한 이 과정은 스프링의 @Transactional 어노테이션을 트랜잭션 처리가 필요한 곳에 붙여주는 것 만으로 손쉽게 이루어진다. 바로 코드에 적용해보자.

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

트랜잭션 관련 코드가 완전히 없어지고, 대신 @Transactional 어노테이션만 붙었다. 스프링 AOP는 스프링 컨테이너가 필요하기 때문에 컨테이너를 생성하고 트랜잭션 처리에 필요한 DataSource, TransactionManager를 빈으로 등록해주는 등의 처리만 잘 해주면 나머지 모든 절차는 자동으로 이루어진다.
프록시를 사용한 자세한 처리 흐름은 다음 그림과 같다.

트랜잭션 AOP3

또한 트랜잭션 AOP에는 DataSource, TransactionManager가 빈으로 등록되어야 하는데, 스프링 부트에서는 수동으로 등록하지 않는 한 이것을 자동으로 등록해준다.
데이터 소스는 application.properties에 객체 생성에 필요한 여러 속성들을 입력해주면, 스프링 부트에서 이것을 보고 기본 지원하는 HikariDataSource를 이용하여 객체를 생성하고 빈으로 등록한다.

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

트랜잭션 매니저도 transactionManager라는 빈 이름으로 라이브러리를 보고 적절한 트랜잭션 매니저를 선택하여 자동 생성하고 등록해준다.

트랜잭션 관리에는 어노테이션, XML 등으로 편리하게 처리하는 선언적 트랜잭션 관리와 트랜잭션 템플릿 등으로 직접 코드를 작성하는 프로그래밍 방식 트랜잭션 관리가 있는데, 그냥 선언적 방식을 사용하는게 좋다. 프로그래밍 방식은 테스트 시 가끔 사용될 수도 있다.

정리

스프링에서의 트랜잭션 처리를 JDBC만을 사용한 처리에서부터 여러 문제점들이 해결된 스프링의 선언적 트랜잭션 처리까지 배울 수 있었다. 강의를 들을 때마다 느끼는 것이지만 이 기술이 왜 나왔는지, 어떤 문제점이 해결되었는지를 알아가며 배울 수 있어서 정말 좋은 강의 같다.
다음 포스팅에서는 스프링의 예외 처리에 대해 알아보도록 하겠다. 이전 웹에서의 예외 처리와는 다른 내용일 것 같다.