[Spring] 프록시 방식의 AOP 한계: self-invocation 이슈

 

 Spring AOP 기반의 @Async를 공부하다가, self-invocation(내부 호출)하는 경우에는 AOP가 걸리지 않는 이슈를 마주하게 되었습니다.

 

 왜 내부 호출하는 메서드는 AOP 타겟이 되지 못하는 것인지, 그리고 이 문제를 해결할 수 있는 방법에는 어떤 것들이 있는지 알아보겠습니다.

 


내부 호출 시 왜 AOP 기능이 수행되지 않을까?

이 문제는 프록시 객체와 관련이 있습니다. 따라서, 먼저 프록시에 대해 간단하게 알아보겠습니다.

출처: 스프링 핵심 원리 - 고급편(인프런 김영한 님)

프록시 적용 전

실제 객체가 스프링 빈으로 등록됩니다. 빈 객체의 마지막 @x0..은 인스턴스를 의미합니다.

 

프록시 적용 후

  • 스프링 컨테이너에 프록시 객체가 등록됩니다. 스프링 컨테이너는 이제 실제 객체가 아닌 프록시 객체를 스프링 빈으로 관리합니다.
  • 실제 객체는 스프링 컨테이너와 상관이 없고, 프록시 객체를 통해 참조하게 됩니다.
  • 프록시 객체는 스프링컨테이너가 관리하고 자바 힙 메모리에도 올라가게 되는 반면에, 실제 객체는 자바 힙 메모리에는 올라가지만 스프링 컨테이너가 관리하지는 않습니다.

 

스프링은 프록시 방식의 AOP를 사용합니다. 따라서 AOP를 적용하려면 항상 프록시를 통해서 대상 객체(target)를 호출해야 합니다. 이렇게 해야 프록시에서 먼저 어드바이스를 호출하고, 이후에 대상 객체를 호출합니다.

 

AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록합니다. 따라서 스프링은 의존관계 주입 시에 항상 프록시 객체를 주입하는데요. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않습니다.

 

💫 하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생합니다. (실무에서 반드시 마주할 수 있는 문제...!)

 

 

먼저 실제로 의존관계 주입 시 프록시 적용 여부를 확인해보았습니다.

 

AopTest

@Slf4j
@SpringBootTest
class AopTest {

	@Autowired
	private RunService runService;

	@Autowired
	private MemberRepository memberRepository;

	@Test
	void AopCheck() {
		log.info("runService class={}", runService.getClass());
		log.info("memberRepository class={}", memberRepository.getClass());

		Assertions.assertThat(AopUtils.isAopProxy(runService)).isTrue();
		Assertions.assertThat(AopUtils.isAopProxy(memberRepository)).isTrue();
	}

}

 

실행 결과

위의 결과를 보면, 실제로 프록시 객체가 주입된 것을 볼 수 있습니다.

 

  • runServiceRunService$$EnhancerBySpringCGLIB$$2030c939 라는 부분을 통해 프록시(CGLIB)가 적용된 것을 확인할 수 있습니다. 이는 CGLIB를 통해서 생성된 클래스의 이름을 뜻합니다. (CGLIB는 Enhancer를 사용해서 프록시를 생성합니다.)
  • 아래 memberRepository class jdk.proxy3.$Proxy98 는 JDK Proxy가 생성한 클래스 이름입니다.

 

 

이번에는 이 글의 핵심인, 내부 호출이 발생할 때 어떤 문제가 발생하는지 알아보겠습니다.

 

RunService

위의 external()을 호출하면 내부에서 internal()이라는 자기 자신의 메서드를 호출합니다.

 

자바 언어에서 메서드를 호출할 때 대상을 지정하지 않으면 앞에 자기 자신의 인스턴스를 뜻하는 this 가 붙게 됩니다. 따라서 여기서는 this.internal()이라고 할 수 있습니다.

 

 

RunLogAspect

RunService에 AOP를 적용하기 위한 간단한 Aspect도 만들어줍니다.

 

 

RunServiceTest

앞서 만든 RunService를 테스트해보겠습니다.

  • @Import(RunLogAspect.class) : 앞서 만든 Aspect를 스프링 빈으로 등록한다. 이렇게 RunService에 AOP 프록시를 적용한다.
  • @SpringBootTest : 내부에 컴포넌트 스캔을 포함하고 있어서, RunService에 @Service가 붙어있으므로 스프링 빈 등록 대상이 된다.

 

 

💫 먼저, runService.external() 을 실행해 보겠습니다.

 

출처: 스프링 핵심 원리 - 고급편 (김영한 님)

실행 결과를 보면,

runService.external()을 실행할 때는 프록시를 호출합니다. 따라서 RunLogAspect 어드바이스가 호출된 것을 확인할 수 있습니다. 그리고 AOP Proxy는 target.external()을 호출합니다.

 

그런데 여기서 문제는 runService.external() 안에서 internal()을 호출할 때, RunLogAspect 어드바이스가 호출되지 않았다는 점입니다. 왜 internal() 호출 시, 어드바이스가 호출되지 않았을까요?

➡️ 자바 언어에서 메서드 앞에 별도의 참조가 없으면 this라는 뜻으로 자기 자신의 인스턴스를 가리킵니다. 결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal() 이 되는데, 여기서 this는 실제 대상 객체(target)의 인스턴스를 뜻합니다. 이러한 내부 호출은 프록시를 거치지 않습니다. 따라서 AOP도 적용할 수 없습니다.

 

 

