<DB> JDBC 트랜잭션에서 Spring의 @Transactional까지

이전 포스팅까지 저희는 JDBC 기술에 의존하여 Transaction을 적용시켜 보았습니다.만약 현재의 상황에서 JDBC 말고 JPA를 사용한다고 가정하면 서비스 계층의 모든 트랜잭션 코드들이 모두 수정되어야 합니다.쉽게 말하면 디비 접근 기술을 바꾸려고 하는데 서비스단의 코드들이 수정되어야 하는 현상이 발생하는것입니다.이러한 문제를 해겨하기 위해 트랜잭션 추상화를 도입해봅시다.

스프링의 트랜잭션 추상화

트랜잭션 추상화라 함은 쉽게 말하면 interface를 만들고 해당 인터페이스의 구현체들을 각각 만들고 기술을 변경하고 싶을 때 마다 DI를 통해 구현체를 갈아끼울수 있게 만드는 것입니다.(다형성과 스프링을 잘 아시는 분들은 바로 이해하실 수 있습니다)이러한 트래잭션 추상화는 개발자가 서비스단의 코드를 변경하지 않고도 데이터 접근 기술을 편하게 변경할 수 있도록 해줍니다.

놀랍지 않게 스프링 프레임워크는 위에 언급된 트랜잭션 추상화 인터페이스를 미리 만들어두고 있습니다.아래와 같이 PlatformTransactionManager라는 인터페이스로 제공됩니다.심지어 데이터 접근 기술에 따른 트랜잭션 구현체 또한 모두 만들어져 있습니다.

김영한 - 스프링 DB 1

정리해보겠습니다.저희는 현재 JDBC를 통해 트랜잭션을 구현하였습니다.하지만 데이터 접근 기술을 JPA 또는 그 외의것으로 바꿔야하는 상황에 마주쳤습니다.현재 구조로는 서비스단의 트랜잭션에 관한 모든 내용(대략 아래 그림)을 바꿔야하는 문제점이 발생합니다.

MemberServiceWithTransaction

이러한 문제점을 해결하기 위해 우리는 추상화라는 방법을 떠올렸으며 스프링 프레임워크는 이미 인터페이스를 만들어 놓았습니다.간략히 줄여서 TransactionManager라고 하겠습니다.이러한 트랜잭션 매니저뿐만 아니라 이를 구현하는 구현체들까지도 스프링은 모두 구현해놓았습니다.아래는 실제 스프링에서 제공하는 트랜잭션 매니저 인터페이스의 코드입니다.

트랜잭션 매니저의 또 다른 기능

위에서 언급된 트랜잭션 추상화를 통해 데이터 접근 기술의 변경을 용이하게 해준다는 기능 외에도 트랜잭션 매니저는 중요한 기능을 수행합니다.이는 바로 리소스 동기화입니다.

쉽게 말하면 트랜잭션이 시작하고 끝날 때까지 커넥션을 동일한 커넥션으로 유지시켜 주는 기능을 수행합니다.위의 MemberServiceWithTransaction 이미지를 보시면 커넥션을 만들어 직접 repository단의 메서드를 통해 전달해주고 있는것을 확인할 수 있습니다.즉,서비스단에서 커넥션을 만들어야하는 단점이 생깁니다.(이뿐만 아니라 커넥션을 파라미터로 넘기는 메소드를 레포지토리에 오버로딩해야합니다..)이러한 점을 보완하기 위해 트랜잭션 매니저가 커넥션 동기화를 통해 트랜잭션이 진행되는 동안 동일한 커넥션을 유지시켜 줍니다.

동기화의 동작을 조금 더 상세히 설명해보겠습니다.아래의 그림과 같이 트랜잭션 매니저가 트랜잭션이 시작되면 커넥션을 트랜잭션 동기화 매니저라는 곳에 보관을 합니다.그러면 레포지토리단에서 데이터에 접근해야할 로직이 발생하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득합니다.또한 트랜잭션 동기화 매니저는 쓰레드 로컬을 사용하기 때문에 멀티쓰레드 상황에 안전하게 커넥션을 동기화 할 수 있습니다.

