본문 바로가기
개발/스프링부트

@Transactional 사용시 주의점(feat.AOP,Proxy)

by hamcheeseburger 2022. 11. 13.

트랜잭션은 ACID를 보장하기 위한 중요한 요소이다. 스프링에서는 @Transactional 을 메서드, 클래스 단에 지정하면 쉽게 트랜잭션을 적용할 수 있게 한다.

 

그런데 이렇게 쉽게 사용할 수 있는 @Transactional에도 주의할 점이 필요하다. 스프링으로 웹 개발을 한지 꽤나 되었다고 생각했는데, 지금까지 이러한 주의점을 모르고 있었다.

 

왜냐하면 이 주의점은 꽤나 깊은 지식을 이해해야 파악할 수 있는 부분이기 때문이다.

 

일단 시나리오를 두 가지 준비해 보았다.

Scenario 1.

같은 클래스 내에서 트랜잭션이 적용되지 않은 메서드 A가 트랜잭션이 적용된 메서드 B를 호출하고 있다. 외부에서 A를 호출했다면 B는 트랜잭션이 제대로 동작할까?

@Service
public class ProductService {

    public void A(Long id) {
        B(id);
    }

    @Transactional
    public ProductResponse B(final Long id) {
        final Product product = productRepository.findById(id)
                .orElseThrow(ProductNotFoundException::new);
        return ProductResponse.from(product);
    }
 }

Scenario 2.

같은 클래스 내에서 @Transactional이 적용된 메서드 A가 @Transactional(propagation=REQUIRES_NEW)가 적용된 메서드 B를 호출하고 있다. 외부에서 A를 호출했다면 메서드 B는 새로운 트랜잭션에서 동작할 수 있을까?

@Service
public class ProductService {
	
    @Transactional
    public void A(Long id) {
        B(id);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public ProductResponse B(final Long id) {
        final Product product = productRepository.findById(id)
                .orElseThrow(ProductNotFoundException::new);
        return ProductResponse.from(product);
    }
 }

정답은 둘 다 '아니다.' 이다.

 

결론을 먼저 이야기 하자면 원인은 스프링의 다이내믹 프록시 때문이다

 

스프링에서는 다양한 목적을 위해 프록시를 제공하는데, 관점 지향적 프로그래밍(AOP)을 위해 프록시를 활용할 수 있다. 우리가 사용하는 @Transactional도 관점 지향적 프로그래밍을 위해 나온 어노테이션이며, 이를 구현하기 위해 프록시라는 개념을 사용하는 것이다.

 

이를 이해하려면 Spring이 bean을 생성하는 방식에 대해서 알아야 하는데, 일단 우리가 만든 Service가 프록시로 wrapping되어 생성된다고 가정하고 상황을 살펴보자.

위는 스프링에서 다이나믹 프록시에서 메서드가 호출됐을 때의 과정을 설명하고 있다.

 

다이내믹 프록시로 생성된 ProductService의 A()가 호출 됐을 때 포인트 컷을 확인하여 해당 메서드에 부가기능을 수행할 부분이 있는지 확인한다. Transaction으로 예시를 들면 메서드에 @Transactional이 적용 되었는지 확인을 할 것이다.

@Transactional이 적용이 되었으면 어드바이스에 정의된 부가 기능을 수행하게 된다. 반대로 적용되지 않았다면 부가기능을 수행하지 않는다.

(Transaction에 한해서 부가 기능이라는 것은 트랜잭션을 시작하고, 실제 target object의 메서드에서 예외가 발생하면 롤백을 시켜주는 로직이 포함되어 있을 것이다.)

 

순서를 정리하자면 아래와 같다.

프록시 생성 -> 호출된 메서드로 포인트 컷 확인 -> 해당하면 어드바이스 실행 -> invocation callback -> target object의 메서드 실행

위의 순서에서 Senario1, Senario2에 대한 문제원인을 확인할 수 있는데, 다이내믹 프록시에서는 호출된 메서드로 포인트 컷을 확인한다는 것이다.

 

Senario1의 상황으로 이야기를 하자면, @Transactional이 붙지 않은 A()에 대해서 포인트 컷을 확인하기 때문에 트랜잭션을 수행하는 어디바이스가 동작하지 않는다. A() 내에서 호출되는 B()는 @Transactional이 적용이 되어있지만, 이미 메서드의 포인트 컷을 확인하는 작업은 지난 이후이기 때문에 B()에 적용된 속성은 고려하지 않는다.

 

마찬가지로 Senario2에서도, A()의 @Transactional를 확인하여 어드바이스가 실행이 되고, B()에 적용된 @Transactional(propagation = Propagation.REQUIRES_NEW)은 고려하지 않는다.

 

이러한 부분은 주의점은 스프링 공식문서에서도 명시하고 있다.

In proxy mode (which is the default), only external method calls coming in through the proxy are intercepted. This means that self-invocation (in effect, a method within the target object calling another method of the target object) does not lead to an actual transaction at runtime even if the invoked method is marked with @Transactional. Also, the proxy must be fully initialized to provide the expected behavior, so you should not rely on this feature in your initialization code — for example, in a @PostConstruct method.

프록시 모드(기본 설정값)에서는, 프록시를 통해 외부에서 호출된 메서드만 가로챈다. 이 말은 내부호출(self-invocation, 타겟 객체의 한 메서드가 타겟 객체에 있는 다른 메서드를 호출하는 경우) 은 호출되는 메서드가 @Transactional로 지정되어 있어도 실제 트랜잭션이 런타임 시에 동작하지 않을 것이다. 또한, 기대하는 행동을 수행하기 위해서 프록시는 완전히 초기화 되어야 하기 때문에  @PostConstruct와 같은 초기화 코드에 의존해서는 안된다.

 

실험

내부에서 어떤 방식으로 실행되는지 확인하기 위해 실험을 해보려고 한다. 아래의 두가지의 경우에 대해서 동작해보고 비교해 보도록 하겠다.

Case 1.

@Service
public class PureService {
	// 생략
    @Transactional
    public void A() {
        System.out.println("A start");
        B();
        System.out.println("A end");
    }

