-
[김영한 스프링] 16. 스프링과 문제 해결(트랜잭션) - 트랜잭션 AOPSpring/스프링 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
'Spring > 스프링 DB 1편 - 데이터 접근 핵심 원리' 카테고리의 다른 글
[김영한 스프링] 18. 자바 예외 이해 - 예외 계층 & 예외 기본 규칙 (1) 2023.12.29 [김영한 스프링] 17. 스프링과 문제 해결(트랜잭션) - 스프링부트의 자동 리소스 등록 (0) 2023.12.29 [김영한 스프링] 15. 스프링과 문제 해결(트랜잭션) - 트랜잭션 템플릿 (1) 2023.12.29 [김영한 스프링] 14. 스프링과 문제 해결(트랜잭션) - 트랜잭션 매니저 (0) 2023.12.29 [김영한 스프링] 13. 스프링과 문제 해결(트랜잭션) - 문제점들 & 추상화 & 동기화 (1) 2023.12.29