예를 들어 MemberServiceWithTransaction 코드에서 보실 수 있듯이 memberRepository를 통해 총 두번의 update 쿼리가 DB로 날라가야 합니다.기본적으로 디비에 쿼리를 날리기 위해서 커넥션이 필요합니다.동시에 동일한 트랜잭션 내에 발생해야하는 쿼리이기에 동일한 커넥션을 사용해야합니다.이 순간 트랜잭션 매니저를 사용한다면 레포지토리에서 트랜잭션 동기화 매니저에 접근하여 동일한 커넥션을 획득하는 것입니다.아래 그림도 참조하시길 바랍니다.

김영한 - 스프링 DB 1

DataSourceUtils

DataSourceUtils 인터페이스는 서비스단에서 트랜잭션 매니저를 사용한 경우 레포지토리단에서 커넥션을 얻어오거나 해제할 때 사용되는 인터페이스입니다.

아래의 doGetConnection() 메서드의 경우 커넥션을 얻기 위해 동기화 매니저에 접근하는것을 확인 할 수 있습니다.만약 동기화 매니저에 커넥션이 없을 경우에는 새로운 커넥션을 생성해서 해당 커넥션을 사용해서 디비에 쿼리를 날립니다.

또한 releaseConnection() 메서드는 트랜잭션을 사용하기 위해 동기화된 커넥션은 바로 닫지 않고 그대로 유지해줍니다.반면에 트랜잭션 동기화 매니저가 관리하는 커넥션이 없을 경우,해당 커넥션은 바로 닫아버립니다.즉,서비스단에서 트랜잭션 매니저를 사용하지 않았기에 커넥션을 유지시키지 않고 바로 닫은 것입니다.

아래 코드는 서비스단에서 트랜잭션 매니저를 사용할 경우의 레포지토리 클래스 내의 closing과 getConnection 메서드입니다.코드전문

private void closing(Connection con, Statement stmt, ResultSet resultSet) {
        JdbcUtils.closeResultSet(resultSet);
        JdbcUtils.closeStatement(stmt);
        //트랜잭션 동기화를 사용하려면 DataSourceUtils 사용해야함
        DataSourceUtils.releaseConnection(con,dataSource);
    }

private Connection getConnection() throws SQLException {
        //앞서 Connection을 Parameter로 넘겨서 유지하는 방식 대신
        // -> 트랜잭션 동기화 사용하려면 DataSourceUtils 사용
        Connection connection = DataSourceUtils.getConnection(dataSource);
        log.info("get connection = {}, class = {}", connection, connection.getClass());
        return connection;
    }

마지막으로 실제 트랜잭션 매니저를 사용하는 서비스단의 코드를 확인해봅시다.

public class MemberServiceWithTransactionManager {
    private final MemberRepositoryV3 memberRepository;
    //private final DataSource dataSource;
    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 IllegalStateException(e);
        }
    }

    private void bizLogic(String fromId, String toId, int money) throws SQLException {
        //...
    }

    private void validation(Member toMember) {
        //...
    }
}

현재까지 저희는 트랜잭션 추상화를 통해서 서비스단의 코드는 더이상 JDBC 기술에 의존하지 않게 되었습니다.다음으로는 반복되는 try-catch 구조를 한번 해결해봅시다.

트랜잭션 템플릿

위에 언급된 것처럼 현재 트랜잭션을 사용하기 위해서는 서비스단에서 try-catch 구문을 계속 사용해야합니다.이러한 과정을 해결하기 위해 스프링은 템플릿 콜백 패턴을 도입하였습니다.

스프링에서 템플릿 콜백 패턴을 도입시킨 클래스가 바로 트랜잭션 템플릿이라는 클래스입니다.이번 포스팅에서는 스프링이 트랜잭션 템플릿을 도입해서 위와 같은 문제를 해결했다라는 정도까지만 다루겠습니다.추후 템플릿 콜백 패턴에 대한 자세한 포스팅을 진행할 예정입니다.

