최근 Spring AOP를 학습하는 도중 self-invocaion이라는 문제를 발견했습니다.

이 문제를 해결하면서 정리한 내용을  공유해보고자 합니다.

 

public interface Pojo {

    void foo();

    void bar();
}

 

@Slf4j
public class SimplePojo implements Pojo {

    @Override
    public void foo() {
        log.info("### foo");
        bar(); // this.bar()
    }

    @Override
    public void bar() {
        log.info("### bar");
    }
}

SimplePojo 클래스는 Pojo인터페이스를 구현한 클래스입니다.

foo()는 bar()를 호출하고있는 모습입니다.

 

테스트 케이스

Pojo reference를 통해서 직접적으로 foo()메서드를 실행하는 코드입니다.

    @Test
    @DisplayName("pojo를 만들어서 직접 호출한다")
    void direct_pojo() {
        final Pojo pojo = new SimplePojo();
        // direct method call on the 'pojo' reference
        pojo.foo();
    }

위 테스트를 실행하면 예상한대로 로그가 출력된 것을 확인할 수 있습니다.

 

이번엔 프록시를 이용해서 foo()를 호출해보겠습니다.

@Slf4j
public class ExecuteLoggingAdvice implements MethodInterceptor {

    @Override
    public Object invoke(final MethodInvocation invocation) throws Throwable {
        log.info(">>> execute method [{}]", invocation.getMethod().getName());
        return invocation.proceed();
    }
}
    @Test
    @DisplayName("proxy를 이용해서 호출한다")
    void proxy_self_invocation() {
        final ProxyFactory factory = new ProxyFactory(new SimplePojo());
        factory.addInterface(Pojo.class);
        factory.addAdvice(new ExecuteLoggingAdvice());

        final Pojo pojo = (Pojo) factory.getProxy();
        pojo.foo();
    }

ExecuteLoggingAdvice는 target 메서드를 호출하기 전, 해당 메서드 이름을 출력하는 기능을 갖고 있습니다.

(Advice란 부가 기능을 제공하는 오브젝트를 뜻합니다)

 

self-invocation 개념을 모르기 전에는 다음과 같이 출력될 거라고 생각할 수 있습니다.

>>> execute method [foo]
### foo
>>> execute method [bar]
### bar

 

하지만 실행해보면 전혀 다른 결과가 나옵니다.

아까 예상했던 " >>> execute method [bar] " 로그는 찍히지 않았습니다.

 

이런 현상을 self-invocaion이라고 부르며, 자세한 설명은  Spring docs에서 확인할 수 있었습니다.

 This means that method calls on that object reference are calls on the proxy. 
 As a result, the proxy can delegate to all of the interceptors (advice) that are relevant to that particular method call. 
 
 However, once the call has finally reached the target object (the SimplePojo reference in this case), 
 any method calls that it may make on itself, such as this.bar() or this.foo(), are going to be invoked against the this reference, and not the proxy.
 This has important implications. It means that self-invocation is not going to result in the advice associated with a method invocation getting a chance to run.

우선 프록시를 통해 실행하는 것을 성공했습니다. (요청 위임)

하지만 이제 타겟 오브젝트에 도달하는 순간 해당 타겟 오브젝트는 자기 자신을 호출할 수 있습니다.

(여기서는 this.foo(), this.bar()입니다. 이들은 proxy가 아닌 this 참조를 이용해서 호출하는 것입니다)
즉 bar()는 외부 호출이 아닌 내부 호출입니다.

 

그림으로 나타내면 다음과 같습니다. 

타겟 오브젝트(SimplePojo)는 자기 자신을 호출하기 때문에 부가기능이 실행되지 않는 것입니다.

 

그럼 self-invocation은 실무에서 어느 상황에 발생할 수 있을까요?

self-invocation은 실무에서 트랜잭션을 이용할 때 발생할 수 있습니다.

 

다음과 같이 MemberService가 있습니다. doSomething()메서드는 어떤 로직을 처리하고 최종적으로 Member를 저장합니다. 

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    public void doSomething(final Member member) {
        // logic
        saveMember(member);
    }

    @Transactional
    public void saveMember(final Member member) {
        memberRepository.save(member);
    }
}

로그레벨

logging:
  level:
    org.springframework.transaction: trace
    org.springframework.orm: trace

테스트 코드

@SpringBootTest
class MemberServiceTest {

    @Autowired
    MemberService memberService;

    @Autowired
    MemberRepository memberRepository;

    @Test
    @DisplayName("CGLIB으로 만든 프록시를 호출 한 뒤 self invocation을 확인한다")
    void self_invocation() {
        assertThat(Enhancer.isEnhanced(memberService.getClass())).isTrue(); // (1)
        memberService.doSomething(new Member("member1"));
    }
}

침고로 @Transactional 어노테이션을 메서드 혹은 클래스 레벨이 있을 경우 해당 클래스를 프록시로 생성합니다.

