개발자 20%만 아는 Spring AOP의 비밀


들어가기전

Spring AOP의 JDK 다이내믹 프록시와 CGLIB는 프록시를 기반으로 AOP를 적용한다. 이외에도 Spring에선 다양한 방면에서 프록시의 메커니즘을 기반으로 기술을 제공하고 있는데, 대표적으로 Cache, Transaction이 이에 해당한다. 무엇보다 이 프록시 메커니즘의 가장 큰 이슈는 Self-Invocation이다.

마찬가지로 Spring AOP도 같은 이슈가 발생하는데 본 포스팅에선 Self-Invocation 발생하는 근본적인 원인과 이를 해결하고자 한다.

학습 환경으론 Spring Boot 2.1.4에서 진행하였고 학습 과정에서 사용했던 코드는 GitHub를 참고하기 바란다.

학습목표

  1. 프록시의 메커니즘에 대한 이해
  2. Self-Invocation에 대한 해결책

1. Spring AOP의 동작 과정

Spring AOP는 프록시 기반으로 IoC 컨테이너에서 빈을 생성하는 시점에 AOP를 적용할지 여부를 판단하여 프록시 빈을 생성해준다.

이 과정에서 JDK Proxy와 CGLIB 둘 중에 선택 해서 프록시 빈을 생성하는 데, 일반적으로 타깃이 인터페이스를 구현하고 있는 경우 JDK Proxy의 방식으로 프록시를 생성해주고 타깃의 객체가 인터페이스를 구현하지 않으면 CGLIB 방식으로 프록시가 생성된다.

이 프록시의 핵심적인 기능은 지정된 메소드가 호출(Invocation)될 때 이 메소드를 가로채어 부가기능들을 추가할 수 있도록 지원하는 것이다.

2. Self-Invocation이 뭔데

CGLIB 방식이든 JDK Proxy 방식이든 Spring AOP는 프록시를 기반으로 AOP를 적용한다는 점을 생각해봐야 한다. 그렇다면 Self-Invocation가 뭘까? 다음 코드를 살펴보자.

@Component
public class BusinessI implements Business{
    @Override
    public void ready() {
        System.out.println("ready...");
        this.go();  go() 메소드 호출
    }

    @Override
    public void go() {
        System.out.println("go...");
    }
}

다음 코드에서 BusinessI.ready()에 보면 this.go() 메소드를 호출하는걸 알 수 있다. 즉 같은 객체의 자신의 메소드 외의 다른 메소드를 호출을 의미한다. 이러한 호출을 Self-Invocation이라 하고 프록시에서 이 Self-Invocation 문제가 발생한다.

3. Self-Invocation를 강제로 발생시키자

예를들어 Self-Invocation를 발생하기 위해 go() 메소드에 Advice를 제공한다고 가정해보자.

@Aspect
@Component
public class MyAspect {

    @Pointcut("execution(void com.moong.aopproxies.business.Business*.go(..))")
    public void myPointcut() { }

    @Before("myPointcut()")
    public void before() {
        System.err.println("Advice logic...");
    }
}
  • BusinessI.go() 메소드에 Beafor Advice 설정

간단하게 @AspectJ를 사용하여 Spring AOP를 구성해보았다. 이제 테스트 코드를 작성해보자.

@RunWith(SpringRunner.class)
@SpringBootTest
@EnableAspectJAutoProxy
public class SelfInvocationTest {
    @Autowired private Business businessI;

    @Test
    public void isSelefInvocation() {
        assertTrue(Proxy.isProxyClass(businessI.getClass()));   프록시 객체 생성여부
        System.out.println(businessI.getClass());
        businessI.ready();
    }
}

테스트 코드를 실행하기에 앞서, 흔히 Self-Invocation의 오류를 범하는 사람들의 예상 시나리오 다음과 같다.

class com.sun.proxy.$Proxy48
ready...        ← [1] businessI.ready() 메소드 호출
Advice logic... ← [2] AOP에 의해 @Before 어드바이스 호출
go...           ← [3] go() 메소드 호출

하지만 테스트 코드를 실행해보면 결과는 @Before 어드바이스를 호출하지 않는다.

class com.sun.proxy.$Proxy48
ready...
go...

이게 바로 프록시에서 흔히 발생하는 Self-Invocation 이슈이다. “왜 Self-Invocation의 문제가 발생할까”에 대한 의문점을 해결하기 위해선 프록시의 메커니즘을 이해하면 도움이 된다.

4. 프록시의 매커니즘