결론적으로 트랜잭션 템플릿을 서비스단에 도입을 하면 아래와 같이 코드가 변경됩니다.

public class MemberServiceWithTransactionTemplate {
    private final MemberRepositoryV3 memberRepository;
    private final TransactionTemplate txTemplate;

    public MemberServiceWithTransactionTemplate(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 IllegalStateException(e);
            }
        });
    }

    private void bizLogic(String fromId, String toId, int money) throws SQLException {
        //...
    }

    private void validation(Member toMember) {
        //...
    }
}

txTemplate에서 executeWithoutResult()를 람다로 호출한 뒤,람다식 안에 저희가 기존에 사용했던 비지니스 로직 메서드인 bizLogic를 호출합니다.

필자가 느끼기에도 순수하게 JDBC만으로 트랜잭션을 적용하던 코드보다 많은 것을 개선시킨것 같지만 아직 가장 중요한 한가지가 남아있습니다.서비스단에서 트랜잭션을 처리하는 로직을 처리해주는것입니다.마지막으로 이를 스프링은 어떻게 해결했는지 알아봅시다.

프록시 도입

이번 챕터에서는 서비스단에 순수 서비스 로직만 남기는것을 목표로 합니다.이를 위해서 스프링에서는 프록시를 도입해서 해결하고 있습니다.프록시라 함은 쉽게 “가짜”라고 생각하시면 됩니다.만약 트랜잭션 프록시를 도입하게 되면 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져갑니다.그리고 트랜잭션을 시작한 후에 실제 서비스를 호출합니다.이러한 트랜잭션 프록시 덕분에 서비스단에서는 순수한 비지니스 로직만 남길 수 있습니다.

사실 위와 같은 프록시는 스프링이 제공하는 AOP와 함께 동작하면서 트랜잭션 로직을 서비스단에서 분리시켜 줍니다.하지만 현재 단계에서는 AOP에 관한 자세한 얘기를 하지 않고 이를 이용해서 문제를 해결했다정도로만 포스팅하겠습니다.

그러면 최종적으로 서비스단이 어떻게 되었는지 살펴봅시다.

public class MemberServiceWithProxyAOP {
    private final MemberRepositoryV3 memberRepository;

    public MemberServiceWithProxyAOP(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 {
        //...
    }

    private void validation(Member toMember) {
        //...
    }
}

JPA를 통해 한번이라도 어플리케이션을 만들어 보신 분들이라면 익숙한 구조일 것입니다.바로 @Transactional 어노테이션이 사용되었습니다.실제로 스프링은 위와 같은 간단한 어노테이션으로 서비스단의 트랜잭션 로직을 분리해 사용자에게 편리함을 제공하고 있습니다.마지막으로 AOP 프록시가 잘 적용되었는지 테스트 코드를 통해 확인해봅시다.우선 @Transactional 사용을 위해 스프링 빈 등록을 TestConfig를 통해 진행해줍니다.

@Slf4j
@SpringBootTest
class MemberServiceWithProxyAOPTest {

    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 MemberServiceWithProxyAOP 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
        MemberServiceWithProxyAOP MemberServiceWithProxyAOP() {
            return new MemberServiceWithProxyAOP(memberRepositoryV3());
        }
    }
   	//...
    @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();
    }

    //...

}

이후 aopCheck 테스트를 돌려보시면 아래와 같이 memberService 인스턴스는 프록시 객체(CGLIB)이다라는 것을 확인해 볼 수 있습니다.

아래의 그림처럼 트랜잭션 프록시가 모든 트랜잭션 로직을 가져가 프록시 객체에서 처리해주고 나서 실제 서비스가 호출 될때 저희가 사용하는 비지니스 로직이 호출되어 데이터에 접근하게 되는 것입니다.

김영한 - 스프링 DB 1

이로써 단순히 JDBC에서 트랜잭션을 사용하는 법에서 저희가 익숙하게 사용한 @Transactional까지 단계별로 알아보았습니다.

Ref : 김영한 - 스프링 DB 1