(스프링 부트 2.0부터는 기본적으로 Cglib으로 생성함)

 

 

위 테스트를 실행하면 어떤 결과가 나올까요?

-> Member가 정상적으로 저장됩니다. 하지만 self-invocation 때문에 트랜잭션이 원하는데로 동작하지 않았습니다.

 

Member가 저장된 이유는 로그를 분석하면서 설명하겠습니다.

(1) 2021-08-08 13:50:38.686 TRACE 10906 --- [           main] t.a.AnnotationTransactionAttributeSource : Adding transactional method 'com.jinho.selfinvacation.MemberService.saveMember' with attribute: PROPAGATION_REQUIRED,ISOLATION_DEFAULT 
(2) 2021-08-08 13:59:03.929 DEBUG 11051 --- [           main] o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
(3) 2021-08-08 13:50:39.697 TRACE 10906 --- [           main] o.s.t.i.TransactionInterceptor           : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]

(1) : saveMember()에 @Transactional 어노테이션을 확인하고 transactional method를 추가한다

(2): 해당 메서드를 실행하면서 트랜잭션을 만들고 (3): 트랜잭션을 시작합니다.

 

로그를 보면 뜬금없이 SimpleJpaRepository에서 트랜잭션이 생성, 시작되었다고 나오는데 이는 MemberRepository가 JpaRepository를 상속했기 때문입니다. JpaRepository를 상속하면 구현체인 SimpleJpaRepository가 실행되는데,  SimpleJpaRepository에 기본적으로 트랜잭션이 붙어있기 때문에 Memer는 저장이 된 것입니다.

 

하지만 결론적으로 selft-invocation 때문에 원하는 데로 saveMember 메서드에서 트랜잭션이 동작하지 않았습니다.

따라서 트랜잭션 어노테이션을 잘못 활용하면 self-invocaion 문제로 실무에서 치명적인 버그로 이어질 수 있습니다.


그렇다면 우선 이 문제를 어떻게 해결하면 좋을까요?

 

Spring AOP 대신 AspectJ를 사용하거나, Self Injection, 클래스 내 로직을 AOP로 완전히 묶거나(스프링에서 비추천) 하는 방식이 있었는데 개인적인 생각에는 좋은 방식이라고 생각들지 않았습니다.

 

제  생각에는 가장 베스트는 self-invocation 상황을 아예 만들지 않는 것이 좋을 거 같습니다.
객체의 책임을 최대한 분리해서 외부 호출을하는 방법을 활용하는 게 좋을 거 같다고 생각합니다.

 

만일 위 방식에서 만일 객체의 책임을 나누지 않고 동일 메서드로 계속 이용한다면,
다음과 같이 호출하는 메서드 혹은 클래스 레벨에 @Transactional을 붙여서 호출할 때 트랜잭션을 시작하는 방법으로 해결 가능합니다.

@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    public void doSomething(final Member member) {
        saveMember(member);
    }

    public void saveMember(final Member member) {
        memberRepository.save(member);
    }
}
(1) 2021-08-09 14:53:36.619 DEBUG 39111 --- [           main] o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [com.jinho.selfinvacation.MemberService.doSomething]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
(2) 2021-08-09 14:53:36.623 TRACE 39111 --- [           main] o.s.t.i.TransactionInterceptor           : Getting transaction for [com.jinho.selfinvacation.MemberService.doSomething]
(3) 2021-08-09 14:53:36.635 DEBUG 39111 --- [           main] o.s.orm.jpa.JpaTransactionManager        : Participating in existing transaction
(4) 2021-08-09 14:53:36.635 TRACE 39111 --- [           main] o.s.t.i.TransactionInterceptor           : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
...

(1): 아까와 달리 MemberService.doSomething에서 트랜잭션을 생성합니다.

(2): doSomething에서 시작,

(3) ~ (4): SimpleJpaRepository에서 트랜잭션을 만났지만, 이미 존재하는 transacion에 참여합니다. 

 

 

정리

  • 타겟 오브젝트에서 this.~~() 메서드로 자기 자신을 호출하면 self invocation이 발생한다.
  • 다양한 해결 방법이 있지만, 객체의 책임을 나누고 외부 호출로 변경하는 것이 좋을 거 같다는 개인적인 생각입니다.
  • 트랜잭션 같은 경우 self-invocation을 하기 전에 트랜잭션을 시작하면 해결할 수 있습니다.

 

Reference


 

Core Technologies

In the preceding scenario, using @Autowired works well and provides the desired modularity, but determining exactly where the autowired bean definitions are declared is still somewhat ambiguous. For example, as a developer looking at ServiceConfig, how do

docs.spring.io

 

[세트] 토비의 스프링 3.1 (총2권) - YES24

『토비의 스프링 3.1』은 스프링을 처음 접하거나 스프링을 경험했지만 스프링이 어렵게 느껴지는 개발자부터 스프링을 활용한 아키텍처를 설계하고 프레임워크를 개발하려고 하는 아키텍트에

www.yes24.com