    @Transactional
    public void B() {
        productRepository.findById(1L);
    }
}

Case 2.

@Service
public class PureService {
	// 생략
    public void A() {
        System.out.println("A start");
        B();
        System.out.println("A end");
    }

    @Transactional
    public void B() {
        productRepository.findById(1L);
    }
}

Case 1에는 A()에 @Transactional이 적용되었지만, Case 2에는 A()에 @Transactional이 적용되지 않았다.

 

테스트에서는 A()만 호출할 것이다.

 

Call stack

A()의 트랜잭션 적용 여부에 따라 분명이 어느 부분에서 분기가 생길 것이라고 생각했다. 그래서 debug를 통해 call stack을 확인해 봤다.

왼쪽이 case 1, 오른쪽이 case 2에 대한 call stack이다.

DynamicAdvisedInterceptor에서 intercept()라는 메서드가 두 Case 모두 실행되지만 호출되는 line을 자세히 보면 다른 것을 확인 할 수 있다. Case 1은 708번째 줄이, Case 2는 704번째 줄이 실행된다.

 

DynamicAdvisedInterceptor의 intercept()

DynamicAdvisedInterceptor에서 intercept()를 좀 더 살펴보자.

DynamicAdvisedInterceptor의 intercept()

698번째 줄의 if 분기에 따라서 704번째 줄이 실행될 것인지, 708번째 줄이 실행될 것인지가 결정된다.

chain.isEmpty()라는 조건이 눈에 띈다.

chain이라는 변수가 초기화되는 694번째 줄을 확인해 보면, 현재 호출된 메서드와 관련된 interceptor들을 가지고 오는 모습이다. 메서드를 타고 들어가보면 pointcut에서 mathod matchers를 가져와 현재 메서드에 대한 부가기능을 제공할 수 있는 interceptor들을 불러오게 된다.

 

만약 @Transactional 이 붙어 있다면 TransactionInterceptor가 chain안에 들어가 있을 것이다.

정말 그런지 확인해보자.

 

아래는 DynamicInterceptor의 intercept()에서 698번째 줄에 break point를 설정한 후 디버깅을 진행한 모습이다.

Case 1에 대한 디버깅
Case 2에 대한 디버깅

첫 번째 사진이 Case 1에 대한 디버깅이고, 두 번째 사진이 Case 2에 대한 디버깅 결과이다.

두 경우 모두 method에서 A()가 호출된 것이 확인이 되지만 Case 1에는 chain의 size가 1이며 그 안에 TransactionInterceptor가 포함되어 있는 것을 확인할 수 있다. 그와 반면에 Case 2에서는 chain의 size가 0인 것을 확인할 수 있었다.

chain의 emtpy 여부에 따라 이후의 동작이 분기로 나뉘게 된다.

 

결국에 A()에 적용된 @Transactional의 여부에 따라 이후의 동작이 바뀐다는 것을 확인할 수 있다.

 

해결 방법

그렇다면 위의 Senario 들을 해결할 수 있는 방법은 없는가?

1. self-invocation 없애기

제일 단순한 방법으로는 내부 호출(self-invocation)을 없애면 된다. 호출하려는 메서드를 다른 클래스로 분리하면 된다.

2. AspectJ 활용하기

앞서 얘기한 문제점은 스프링 다이내믹 프록시의 동작 방식 때문이기 때문에 AspectJ의 Proxy 생성 방식을 활용하면 해결할 수 있다. 

AspectJ는 컴파일 시점에 바이트 코드를 조작하는 방법으로 부가기능을 바이트 코드 단에 삽입할 수 있다.

각 메서드 마다 적용된 속성들을 바이트 코드 단에 심을 수 있으므로, self-invocation의 경우도 문제가 되지 않는다.

 

요약

- @Transactional을 사용할 때 내부호출(self-invocation)을 하는 경우에는 문제가 발생할 수 있다.

- 이는 스프링의 다이내믹 프록시의 동작 방식 때문이다.

- 다이내믹 프록시는 외부에서 호출되는 메서드를 기준으로 포인트 컷과 어드바이스를 적용하기 때문이다.

- 해결방법은 내부 호출을 없애거나 프록시 생성 방식을 AspectJ로 변경하면 된다.

- 이는 @Transactional에 한정된 문제는 아니며, AOP를 활용할 때 발생할 수 있는 문제이다.

 

출처

- 토비의 스프링 Vol.1

- 스프링 공식 문서(https://docs.spring.io/spring-framework/docs/current/reference/html/data-access.html#transaction-declarative-annotations)

이전 댓글