3가지 기능이 필요하다.
일단 단순하게 시작해서 점차 발전시켜본다.
// Transactional로 사용할 어노테이션은 먼저 생성했다.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyTransactional { }
public class MyTransactionalAnnotationChecker {
private static final Class<MyTransactional> ANNOTATION_TYPE = MyTransactional.class;
public boolean hasAnnotation(Object obj) {
return isEligibleClass(obj) || hasEligibleMethod(obj);
}
private boolean hasEligibleMethod(Object obj) {
// AopUtils로 프록시 내부의 실제 클래스 가져오기
for (Method method : AopUtils.getTargetClass(obj).getMethods()) {
if(hasAnnotation(method))
return true;
}
return false;
}
private boolean isEligibleClass(Object obj) {
return hasAnnotation(
AopUtils.getTargetClass(obj)
);
}
private boolean hasAnnotation(Class<?> targetClass) {
// AnnotationUtils로 어노테이션이 붙어있는지 확인
return AnnotationUtils.findAnnotation(targetClass, ANNOTATION_TYPE) != null;
}
private boolean hasAnnotation(Method method) {
return AnnotationUtils.findAnnotation(method, ANNOTATION_TYPE) != null;
}
}
Spring에서 제공하는
AnnotationUtils.findAnnotation를 활용해서 해당 클래스 또는 메소드에 어노테이션이 붙어있는지 확인이 가능하다.
org.springframework.core.annotation.AnnotationUtils
BeanPostProcessor에 들어오기 전에 이미 프록시 객체로 들어와 어노테이션이 인식되지 않을 수 있다.
Spring에서 제공하는AopUtils.getTargetClass를 활용해서 프록시 내부의 실제 클래스 정보를 가져올 수 있다.
org.springframework.aop.support.AopUtils
public class MyTransactionalAdvice implements MethodInterceptor {
private final MyTransactionManager myTransactionManager;
public MyTransactionalAdvice(MyTransactionManager myTransactionManager) {
this.myTransactionManager = myTransactionManager;
}
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
try {
myTransactionManager.startTransaction();
Object result = invocation.proceed();
myTransactionManager.commit();
return result;
} catch(Throwable throwable) {
myTransactionManager.rollback();
throw throwable;
}
}
}
public class MyTransactionalBeanPostProcessor implements BeanPostProcessor {
private final MyTransactionalAnnotationChecker myTransactionalAnnotationChecker;
private final MyTransactionalAdvice myTransactionalAdvice;
private final Pointcut myTransactionalPointcut;
public MyTransactionalBeanPostProcessor(
MyTransactionalAnnotationChecker myTransactionalAnnotationChecker,
MyTransactionalAdvice myTransactionalAdvice,
Pointcut myTransactionalPointcut // 아래 config에서 빈 생성
) {
this.myTransactionalAnnotationChecker = myTransactionalAnnotationChecker;
this.myTransactionalAdvice = myTransactionalAdvice;
this.myTransactionalPointcut = myTransactionalPointcut;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
/*
Advisor에서 어노테이션 여부를 체크하는데 모든 빈을 프록시로 감싸면 되지 않는가?
1. 컨테이너 내의 모든 빈을 프록시로 감싸는 작업때문에 시작시간이 느려진다. - CGLIB는 새로운 클래스(바이트코드)를 동적으로 생성으로 무거운 작업이다.
2. 불필요한 프록시 로직 추가로 메모리 공간 낭비
*/
if(!myTransactionalAnnotationChecker.hasAnnotation(bean))
return bean;
ProxyFactory proxyFactory = new ProxyFactory(bean);
proxyFactory.addAdvisor(
new DefaultPointcutAdvisor(
myTransactionalPointcut,
myTransactionalAdvice
)
);
return proxyFactory.getProxy();
}
}
// 포인트 컷 설정
@Configuration
public class MyTransactionalPointcutConfig {
@Bean
public Pointcut myTransactionalPointcut() {
// ComposablePointcut composablePointcut = new ComposablePointcut(); -> 이렇게 쓰면 안됨
// classPointcut처럼 두 번째 파라미터가 true면 상속받은 클래스나 인터페이스의 어노테이션도 찾는다
Pointcut classPointcut = new AnnotationMatchingPointcut(MyTransactional.class, true); // 클래스
Pointcut methodPointcut = new AnnotationMatchingPointcut(null, MyTransactional.class); // 메소드
return new ComposablePointcut(classPointcut).union(methodPointcut); // 클래스 OR 메소드
}
}
@SpringJUnitConfig // 내부 config를 바탕으로 컨테이너를 불러옴
public class MyTransactionalProxyTest {
@Autowired private NonMyTransactionalObject nonMyTransactionalObject;
@Autowired private ClassMyTransactionalObject classMyTransactionalObject;
@Autowired private MethodMyTransactionalObject methodMyTransactionalObject;
@Autowired private MyTransactionManager mockTransactionManager;
@BeforeEach
void setUp() {
reset(mockTransactionManager); // 각 테스트간 호출 수가 영향을 주지 않게 리셋
}
@Test
@DisplayName("어노테이션이 없는 클래스는 트랜잭션 적용 안됨")
public void test1() {
// when
nonMyTransactionalObject.hello();
// given
assertThat(AopUtils.isAopProxy(nonMyTransactionalObject)).isFalse();
verify(mockTransactionManager, never()).startTransaction(); // 트랜잭션 없음
}
@Test
@DisplayName("클래스 어노테이션 - 붙어있으면 트랜잭션 적용")
public void test2() {
// when
classMyTransactionalObject.hello();
// then
assertThat(AopUtils.isAopProxy(classMyTransactionalObject)).isTrue();
verify(mockTransactionManager, times(1)).startTransaction();
verify(mockTransactionManager, times(1)).commit();
}
@Test
@DisplayName("클래스 어노테이션이 붙어있으면 모든 메서드 트랜잭션 적용")
public void test3() {
// when - 메서드 2번 호출
classMyTransactionalObject.hello();
classMyTransactionalObject.service();
// then
assertThat(AopUtils.isAopProxy(classMyTransactionalObject)).isTrue();
verify(mockTransactionManager, times(2)).startTransaction();
verify(mockTransactionManager, times(2)).commit();
}
@Test
@DisplayName("트랜잭션 적용 시 예외가 발생하면 롤백 호출")
public void test4() {
// when - 메서드 2번 호출
assertThatThrownBy(() -> classMyTransactionalObject.error());
// then
assertThat(AopUtils.isAopProxy(classMyTransactionalObject)).isTrue();
verify(mockTransactionManager, times(1)).startTransaction();
verify(mockTransactionManager, times(1)).rollback(); // 롤백 호출
}
@Test
@DisplayName("메소드 어노테이션은 붙어있는 메소드만 적용 - 적용되지 않은 메소드 호출")
public void test5() {
// when
methodMyTransactionalObject.hello();
// then
assertThat(AopUtils.isAopProxy(methodMyTransactionalObject)).isTrue();
verify(mockTransactionManager, never()).startTransaction();
verify(mockTransactionManager, never()).commit();
}
@Test
@DisplayName("메소드 어노테이션은 붙어있는 메소드만 적용 - 적용된 메소드 호출")
public void test6() {
// when
methodMyTransactionalObject.service();
// then
assertThat(AopUtils.isAopProxy(methodMyTransactionalObject)).isTrue();
verify(mockTransactionManager, times(1)).startTransaction();
verify(mockTransactionManager, times(1)).commit();
}
static class NonMyTransactionalObject {
public void hello() { }
}
@MyTransactional
static class ClassMyTransactionalObject {
public void hello() { }
public void service() { }
public void error() { throw new IllegalArgumentException(); }
}
static class MethodMyTransactionalObject {
public void hello() { }
@MyTransactional
public void service() { }
}
@Configuration
@ComponentScan("com.example.my_spring_server.my.transaction")
static class MyTransactionalProxyTestConfig {
@Bean
public NonMyTransactionalObject nonMyTransactionalObject() {
return new NonMyTransactionalObject();
}
@Bean
public ClassMyTransactionalObject classMyTransactionalObject() {
return new ClassMyTransactionalObject();
}
@Bean
public MethodMyTransactionalObject methodMyTransactionalObject() {
return new MethodMyTransactionalObject();
}
@Bean
public MyDataSource myDataSource() {
return new DriverManagerDataSource(new MySQLConfig());
}
@Bean
@Primary
public MyTransactionManager mockTransactionManager() {
return mock(MyTransactionManager.class);
}
}
}
MyTransactionalBeanPostProcessor의 코드를 다시 보자
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if(!myTransactionalAnnotationChecker.hasAnnotation(bean))
return bean;
ProxyFactory proxyFactory = new ProxyFactory(bean);
proxyFactory.addAdvisor(
new DefaultPointcutAdvisor(
myTransactionalPointcut,
myTransactionalAdvice
)
);
return proxyFactory.getProxy();
}
내부에서 Advisor를 만들고 있는데 이럴 필요없다.
Bean으로 분리한다.
@Bean
public Advisor myTransactionalAdvisor(MyTransactionalAdvice myTransactionalAdvice) {
return new DefaultPointcutAdvisor(
myTransactionalPointcut(),
myTransactionalAdvice
);
}
근데 이렇게 Advisor를 생성해서 등록하면 BeanPostProcessor는 필요없다.
Spring에서 컨테이너에 있는 Advisor 설정에 따라서 프록시를 적용해주는 기능을 제공한다.
BeanPostProcessor와 관련된 컴포넌트를 주석처리한다.
//@Component
public class MyTransactionalAnnotationChecker { ... }
//@Component
public class MyTransactionalBeanPostProcessor implements BeanPostProcessor { ... }
통합 테스트 코드를 돌려보면 오류가 뜬다.

