나만의 Transactional 만들기

Jang990·2026년 4월 7일

요구사항

3가지 기능이 필요하다.

  1. 어노테이션이 붙어있는지 판단
  2. 트랜잭션 적용 프록시 로직
  3. Bean 등록 전에 가로채서 프록시 등록

일단 단순하게 시작해서 점차 발전시켜본다.

// Transactional로 사용할 어노테이션은 먼저 생성했다.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyTransactional { }


직접 만들기

1. 어노테이션이 붙어있는지 판단

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



2. 트랜잭션 적용 프록시 로직

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;
        }
    }
}


3. Bean 등록 전에 가로채서 프록시 등록

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);
        }
    }
}


Spring 제공 기능 활용

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을 사용하면 프록시 객체가 되는 것이다.

profile
개발 기록 아카이브

0개의 댓글