이번에는 외부에서 internal()을 호출하는 테스트를 실행해 보겠습니다.

출처: 스프링 핵심 원리 - 고급편 (김영한 님)

 

외부에서 호출하는 경우 프록시를 거치기 때문에 internal() 도 RunLogAspect 어드바이스가 적용된 것을 확인할 수 있습니다.


 

프록시 방식의 AOP 한계: 프록시와 내부 호출

이번에는 이러한 한계에 대한 대안을 알아보겠습니다.

 

대안 1) 자기 자신 주입

내부 호출을 해결하는 가장 간단한 방법은 자기 자신을 의존관계 주입받는 것입니다.

 

RunService

 

runService를 수정자를 통해서 주입받았습니다. 스프링에서 AOP가 적용된 대상을 의존관계 주입을 받으면 주입받은 대상은 실제 자신이 아니라 프록시 객체입니다.

external()을 호출하면 runService.internal()를 호출하게 되므로, 주입받은 runService는 프록시이기에 프록시를 통해 AOP를 적용할 수 있습니다.

 

그러나 만약 생성자 주입 시 아래와 같은 오류가 발생합니다. 본인을 생성하면서 주입해야 하기 때문에 순환 사이클이 만들어지기 때문인데요.

 

반면에 수정자 주입은 스프링이 생성된 이후에 주입할 수 있기 때문에 오류가 발생하지 않습니다.

(스프링 2.6 이전 버전은 수정자 주입 시 문제없이 동작하지만, 2.6 릴리즈 이후 순환 참조를 기본적으로 금지하도록 변경되었습니다. 참고: https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.6-Release-Notes)

 

스프링 부트 2.6 버전 이상이라면, application.yml에서 관련 옵션을 추가하여 순환 참조를 허용할 수 있습니다.

 

이후 문제 없이 동작하는 테스트 실행 결과

 

출처: 스프링 핵심 원리 - 고급편 (김영한 님)

이제는 internal()을 호출할 때 자기 자신의 인스턴스를 호출하는 것이 아닌, 프록시 인스턴스를 통해서 호출하는 것을 확인할 수 있습니다. 당연히 AOP도 정상적으로 적용됩니다.

 

 

대안 2) 지연 조회

스프링 빈을 지연해서 조회하면 되는데, ObjectProvider(Provider), ApplicationContext를 사용하면 됩니다.

 

RunService

ApplicationContext는 많은 기능을 제공합니다. ObjectProvider는 객체를 스프링 컨테이너에서 조회한 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있습니다.

runServiceProvider.getObject()를 호출하는 시점에 스프링 컨테이너에서 빈을 조회합니다. 여기서는 자기 자신을 주입받는 것이 아니기 때문에 순환 사이클이 발생하지 않습니다.

 

대안 3) 구조 변경 💫  (권장)

앞선 방식들은 자기 자신을 주입하거나 또는 Provider를 사용해야 하는 것처럼 조금 어색합니다. 가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것으로 가장 권장하는 방식입니다.

 

RunServiceV3

 

InternalService

내부 호출을 InternalService라는 별도의 클래스로 분리합니다.

 

출처: 스프링 핵심 원리 - 고급편 (김영한 님)

내부 호출 자체가 사라지고, runService3 internalService를 호출하는 구조로 변경되었습니다. 덕분에 자연스럽게 AOP가 적용될 수 있습니다.

 

구조를 변경한다는 것은 이처럼 단순하게 분리하는 것뿐만 아니라 다양한 방법이 있을 수 있습니다.

 

예) 클라이언트에서 둘 다 호출한다.
1. 클라이언트 -> external()
2. 클라이언트 -> interanl()

 

이 경우에는 external()에서 internal()을 호출하지 않도록 코드를 변경해야 합니다. 그리고 클라이언트에서 모두 호출하도록 구조를 변경하면 됩니다. (가능한 경우에 한해서)

 


결론

AOP는 주로 @Transactional 적용이나 주요 컴포넌트의 로그 출력 기능에서 사용됩니다. 쉽게 이야기해서 인터페이스에 메서드가 나올 정도의 규모에 AOP를 적용하는 것을, 더 풀어서 이야기하면 AOP는 public 메서드에만 적용합니다. private 메서드처럼 작은 단위에는 AOP를 적용하지 않습니다.

 

AOP를 적용하기 위해 private 메서드를 외부 클래스로 변경하고 public으로 변경하는 일은 거의 없습니다. 그러나 위와 같이 public 메서드에서 public 메서드를 내부 호출하는 경우에는 self-invocation 문제를 마주하게 됩니다.

 

따라서, AOP가 잘 적용되지 않는다면 내부 호출을 의심해 보는 것이 좋습니다.

 

관련된 코드

https://github.com/nahyeon99/Spring-Boot-Lab/tree/master/aop

 

참고 레퍼런스 🙇‍♀️

https://velog.io/@ch4570/AOP-Self-Invocation-문제-AOP의-한계일까

https://tecoble.techcourse.co.kr/post/2022-11-07-transaction-aop-fact-and-misconception/

스프링 핵심 원리 - 고급편 (인프런 김영한 님)