@Test
public void isSelefInvocation() {
  assertTrue(Proxy.isProxyClass(businessI.getClass()));  프록시 객체 생성여부
  System.out.println(businessI.getClass());
  businessI.ready();  프록시 객체의 ready() 호출
}
class com.sun.proxy.$Proxy48 ← 프록시 객체의 참조
ready...  ← [1][2] 프록시 객체의 ready() 메소드 호출
go...     ← [3] 타깃의 go() 메소드 호출

다음 그림에서 중요한 점은 businessI의 메소드 호출이 Spring AOP에 의해 생성된 프록시를 참조한다는 점이다. 따라서 클라이언트는 $Proxy48.ready()를 호출하게 되고, 프록시에 의해 호출된 메소드와 Pointcut을 비교하여 Advice를 수행하게 된다.

하지만 ready() 메소드 호출이 끝이나고 go()가 호출되는 시점이 되면, 더이상 프록시를 참조하지 않고 타깃을 참조하게 된다. 즉 $Proxy48.go 가 아닌 BusinessI를 참조하는 this.go() 가 호출 되어지기 때문에, 결과적으론 Self-Invocation으로 호출된 메소드는 AOP가 적용할 수 없다.

5. Self-Invocation 해결 방안들

그렇다면 Self-Invocation의 해결 방법은 없을까?

  • AopContext
  • IoC 컨테이너 Bean 활용
  • AspectJ Weaving

5.1. AopContext

첫 번째 방법으론 AopContext 추상화 클래스를 사용하는 것이다.

AopContext는 현재 AOP 호출에 대한 정보를 얻기 위해 사용되는 추상 클래스이다. 이 클래스의 currentProxy() 메소드는 현재 AOP 프록시 반환해주는데 이를 이용하면 Self-Invocation 문제를 해결할 수 있다.

@Component
public class BusinessI implements Business{

    @Override
    public void ready() {
        System.out.println("ready...");
        //go();
        ((Business) AopContext.currentProxy()).go();  호출된 프록시 객체를 활용
    }

    @Override
    public void go() {
        System.out.println("go...");
    }
}

$Proxy.ready() → $Proxy.go()

다음 코드처럼 호출된 AOP 프록시를 활용하여 go() 메소드를 호출해주면 된다. 즉 기존의 타깃을 참조되던 this.go() 메소드를 프록시로 참조할 수 있도록 AopContext.currentProxy()를 활용한 것이다. 이 AopContext.currentProxy() 메소드를 사용하기 위해선 expose-proxy 옵션을 활성화 해줘야 한다.

  • <aop:aspectj-autoproxy expose-proxy="true"/>
  • ProxyFactory.setExposeProxy(true)

일반적으로 자동 프록싱 XML 태그인 <aop:aspectj-autoproxy />의 expose-proxy 옵션을 통해 활성화해주는 방법과 ProxyFactory의 setExposeProxy() 메소드를 활용하는 방법이 있다. 하지만 이 방법들론 expose-proxy을 활성화할 수 없다. 첫 번째 이윤 본 포스팅의 학습 환경이 Spring Boot이기 때문에 XML 설정을 할 수 없다. 마지막으로 @AspectJ 어노테이션으로 Spring AOP를 구축했기 때문에 ProxyFactory 방식을 사용할 수 없다.

Spring Boot에선 @EnableAspectJAutoProxy 어노테이션을 활용하여 자동 프록싱을 지원하는데, 이를 활용해보자.

@RunWith(SpringRunner.class)
@SpringBootTest
@EnableAspectJAutoProxy(exposeProxy=true)  expose-proxy 옵션 활성화
public class SelfInvocationTest {

    @Autowired private Business businessI;

    @Test
    public void isSelefInvocation() {
        assertTrue(Proxy.isProxyClass(businessI.getClass()));
        System.out.println(businessI.getClass());
        businessI.ready();
    }
}
class com.sun.proxy.$Proxy48
ready...
Advice logic...
go...
  • AopContext.currentProxy()
  • expose-proxy 활성화

5.2. IoC 컨테이너 Bean 활용

두 번째 방법으론 IoC 컨테이너에 등록된 자기 자신의 빈을 활용하는 방법이다.

  • @PostConstruct 와 @Autowired ApplicationContext context
  • @Autowired
  • @Resource
  • @Inject

이 접근할 수 있는 방식은 다양하지만 @Resource를 사용하여 Self-Invocation을 해결해보자.

@Component
public class BusinessWithResource implements Business{
    @Resource(name="businessWithResource")  자기 자신의 빈을 주입 받는다.
    Business self;

    @Override
    public void ready() {
        System.out.println("ready...");
        self.go();  DI된 자기 자신의 Bean을 이용해 다시 호출
    }

    @Override
    public void go() {
        System.out.println("go...");
    }
}