자동으로 프록시로 등록해주는 DefaultAdvisorAutoProxyCreator를 등록해줘야 한다.
@SpringJUnitConfig // 내부 config를 바탕으로 컨테이너를 불러옴
public class MyTransactionalProxyTest {
...
@Configuration
@ComponentScan("com.example.my_spring_server.my.transaction")
static class MyTransactionalProxyTestConfig {
...
@Bean // 이것만 등록해주자. - static으로 등록하는게 좋다.
public static DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
return new DefaultAdvisorAutoProxyCreator();
}
}
}
다시 테스트 코드를 돌려보면 정상 동작한다.

static Bean을 사용하는 이유
기존 Bean보다 빠르게 등록된다.
그래서 Bean들이 사용해야하는 인프라 Bean의 경우 static으로 등록하는 것이 좋다.
참고 - Tistroy 블로그 글 - static @Bean 팩토리 메서드란
// 적용 전 - FoodOrderService
public void order(long userId, FoodOrderRequests foodOrderRequests) {
Users user = usersRepository.findById(userId);
List<Foods> foods = foodsRepository.findAll(foodOrderRequests.foodIds());
List<FoodOrders> foodOrders = FoodOrders.from(foodOrderRequests, foods);
myTransactionManager.startTransaction();
try {
Orders order = orderService.order(user, foodOrders);
orderRepository.save(order);
usersRepository.updateBalance(user.getId(), user.getBalance());
for (Foods food : foods)
foodsRepository.updateStock(food.getId(), food.getStock());
myTransactionManager.commit();
} catch(RuntimeException e) {
myTransactionManager.rollback();
throw e;
}
}
// 적용 후 - FoodOrderService
@MyTransactional
public void order(long userId, FoodOrderRequests foodOrderRequests) {
Users user = usersRepository.findById(userId);
List<Foods> foods = foodsRepository.findAll(foodOrderRequests.foodIds());
List<FoodOrders> foodOrders = FoodOrders.from(foodOrderRequests, foods);
Orders order = orderService.order(user, foodOrders);
orderRepository.save(order);
usersRepository.updateBalance(user.getId(), user.getBalance());
for (Foods food : foods)
foodsRepository.updateStock(food.getId(), food.getStock());
}
적용한 것을 테스트할 때 문제가 발생했다.
Advice까진 동작을 했는데 Advice 내부에서 TM의 메서드를 호출했지만, TM의 내부 코드가 동작하지 않았다.
알아보니 TM이 mock처리 돼있었다.
@SpringJUnitConfig
class FoodOrderServiceTest {
@Autowired
private MyTransactionManager myTransactionManager;
@Test
@DisplayName("TM이 Mock 처리됐어요")
public void test() {
assertThat(Mockito.mockingDetails(myTransactionManager).isMock()).isTrue();
}
@Configuration
@Import(AppConfig.class)
static class TestConfig {
...
}
다른 클래스에서 mock 처리를 한 기억이 있어서 해당 bean을 주석처리했는데 그제서야 비로소 TM 코드가 실행됐다.
@SpringJUnitConfig
public class MyTransactionalProxyTest {
...
@Configuration
@ComponentScan("com.example.my_spring_server.my.transaction")
static class TestConfig {
...
@Bean
@Primary
public MyTransactionManager mockTransactionManager() {
return mock(MyTransactionManager.class);
}
}
}
정확한 원인
@SpringJUnitConfig는 내부적으로 SpringExtension 사용하고 테스트 컨텍스트 캐싱이 발생한다.
그래서 캐싱된 정보가 다른 클래스에서 등장한 것이다.
참고 : Tistory 블로그 글 - 스프링의 테스트 컨텍스트 캐싱(Spring TestContext Caching)에 대해 알아보자
해결방법
간단하게 직접 TM으로 등록하는 방식이 아니라
@MockBean을 사용하면 해결된다.
@MockBean은 사용이 끝나면 컨테이너에서 등록 해제되기 때문에 문제가 발생하지 않는다.
@MockBean은 SpringBoot의 기능이다. Spring 6.2.0 이후의 기능인@MockitoBean을 사용했다.
해결방법의 단점
@MockBean,@MockitoBean을 사용할 경우 Context Reload가 발생한다는 단점이 있다.
즉 Context Reload로 인해 테스트 실행시간이 길어질 수 있다.
큰 규모의 통합 테스트를 할 경우에는 치명적인 단점이 될 수 있다.
참고 : SpringBootTest @MockBean의 실행과정과 context reload
이제 MyTransactionManager를 직접 사용할 필요가 없어졌다.
@MyTransactional을 붙이기만 하면 알아서 적용된다.
MyTransactionalAdvisor가 있기 때문이다. 이 Advisor는
실제 트랜잭션 매니저의 로직을 담고 있는 MyTransactionalAdvice와
@MyTransactional을 클래스 또는 메소드 레벨에서 사용하는 빈을 겨냥하는 myTransactionalPointcut으로 이뤄져있다.
이 Advisor는 DefaultAdvisorAutoProxyCreator가 포인트컷에 따라서 프록시를 생성해준다.
즉 클래스 또는 메소드 레벨에서 @MyTransactional을 사용하면 프록시 객체가 되는 것이다.