ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [김영한 스프링] 16. 스프링과 문제 해결(트랜잭션) - 트랜잭션 AOP
    Spring/스프링 DB 1편 - 데이터 접근 핵심 원리 2023. 12. 29. 15:25

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

     

    지금까지 트랜잭션을 편리하게 처리하기 위해서 트랜잭션 추상화도 도입하고, 추가로 반복적인 트랜잭션 로직을 해결하기 위해 트랜잭션 템플릿도 도입했다.

    트랜잭션 템플릿 덕분에 트랜잭션을 처리하는 반복 코드는 해결할 수 있었다. 하지만 서비스 계층에 순수한 비즈니스 로직만 남긴다는 목표는 아직 달성하지 못했다.

    이럴 때 스프링 AOP를 통해 프록시를 도입하면 문제를 깔끔하게 해결할 수 있다.

     

    참고
    스프링 AOP와 프록시에 대해서 지금은 자세히 이해하지 못해도 괜찮다. 지금은 @Transactional을 사용하면 스프링이 AOP를 사용해서 트랜잭션을 편리하게 처리해 준다 정도로 이해해도 된다. 스프링 AOP와 프록시에 대한 자세한 내용은 스프링 핵심 원리 - 고급편을 참고하자.

     

     

    프록시를 통한 문제 해결

    프록시 도입 전

    프록시를 도입하기 전에는 기존처럼 서비스의 로직에서 트랜잭션을 직접 시작한다.

     

     

    서비스 계층의 트랜잭션 사용 코드 예시

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

     

     

    프록시 도입 후

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

     

     

    트랜잭션 프록시 코드 예시

    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를 열심히 공부하신 분이라면 아마도 @Aspect, @Advice, @Pointcut를 사용해서 트랜잭션 처리용 AOP를 어떻게 만들지 머리속으로 그림이 그려질 것이다.
    • 물론 스프링 AOP를 직접 사용해서 트랜잭션을 처리해도 되지만, 트랜잭션은 매우 중요한 기능이고, 전세계 누구나 다 사용하는 기능이다. 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공한다. 스프링 부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록해 준다.
    • 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해 준다.

     

    @Transactional

    org.springframework.transaction.annotation.Transactional

     

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

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

     

     

    트랜잭션 문제 해결 - 트랜잭션 AOP 적용

     

    트랜잭션 AOP를 사용하는 새로운 서비스 클래스를 만들자.

     

     

    MemberServiceV3_3

    package hello.jdbc.service;
    
    import hello.jdbc.domain.Member;
    import hello.jdbc.repository.MemberRepositoryV3;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.transaction.annotation.Transactional;
    
    import java.sql.SQLException;
    
    /**
     * 트랜잭션 - @Transactional AOP
     */
    @Slf4j
    @RequiredArgsConstructor
    public class MemberServiceV3_3 {
    
        private final MemberRepositoryV3 memberRepository;
        
        @Transactional
        public void accountTransfer(String fromId, String toId, int money) throws SQLException {
            bizLogin(fromId, toId, money);
        }
    
        private void bizLogin(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("이체 중 예외 발생");
            }
        }
    }

    • 순수한 비즈니스 로직만 남기고, 트랜잭션 관련 코드는 모두 제거했다.
    • 스프링이 제공하는 트랜잭션 AOP를 적용하기 위해 @Transactional 애노테이션을 추가했다.
    • @Transactional 애노테이션은 메서드에 붙여도 되고, 클래스에 붙여도 된다. 클래스에 붙이면 외부에서 호출 가능한 public 메서드가 AOP 적용 대상이 된다.

     

     

    MemberServiceV3_3Test

    package hello.jdbc.service;
    
    import hello.jdbc.domain.Member;
    import hello.jdbc.repository.MemberRepositoryV3;
    import lombok.extern.slf4j.Slf4j;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.boot.test.context.TestConfiguration;
    import org.springframework.context.annotation.Bean;
    import org.springframework.jdbc.datasource.DataSourceTransactionManager;
    import org.springframework.jdbc.datasource.DriverManagerDataSource;
    import org.springframework.transaction.PlatformTransactionManager;
    
    import javax.sql.DataSource;
    import java.sql.SQLException;
    
    import static hello.jdbc.connection.ConnectionConst.*;
    import static org.assertj.core.api.Assertions.assertThat;
    import static org.assertj.core.api.Assertions.assertThatThrownBy;
    
    /**
     * 트랜잭션 - @Transactional AOP
     */
    @Slf4j
    @SpringBootTest
    class MemberServiceV3_3Test {
    
        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 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());
            }
        }
    
        @AfterEach
        void after() throws SQLException {
            memberRepository.delete(MEMBER_A);
            memberRepository.delete(MEMBER_B);
            memberRepository.delete(MEMBER_EX);
        }
    
        @Test
        @DisplayName("정상 이체")
        void accountTransfer() throws SQLException {
            // given
            Member memberA = new Member(MEMBER_A, 10000);
            Member memberB = new Member(MEMBER_B, 10000);
            memberRepository.save(memberA);
            memberRepository.save(memberB);
    
            // when
            memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
    
            // then
            Member findMemberA = memberRepository.findById(memberA.getMemberId());
            Member findMemberB = memberRepository.findById(memberB.getMemberId());
            assertThat(findMemberA.getMoney()).isEqualTo(8000);
            assertThat(findMemberB.getMoney()).isEqualTo(12000);
        }
    
        @Test
        @DisplayName("이체중 예외 발생")
        void accountTransferEx() throws SQLException {
            // given
            Member memberA = new Member(MEMBER_A, 10000);
            Member memberEx = new Member(MEMBER_EX, 10000);
            memberRepository.save(memberA);
            memberRepository.save(memberEx);
    
            // when
            assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                    .isInstanceOf(IllegalStateException.class);
    
            // then
            Member findMemberA = memberRepository.findById(memberA.getMemberId());
            Member findMemberB = memberRepository.findById(memberEx.getMemberId());
            assertThat(findMemberA.getMoney()).isEqualTo(10000);
            assertThat(findMemberB.getMoney()).isEqualTo(10000);
        }
    }

    • @SpringBootTest : 스프링 AOP를 적용하려면 스프링 컨테이너가 필요하다. 이 애노테이션이 있으면 테스트 시 스프링 부트를 통해 스프링 컨테이너를 생성한다. 그리고 테스트에서 @Autowired 등을 통해 스프링 컨테이너가 관리하는 빈들을 사용할 수 있다.
    • @TestConfiguration : 테스트 안에서 내부 설정 클래스를 만들어서 사용하면서 이 에노테이션을 붙이면, 스프링 부트가 자동으로 만들어주는 빈들에 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 수 있다.
    • TestConfig
      • DataSource 스프링에서 기본으로 사용할 데이터소스를 스프링 빈으로 등록한다. 추가로 트랜잭션 매니저에서도 사용한다.
      • DataSourceTransactionManager 트랜잭션 매니저를 스프링 빈으로 등록한다.
        • 스프링이 제공하는 트랜잭션 AOP는 스프링 빈에 등록된 트랜잭션 매니저를 찾아서 사용하기 때문에 트랜잭션 매니저를 스프링 빈으로 등록해두어야 한다.

     

     

    AOP 프록시 적용 확인

    package hello.jdbc.service;
    
    import hello.jdbc.domain.Member;
    import hello.jdbc.repository.MemberRepositoryV3;
    import lombok.extern.slf4j.Slf4j;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    import org.springframework.aop.support.AopUtils;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.boot.test.context.TestConfiguration;
    import org.springframework.context.annotation.Bean;
    import org.springframework.jdbc.datasource.DataSourceTransactionManager;
    import org.springframework.jdbc.datasource.DriverManagerDataSource;
    import org.springframework.transaction.PlatformTransactionManager;
    
    import javax.sql.DataSource;
    import java.sql.SQLException;
    
    import static hello.jdbc.connection.ConnectionConst.*;
    import static org.assertj.core.api.Assertions.assertThat;
    import static org.assertj.core.api.Assertions.assertThatThrownBy;
    
    /**
     * 트랜잭션 - @Transactional AOP
     */
    @Slf4j
    @SpringBootTest
    class MemberServiceV3_3Test {
    
        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 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());
            }
        }
    
        @AfterEach
        void after() throws SQLException {
            memberRepository.delete(MEMBER_A);
            memberRepository.delete(MEMBER_B);
            memberRepository.delete(MEMBER_EX);
        }
    
        @Test
        void AopCheck() {
            log.info("memberService class={}", memberService.getClass());
            log.info("memberRepository class={}", memberRepository.getClass());
            assertThat(AopUtils.isAopProxy(memberService)).isTrue();
            assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
        }
    
        @Test
        @DisplayName("정상 이체")
        void accountTransfer() throws SQLException {
            // given
            Member memberA = new Member(MEMBER_A, 10000);
            Member memberB = new Member(MEMBER_B, 10000);
            memberRepository.save(memberA);
            memberRepository.save(memberB);
    
            // when
            memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
    
            // then
            Member findMemberA = memberRepository.findById(memberA.getMemberId());
            Member findMemberB = memberRepository.findById(memberB.getMemberId());
            assertThat(findMemberA.getMoney()).isEqualTo(8000);
            assertThat(findMemberB.getMoney()).isEqualTo(12000);
        }
    
        @Test
        @DisplayName("이체중 예외 발생")
        void accountTransferEx() throws SQLException {
            // given
            Member memberA = new Member(MEMBER_A, 10000);
            Member memberEx = new Member(MEMBER_EX, 10000);
            memberRepository.save(memberA);
            memberRepository.save(memberEx);
    
            // when
            assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                    .isInstanceOf(IllegalStateException.class);
    
            // then
            Member findMemberA = memberRepository.findById(memberA.getMemberId());
            Member findMemberB = memberRepository.findById(memberEx.getMemberId());
            assertThat(findMemberA.getMoney()).isEqualTo(10000);
            assertThat(findMemberB.getMoney()).isEqualTo(10000);
        }
    }

     

     

    실행 결과 - AopCheck()

    memberService class=class hello.jdbc.service.MemberServiceV3_3$$EnhancerBySpringCGLIB$$...
    memberRepository class=class hello.jdbc.repository.MemberRepositoryV3
    • 먼저 AOP 프록시가 적용되었는지 확인해 보자. AopCheck()의 실행 결과를 보면 memberService에 EnhancerBySpringCGLIB.. 라는 부분을 통해 프록시(CGLIB)가 적용된 것을 확인할 수 있다. memberRepository에는 AOP를 적용하지 않았기 때문에 프록시가 적용되지 않는다.
    • 나머지 테스트 코드들을 실행해 보면 트랜잭션이 정상 수행되고, 실패 시 정상 롤백된 것을 확인할 수 있다.

     

     

    트랜잭션 문제 해결 - 트랜잭션 AOP 정리

     


    트랜잭션 AOP가 사용된 전체 흐름을 그림으로 정리해 보자.

     

     

    트랜잭션 AOP 적용 전체 흐름

     

     

    선언적 트랜잭션 관리 vs 프로그래밍 방식 트랜잭션 관리

    • 선언적 트랜잭션 관리(Declarative Transaction Management)
      • @Transactional 애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 선언적 트랜잭션 관리라 한다.
      • 선언적 트랜잭션 관리는 과거 XML에 설정하기도 했다. 이름 그대로 해당 로직에 트랜잭션을 적용하겠다라고 어딘가에 선언하기만 하면 트랜잭션이 적용되는 방식이다.
    • 프로그래밍 방식의 트랜잭션 관리(programmatic transaction management)
      • 트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것을 프로그래밍 방식의 트랜잭션 관리라 한다.
    • 선언적 트랜잭션 관리가 프로그래밍 방식에 비해서 훨씬 간편하고 실용적이기 때문에 실무에서는 대부분 선언적 트랜잭션 관리를 사용한다.
    • 프로그래밍 방식의 트랜잭션 관리는 스프링 컨테이너나 스프링 AOP 기술 없이 간단히 사용할 수 있지만 실무에서는 대부분 스프링 컨테이너와 스프링 AOP를 사용하기 때문에 거의 사용되지 않는다.
    • 프로그래밍 방식 트랜잭션 관리는 테스트 시에 가끔 사용될 때는 있다.

     

     

    정리

    • 스프링이 제공하는 선언적 트랜잭션 관리 덕분에 드디어 트랜잭션 관련 코드를 순수한 비즈니스 로직에서 제거할 수 있었다.
    • 개발자는 트랜잭션이 필요한 곳에 @Transactional 애노테이션 하나만 추가하면 된다. 나머지는 스프링 트랜잭션 AOP가 자동으로 처리해 준다.
    • @Transactional 애노테이션의 자세한 사용법은 뒤에서 설명한다. 지금은 전체 구조를 이해하는데 초점을 맞추자.

     

     

    출처 :  https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1

     

    스프링 DB 1편 - 데이터 접근 핵심 원리 강의 - 인프런

    백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., 백엔

    www.inflearn.com

Designed by Tistory.