다음 코드를 보면 @Resource 어노테이션을 사용하여 빈을 주입 받는다. 이는 자기 자신의 빈 객체를 복사본을 활용한다고 생각하면 된다.

메소드 호출은 this.go() 방식이 아닌 self.go() 방식으로 호출하기 되기 때문에 Proxy를 통해 호출하는 효과를 만드는 방법이다. 테스트를 통해 검증해보자.

@RunWith(SpringRunner.class)
@SpringBootTest
@EnableAspectJAutoProxy
public class ResourceTest {

    @Autowired private Business businessWithResource;

    @Test
    public void isSelefInvocation() {
        System.out.println(businessWithResource.getClass());
        businessWithResource.ready();
    }
}
class com.sun.proxy.$Proxy48
ready...
Advice logic...
go...

5.3. AspectJ Weaving

마지막으론 Spring AOP의 Weaving 방식을 AspectJ Weaving 방식으로 바꾸는 것이다.

AspectJ Weaving은 Spring AOP와 달리 바이트 코드를 조작하는 방식이기 때문에 Proxy의 Self-Invocation 이슈가 발생하지 않는다. 물론 Self-Invocation를 해결하기 위해 기존 코드를 수정하는 작업이 필요하지 않다.

무엇보다 본 포스팅에서 @AspectJ 어노테이션으로 Spring AOP를 구현한 이유이기도 하다. AspectJ의 Weaving 방식에는 CTW, PTW, LTW 세 가지 방식이 있는데 그중에 CTW 방식으로 변환해보겠다.

@AspectJ 어노테이션은 AspectJ 5 라이브러리에 포함된 어노테이션으로 aspectjweaver.jar 라이브러리를 추가하면 된다.

5.3.1. Maven 추가

먼저 AspectJ의 Weaving 방식으로 변환하기 위해선 aspectjweaver.jar와 더불어 AspectJ 런타임 라이브러리인 aspectjrt.jar가 필요하다.

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjrt</artifactId>
    <version>${org.aspectj.version}</version>
</dependency>

5.3.2. Plugin 설정

AspectJ는 기본적으로 AJW(AspectJ Compiler)에 의해 Weaving을 처리하기 때문에, AJW가 내장되어 있는 AspectJ Development Tool을 사용하여 개발을 해야한다. 하지만 본 포스팅처럼 미리 개발이 되어있는 상태된 상태라면 Mojo의 AspectJ Maven Plugin 플러그인을 사용하면 된다.

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>aspectj-maven-plugin</artifactId>
    <version>1.10</version>
    <configuration>
        <encoding>UTF-8 </encoding>	<!-- 인코딩 -->
        <source>${java.version}</source> <!-- source level : 1.3 to 1.8 -->
        <target>${java.version}</target> <!-- classfile : 1.1 to 1.8 -->
        <complianceLevel>${java.version}</complianceLevel>
        <showWeaveInfo>true</showWeaveInfo> <!-- 위빙에 대한 정보를 알기 위함 -->
        <verbose>true</verbose>
        <Xlint>ignore</Xlint>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>compile</goal>
                <goal>test-compile</goal>
            </goals>
        </execution>
    </executions>
</plugin>

5.3.3. CTW 검증

@RunWith(SpringRunner.class)
@SpringBootTest
@EnableAspectJAutoProxy
public class CTWConfigTest {

    @Autowired
    private Business businessI;

    @Test
    public void selfInvocation() {
        System.out.println(businessI.getClass());
        businessI.ready();
    }
}
class com.moong.selfinvocationctw.business.BusinessI ← 프록시가 아님
ready...
Adivce logic...
go...

결과적으로 별도의 수정없이 Weaving 방식만 변경하여 Self-Invocation 문제를 해결하였다. 마지막으로 class 파일을 확인해보자.

@Component
public class BusinessI
  implements Business
{
    public void ready()
    {
        System.out.println("ready...");
        go();
    }

    public void go()
    {
        MyAspect.aspectOf().myBefore();System.out.println("go...");  AspectJ에 의해 class 코드가 변경
    }
}

마무리

프록시 기반의 기술에서 흔히 발생하는 Self-Invocation에 대한 원인과 해결 방안을 살펴보았다.

AspectJ의 Weaving을 활용하여 코드의 변경 없이 AOP를 적용할 수 있었다. 하지만 “특정 @AspectJ만 설정 파일만 AspectJ Weaving을 적용할 순 없을까?”라는 의문이 생겼다. 이에 대해 Mojo - IncludeExclude을 참고하여 학습하여 추후 AspectJ Weaving에 대해 자세히 포스팅을 진행하겠다.


참고