@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyTransactional {
}
MyTransactional annotaion을 선언해줍니다.
- RetentionPolicy.CLASS : 컴파일러가 컴파일에서는 어노테이션의 메모리를 가져가지만 실질적으로 런타임시에는 사라지게 됩니다. 런타임시에 사라진다는 것은 리플렉션으로 선언된 어노테이션 데이터를 가져올 수 없게 됩니다. 디폴트값입니다.
- RetentionPolicy.RUNTIME : 어노테이션을 런타임시에까지 사용할 수 있습니다. JVM이 자바 바이트코드가 담긴 class 파일에서 런타임환경을 구성하고 런타임을 종료할 때까지 메모리는 살아있습니다.
@Slf4j
@Aspect
@RequiredArgsConstructor
public class MyTransactionalAspect {
private final PlatformTransactionManager transactionManager;
@Around("@annotation(MyTransactional)")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
log.info("[Transaction Start] {}", joinPoint.getSignature());
final TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 비지니스 로직
joinPoint.proceed();
// 트랜잭션 종료
transactionManager.commit(status);
} catch (Exception e) {
// 실패시 롤백
transactionManager.rollback(status);
throw new IllegalStateException(e);
}
log.info("[Transaction End] {}", joinPoint.getSignature());
return null;
} catch (IllegalStateException e) {
log.info("[Transaction Rollback] {}", joinPoint.getSignature());
throw e;
} finally {
log.info("[Resource Release] {}", joinPoint.getSignature());
}
}
}
MyTransactional annotaion이 붙은 메소드에 프록시 로직입니다.
- TransactionManager : transaction에 이용되는 connection을 동기화 시켜주는 역할을 합니다.(ThreadLocal 사용)
- transactionManager.commit(status) : target 로직에서 예외가 발생하지 않으면 commit이 동작하고 예외가 발생한다면 rollback이 동작하게 됩니다.
- status를 파라미터로 주면 해당 커넥션이 pool로 반환되면서 트랜잭션이 종료됩니다.
- @Around : Pointcut, 비지니스 메소드 실행 전과 실행 후 Advice 메소드 동작하는 형태입니다.
- ("@annotation(MyTransactional)") : Joinpoint, 해당 어노테이션이 붙은 메소드에만 동작합니다.
- joinPoint.proceed() : 실제 target을 호출하게 됩니다.
@Slf4j
@Component
public class MemberServiceV4 {
private final MemberRepositoryV4 memberRepository;
public MemberServiceV4(MemberRepositoryV4 memberRepository) {
this.memberRepository = memberRepository;
}
@MyTransactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
accountTransferLogic(fromId, toId, money);
}
private void accountTransferLogic(String fromId, String toId, int money) throws SQLException {
final Member fromMember = memberRepository.findById(fromId);
final Member toMember = memberRepository.findById(toId);
memberRepository.update(fromMember.getMemberId(), fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toMember.getMemberId(), toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex"))
throw new IllegalStateException("이체 중 예외발생");
}
}
계좌이체 로직입니다.
@Slf4j
@Import(MyTransactionalAspect.class) // Aspect를 import해줘야 테스트코드에서 작동하게 됩니다.
@SpringBootTest
class MemberServiceV4Test {
@Autowired
MemberServiceV4 memberService;
@Autowired
MemberRepositoryV3 memberRepository;
private static final String toMemberId = "김다현";
private static final String fromMemberId = "김사나";
private static final String errorMemberId = "ex";
private static final int money = 10000;
@TestConfiguration
static class TestConfig {
// spring container 에 dataSource 가 자동으로 등록되어 주입받아 사용합니다.
private final DataSource dataSource;
TestConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource);
}
}
// 테스트를 반복하기위해 테스트가 끝난 후 데이터를 제거해줍니다.
@AfterEach
void after() throws SQLException {
memberRepository.delete(toMemberId);
memberRepository.delete(fromMemberId);
memberRepository.delete(errorMemberId);
}
// memberService에 MyTransactional AOP 어노테이션을 적용했기 때문에
// 해당 메소드는 트랜잭션이 적용된 프록시가 주입되어야 됩니다.
@Test
void AopProxyCheck() {
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 {
final Member toMember = new Member(toMemberId, money);
final Member fromMember = new Member(fromMemberId, money);
memberRepository.save(toMember);
memberRepository.save(fromMember);
memberService.accountTransfer(toMember.getMemberId(), fromMember.getMemberId(), money);
final Member toMemberResult = memberRepository.findById(toMember.getMemberId());
final Member fromMemberResult = memberRepository.findById(fromMember.getMemberId());
assertThat(toMemberResult.getMoney()).isEqualTo(money-money);
assertThat(fromMemberResult.getMoney()).isEqualTo(money+money);
}
@Test
@DisplayName("이체 중 예외발생")
void accountTransferEx() throws SQLException {
final Member toMember = new Member(toMemberId, money);
final Member fromMember = new Member(errorMemberId, money);
memberRepository.save(toMember);
memberRepository.save(fromMember);
assertThatThrownBy(() -> memberService.accountTransfer(toMember.getMemberId(), fromMember.getMemberId(), money))
.isInstanceOf(IllegalStateException.class);
final Member toMemberResult = memberRepository.findById(toMember.getMemberId());
final Member fromMemberResult = memberRepository.findById(fromMember.getMemberId());
assertThat(toMemberResult.getMoney()).isEqualTo(money);
assertThat(fromMemberResult.getMoney()).isEqualTo(money);
}
}
MyTransactional이 제대로 구현되었나 확인할 수 있는 테스트 코드입니다.
정상적으로 구현이 되었다면 아래 사진처럼 테스트 코드를 통과하게 됩니다.
reference :
https://sas-study.tistory.com/329,
김영한 선생님,
https://developer-joe.tistory.com/221