이 글은 강의 : 김영한님의 - "[스프링 DB 1편 - 데이터 접근 핵심 원리]"을 듣고 정리한 내용입니다. 😁😁
1~4번까지 점진적 코드의 개선이 있었다.
🎃 TranscationTemplate에는 한 가지 문제가 남는다. 서비스 계층에 transcationTemplate.execute()라는 코드가 남는다는 것이다. 이것은 서비스 계층이 트랜잭션 기술에 의존하는 것을 의미한다. 바꿔 이야기하면 서비스 계층에 비즈니스 로직 외에 부가 관심사가 들어간다는 것을 의미한다.
스프링 AOP를 통해 프록시를 도입하면 문제를 깔끔하게 해결할 수 있다.
🎈 @Transactional
를 사용하면 스프링은 트랜잭션 AOP를 제공해준다. 트랜잭션 AOP는 AOP Proxy를 이용해서 트랜잭션과 관련된 부가 관심사를 비즈니스 로직과 분리시켜준다.
🎈 프록시를 도입하기 전에는 기존처럼 서비스의 로직에서 트랜잭션을 직접 시작한다.
🎈 트랜잭션 AOP를 이용한 프록시를 사용하면, 트랜잭션을 처리하는 객체와 비즈니스 로직 처리 객체를 명확하게 분리할 수 있다. 트랜잭션 AOP로 프록시를 생성하고, 프록시는 부가 관심사를 프록시 객체 내에서 처리해준다. 그리고 프록시는 부가 관심사 내에서 실제 대상이 되는 타겟 객체를 호출하면서 비즈니스 로직을 처리해준다. 즉, 핵심 관심사 / 부가 관심사에 따라 객체가 2개로 분리가 된다.
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를 사용하는 것이 좋다.
개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 어노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 어노테이션 기반 AOP가 적용되서 트랜잭션 프록시 객체를 만들어준다.
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
@Transactional
애노테이션을 추가했다.@Transactional
애노테이션을 이용해서 트랜잭션의 관심사 분리가 되었다. 이를 통해 유지/보수가 용이한 코드를 만들 수 있게 되었다.
스프링 AOP를 적용하려면 어드바이저, 포인트컷, 어드바이스가 필요하다. 스프링은 트랜잭션 AOP를 처리를 위해 다음 클래스를 제공. 스프링부트를 사용하면 해당 빈들은 스프링 컨테이너에 자동으로 등록된다.
어드바이저 : BeanFactoryTransactionAttributeSourceAdvisor
포인트컷 : TransactionAttributeSourcePointcut
어드바이스 : TransactionAttributeSourcePointcut
트랜잭션 AOP를 사용하는 새로운 서비스 클래스를 만들자
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.SQLException;
/**
* 트랜잭션 - @Transactional AOP
*/
@Slf4j
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_3(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 {
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 적용 대상이 된다.
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
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
MemberRepositoryV3 memberRepository;
@Autowired
MemberServiceV3_3 memberService;
@AfterEach
void after() throws SQLException {
memberRepository.delete(MEMBER_A);
memberRepository.delete(MEMBER_B);
memberRepository.delete(MEMBER_EX);
}
@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());
}
}
@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();
}
@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 findMemberEx =
memberRepository.findById(memberEx.getMemberId());
//memberA의 돈이 롤백 되어야함
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
🎃 @SpringBootTest
: 스프링 AOP를 적용하려면 스프링 컨테이너가 필요하다. 이 애노테이션이 있으면 테스트시 스프링 부트를 통해 스프링 컨테이너를 생성한다. 그리고 테스트에서 @Autowired
등을 통해 스프링 컨테이너가 관리하는 빈들을 사용할 수 있다. (스프링 컨테이너를 생성하고 스프링 빈 의존관계 주입도 해주는게 핵심 !)
근데 아직 스프링 빈으로 등록을 안해놨기 때문에 아직 의존관계 주입이 안돼 ! 그래서 이제 스프링 빈으로 등록을 해주자 ! → @TestConfiguration
🎃 @TestConfiguration
: 테스트 안에서 내부 설정 클래스를 만들어서 사용하면서 이 에노테이션을 붙이면, 스프링 부트가 자동으로 만들어주는 빈들에 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 수 있다.
🎃 TestConfig
메서드
DataSource
스프링에서 기본으로 사용할 데이터소스를 스프링 빈으로 등록한다. 추가로 트랜잭션 매니저에서도 사용한다.클라이언트가 비즈니스 로직을 호출한다.
비즈니스 로직은 프록시 객체를 호출한다.(@Transactional
애노테이션이 있기 때문에)
프록시 객체는 트랜잭션을 시작한다. 프록시 객체는 내부적으로 트랜잭션 매니저를 가지고 있다. 트랜잭션 매니저는 스프링 컨테이너를 통해 주입 받는다.
트랜잭션 매니저는 getTransaction()을 호출한다. 이 때, 트랜잭션 매니저는 데이터 소스로부터 커넥션을 가지고 온다. 가져온 커넥션에 setAutoCommit을 False로 설정해준다.
트랜잭션 매니저는 설정된 커넥션을 트랜잭션 동기화 매니저에게 저장해준다.
프록시 객체는 실제 비즈니스 로직을 호출한다. 비즈니스 로직은 Repository를 호출한다.
Repository는 트랜잭션이 있어야 DB와 통신을 할 수 있는데, 이 때 Repository는 내부적으로 가지고 있는 DataSourceUtils.getConnection()을 이용해 트랜잭션 동기화 매니저에게서 동기화 된 커넥션을 받아온다.
다 끝나면 반환되어서, 트랜잭션 프록시 객체에서 Commit / RollBack을 처리해준다.
@Transactional
애노테이션 하나로 accountTransfer() 비즈니스 로직을 알아서 상황에 따라 트랜잭션 commit/rollback 해주네 !! 성공하거나.. 실패해서 이전 상태로 돌아가거나.. 테스트 성공.
🎈 선언적 트랜잭션 관리
@Transactional
애노테이션 하나만 선언해서 트랜잭션을 처리하는 방법🎈 프로그래밍 방식의 트랜잭션 관리
두 가지 방법에는 각각의 장점이 있다. @Transactional은 간편하게 이용할 수 있기 때문에 실무에서 많이 사용하는 방법이다. TranscationManager와 TransactionTemplate은 스프링 컨테이너가 없어도 사용할 수 있기 때문에 스프링 기술 의존성이 많이 줄어든다.
그냥 애노테이션 사용이 편해 !
스프링이 제공하는 @Transactional
덕분에 트랜잭션 관련 코드를 순수한 비즈니스 로직에서 제거할 수 있었다.
개발자는 트랜잭션이 필요한 곳에 @Transactional 어노테이션을 하나 추가하기만 하면 된다.
@Transactional
은 스프링 AOP 프록시 객체를 전달해준다. 이것은 Bean PostProcessor를 통해서 처리가 되기 때문에 스프링 컨테이너가 반드시 필요하다.
스프링 부트가 등장하기 이전에 개발자들은 DataSource와 TransactionManager를 개발자가 직접 스프링 빈으로 등록해서 사용했다. 그런데 스프링 부트로 개발을 시작한 개발자라면 DataSource나 TransactionManager를 직접 등록한 적이 없다.
@Bean
public DataSource dataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
return dataSource;
}
@Bean
public TransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSource());
}
application.properties
에 있는 설정을 참고해서 DataSource를 등록한다. application.properties
에서 처리할 수 있다.🎈 application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
스프링 부트는 적절한 트랜잭션 매니저(PlatformTransactionManager)를 자동으로 스프링 빈에 등록해준다.
자동으로 등록되는 스프링 빈의 이름은 'transactionManager'다.
개발자가 직접 트랜잭션 매니저를 빈으로 등록하면 스프링 부트는 트랜잭션 매니저를 자동으로 등록하지 않는다.
TransactionManager는 DB 접근 기술을 어떤 것을 사용하느냐에 따라 다른 TransactionManager 구현체가 필요하다. 그렇다면 스프링부트는 어떤 것을 기준으로 TransactionManager를 생성해서 등록해줄까? 스프링부트는 현재 등록된 라이브러리를 참고해서 적절한 TransactionManager를 등록해준다.
Jdbc 라이브러리가 존재할 경우 DataSourceTransactionManager를 등록해준다. JPA를 사용하면 JpaTransactionManager를 등록해준다. 둘다 사용하는 경우에는 JpaTransactionManager를 등록해주는데, JpaTransactionManager는 DataSourceTransactionManager의 대부분의 기술을 대응해줄 수 있기 때문에 등록해준다.
@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());
}
}
🎈 이전에 작성한 코드. 이렇게 데이터소스와 트랜잭션 매니저를 직접 등록하면 스프링 부트는 데이터소스와 트랜잭션 매니저를 자동으로 등록하지 않는다.
🎈 이번에는 스프링부트가 제공하는 자동 등록을 이용해서 데이터소스와 트랜잭션 매니저를 편리하게 적용해보자.
→ 먼저 application.properties
에 등록해야지... ↓
application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
🎃 MemberServiceV3_4Test
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
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 javax.sql.DataSource;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 트랜잭션 - @Transactional AOP
*/
@Slf4j
@SpringBootTest
class MemberServiceV3_4Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
@Autowired
MemberRepositoryV3 memberRepository;
@Autowired
MemberServiceV3_3 memberService;
@AfterEach
void after() throws SQLException {
memberRepository.delete(MEMBER_A);
memberRepository.delete(MEMBER_B);
memberRepository.delete(MEMBER_EX);
}
@TestConfiguration
static class TestConfig {
private final DataSource dataSource;
TestConfig(DataSource dataSource) { //스프링 컨테이너에 등록된 dataSource를 의존관계 주입해줌.
this.dataSource = dataSource;
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource);
}
@Bean
MemberServiceV3_3 memberServiceV3_3() {
return new MemberServiceV3_3(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();
}
@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 findMemberEx =
memberRepository.findById(memberEx.getMemberId());
//memberA의 돈이 롤백 되어야함
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
기존( MemberServiceV3_3Test )과 같은 코드이고 TestConfig 부분만 다르다. (dataSource, transactionManager가 빠짐)
데이터소스와 트랜잭션 매니저를 스프링 빈으로 등록하는 코드가 생략되었다. 따라서 스프링 부트가 application.properties
에 지정된 속성을 참고해서 데이터소스와 트랜잭션 매니저를 자동으로 생성해준다.
코드에서 보는 것처럼 생성자를 통해서 스프링부트가 만들어준 데이터소스 빈을 주입 받을 수도 있다. 실행해보면 모든 테스트가 정상 수행되는 것을 확인할 수 있다.
스프링부트가 제공하는 DataSource/TransactionManager의 자동 빈 등록 기능을 사용하자. → 편리해 !
application.properties
를 이용해 편리하게 설정을 할 수 있다.