4. Aspect Instantiation Models, An AOP Example, Proxying Mechanisms

2023. 5. 24. 14:12Spring Framework/Spring AOP

Aspect Instantiation Models

이것은 고급 주제입니다. AOP를 처음 시작하는 경우, 나중에 이 내용을 안전하게 건너뛰어도 됩니다.

기본적으로 각 aspect는 애플리케이션 컨텍스트 내에서 단일 인스턴스를 가집니다. AspectJ에서는 이를 싱글톤 인스턴스화 모델이라고 부릅니다. 하지만 다른 생명 주기를 가진 aspect를 정의하는 것도 가능합니다. Spring은 AspectJ의 perthis, pertarget, 및 pertypewithin 인스턴스화 모델을 지원하며, percflowpercflowbelow는 현재 지원하지 않습니다.

@Aspect 애노테이션에서 perthis 절을 지정하여 perthis aspect를 선언할 수 있습니다. 다음은 그 예입니다:

@Aspect("perthis(execution(* com.xyz..service.*.*(..)))")
public class MyAspect {

    private int someState;

    @Before("execution(* com.xyz..service.*.*(..))")
    public void recordServiceUsage() {
        // ...
    }
}

위 예제에서 perthis 절의 효과는 비즈니스 서비스를 수행하는 각 고유한 서비스 객체(포인트컷 표현식에 의해 매칭되는 조인 포인트에서 this에 바인딩된 고유 객체)마다 하나의 aspect 인스턴스가 생성된다는 것입니다. 서비스 객체의 메서드가 처음 호출될 때 aspect 인스턴스가 생성됩니다. 서비스 객체가 스코프에서 벗어나면 aspect도 스코프에서 벗어납니다. aspect 인스턴스가 생성되기 전에는 그 안에 선언된 advice는 실행되지 않습니다. aspect 인스턴스가 생성되면, 해당 인스턴스와 연결된 서비스 객체가 참여하는 매칭된 조인 포인트에서 선언된 advice가 실행됩니다. per 절에 대한 더 자세한 내용은 AspectJ 프로그래밍 가이드를 참조하세요.

pertarget 인스턴스화 모델은 perthis와 동일한 방식으로 작동하지만, 매칭된 조인 포인트에서 각 고유한 타겟 객체마다 하나의 aspect 인스턴스를 생성합니다.

An AOP Example

지금까지 구성 요소들이 어떻게 작동하는지 보았으므로, 이제 이들을 조합하여 유용한 작업을 수행하는 방법을 알아보겠습니다.

비즈니스 서비스의 실행은 때때로 동시성 문제(예: 교착 상태에서 패배)가 원인이 되어 실패할 수 있습니다. 만약 작업을 다시 시도하면, 다음 시도에서 성공할 가능성이 높습니다. 이러한 상황에서 다시 시도하는 것이 적절한 비즈니스 서비스(사용자에게 충돌 해결을 위해 다시 돌아갈 필요가 없는 멱등 연산)에서는 클라이언트가 PessimisticLockingFailureException을 보지 않도록 작업을 투명하게 재시도하고 싶습니다. 이러한 요구사항은 서비스 계층의 여러 서비스에 걸쳐 발생하므로, aspect로 구현하기에 이상적입니다.

작업을 다시 시도하려면, 여러 번 proceed를 호출할 수 있도록 around advice를 사용해야 합니다. 다음은 기본적인 aspect 구현을 보여줍니다:

@Aspect
public class ConcurrentOperationExecutor implements Ordered {

    private static final int DEFAULT_MAX_RETRIES = 2;

    private int maxRetries = DEFAULT_MAX_RETRIES;
    private int order = 1;

    public void setMaxRetries(int maxRetries) {
        this.maxRetries = maxRetries;
    }

    public int getOrder() {
        return this.order;
    }

    public void setOrder(int order) {
        this.order = order;
    }

    @Around("com.xyz.CommonPointcuts.businessService()") 
    public Object doConcurrentOperation(ProceedingJoinPoint pjp) throws Throwable {
        int numAttempts = 0;
        PessimisticLockingFailureException lockFailureException;
        do {
            numAttempts++;
            try {
                return pjp.proceed();
            }
            catch(PessimisticLockingFailureException ex) {
                lockFailureException = ex;
            }
        } while(numAttempts <= this.maxRetries);
        throw lockFailureException;
    }
}

여기서 businessService라는 이름의 포인트컷을 참조하는 부분은 Named Pointcut 정의에서 가져온 것입니다.

이 aspect는 Ordered 인터페이스를 구현하여, 트랜잭션 어드바이스보다 높은 우선순위를 가지도록 설정할 수 있습니다(재시도할 때마다 새로운 트랜잭션을 원하기 때문입니다). maxRetriesorder 속성은 Spring에 의해 구성됩니다. 주요 동작은 doConcurrentOperation around advice에서 발생합니다. 현재는 모든 businessService에 재시도 로직을 적용합니다. 먼저 proceed를 시도하고, PessimisticLockingFailureException이 발생하면 재시도합니다. 단, 모든 재시도 기회를 다 소진했을 경우에는 예외를 다시 던집니다.

다음은 해당하는 Spring 설정입니다:

<aop:aspectj-autoproxy/>

<bean id="concurrentOperationExecutor"
        class="com.xyz.service.impl.ConcurrentOperationExecutor">
    <property name="maxRetries" value="3"/>
    <property name="order" value="100"/>
</bean>

이 aspect를 멱등 연산에만 재시도하도록 개선하기 위해, 다음과 같은 Idempotent 애노테이션을 정의할 수 있습니다:

@Retention(RetentionPolicy.RUNTIME)
// 마커 애노테이션
public @interface Idempotent {
}

그런 다음 이 애노테이션을 서비스 연산의 구현에 적용할 수 있습니다. 멱등 연산에만 재시도하도록 aspect를 변경하려면 포인트컷 표현식을 수정하여 @Idempotent 애노테이션이 적용된 연산만 매칭되도록 해야 합니다. 다음은 그 예입니다:

@Around("execution(* com.xyz..service.*.*(..)) && " +
        "@annotation(com.xyz.service.Idempotent)")
public Object doConcurrentOperation(ProceedingJoinPoint pjp) throws Throwable {
    // ...
}

Proxying Mechanisms

Spring AOP는 지정된 타겟 객체에 대해 JDK 동적 프록시 또는 CGLIB을 사용하여 프록시를 생성합니다. JDK 동적 프록시는 JDK에 내장되어 있는 반면, CGLIB은 일반적인 오픈 소스 클래스 정의 라이브러리로, Spring Core에 재패키징되어 있습니다.

프록시 대상 객체가 하나 이상의 인터페이스를 구현하는 경우 JDK 동적 프록시가 사용됩니다. 이때 타겟 타입이 구현하는 모든 인터페이스가 프록시됩니다. 반면, 타겟 객체가 인터페이스를 구현하지 않는 경우 CGLIB 프록시가 생성됩니다.

모든 메서드(인터페이스에서 구현한 메서드뿐만 아니라 타겟 객체에 정의된 모든 메서드)를 프록시하려면 CGLIB 프록시를 강제로 사용하도록 설정할 수 있습니다. 그러나 다음과 같은 문제를 고려해야 합니다:

  • CGLIB을 사용하는 경우, final 메서드는 런타임 생성된 서브클래스에서 오버라이드할 수 없으므로 조언을 적용할 수 없습니다.
  • Spring 4.0부터는 더 이상 프록시된 객체의 생성자가 두 번 호출되지 않습니다. 이는 CGLIB 프록시 인스턴스가 Objenesis를 통해 생성되기 때문입니다. 단, JVM이 생성자 우회를 허용하지 않는 경우, 생성자가 두 번 호출되는 것과 관련된 디버그 로그 항목을 볼 수 있습니다.
  • CGLIB 프록시 사용은 JDK 9+ 플랫폼 모듈 시스템에서 제한될 수 있습니다. 일반적인 경우로, 모듈 경로에서 배포할 때 java.lang 패키지의 클래스를 프록시로 생성할 수 없습니다. 이러한 경우 JVM 부트스트랩 플래그 --add-opens=java.base/java.lang=ALL-UNNAMED가 필요하지만, 이는 모듈에서는 사용할 수 없습니다.

CGLIB 프록시 사용을 강제하려면 <aop:config> 요소의 proxy-target-class 속성 값을 true로 설정하세요:

<aop:config proxy-target-class="true">
    <!-- 다른 빈 정의는 여기... -->
</aop:config>

@AspectJ 자동 프록시 지원을 사용할 때 CGLIB 프록시를 강제하려면 <aop:aspectj-autoproxy> 요소의 proxy-target-class 속성 값을 true로 설정하세요:

<aop:aspectj-autoproxy proxy-target-class="true"/>

여러 <aop:config/> 섹션이 런타임 시 단일 통합 자동 프록시 생성기로 병합되며, 이는 각 <aop:config/> 섹션(일반적으로 다른 XML 빈 정의 파일에서 지정됨)이 지정한 가장 강력한 프록시 설정을 적용합니다. 이 규칙은 <tx:annotation-driven/><aop:aspectj-autoproxy/> 요소에도 적용됩니다.

따라서 <tx:annotation-driven/>, <aop:aspectj-autoproxy/>, 또는 <aop:config/> 요소에 proxy-target-class="true"를 사용하면 세 요소 모두에 대해 CGLIB 프록시 사용이 강제됩니다.

Understanding AOP Proxies

Spring AOP는 프록시 기반입니다. 이 문장이 실제로 의미하는 바를 충분히 이해하는 것이 매우 중요합니다. 그래야 자신만의 aspect를 작성하거나 Spring 프레임워크에서 제공하는 AOP 기반 aspect를 사용할 때 제대로 활용할 수 있습니다.

먼저 프록시가 적용되지 않은 일반 객체 참조가 있는 시나리오를 생각해 보겠습니다. 다음 코드 조각을 참고하세요:

public class SimplePojo implements Pojo {

    public void foo() {
        // 다음 메서드 호출은 'this' 참조에 대한 직접 호출입니다.
        this.bar();
    }

    public void bar() {
        // 일부 로직...
    }
}

객체 참조에서 메서드를 호출하면 해당 객체 참조에서 메서드가 직접 호출됩니다. 다음 그림과 코드는 이를 보여줍니다:

public class Main {

    public static void main(String[] args) {
        Pojo pojo = new SimplePojo();
        // 이것은 'pojo' 참조에서의 직접 메서드 호출입니다.
        pojo.foo();
    }
}

클라이언트 코드가 가진 참조가 프록시인 경우 상황이 약간 달라집니다. 다음 다이어그램과 코드 조각을 참조하세요:

public class Main {

    public static void main(String[] args) {
        ProxyFactory factory = new ProxyFactory(new SimplePojo());
        factory.addInterface(Pojo.class);
        factory.addAdvice(new RetryAdvice());

        Pojo pojo = (Pojo) factory.getProxy();
        // 이것은 프록시에 대한 메서드 호출입니다!
        pojo.foo();
    }
}

여기서 이해해야 할 핵심은 Main 클래스의 main(..) 메서드 내 클라이언트 코드가 프록시에 대한 참조를 가진다는 점입니다. 이는 객체 참조에서의 메서드 호출이 프록시에 대한 호출임을 의미합니다. 그 결과, 프록시는 해당 메서드 호출과 관련된 모든 인터셉터(즉, 조언)에 위임할 수 있습니다. 그러나 호출이 결국 타겟 객체에 도달하면(이 경우 SimplePojo 참조), 그 객체가 자신에게 호출하는 모든 메서드(this.bar() 또는 this.foo() 등)는 프록시가 아닌 this 참조에 대해 호출됩니다. 이는 중요한 함의를 가지고 있습니다. 즉, 자기 호출(self-invocation)에서는 메서드 호출과 관련된 조언이 실행될 기회를 얻지 못하게 됩니다.

그렇다면 이 문제에 대해 무엇을 해야 할까요? 가장 좋은 접근 방식(여기서 '좋은'이라는 용어는 다소 유연하게 사용됨)은 자기 호출이 발생하지 않도록 코드를 리팩터링하는 것입니다. 이는 일부 작업을 필요로 하지만, 가장 적게 침입하는 최선의 접근 방식입니다. 다음 접근 방식은 매우 끔찍한데, 우리가 이 접근 방식을 언급하기를 주저하는 이유는 바로 그만큼 끔찍하기 때문입니다. 다음 예에서 볼 수 있듯이, 클래스 내 로직을 완전히 Spring AOP에 결합할 수 있습니다:

public class SimplePojo implements Pojo {

    public void foo() {
        // 이렇게 하면 되지만... 정말 별로입니다!
        ((Pojo) AopContext.currentProxy()).bar();
    }

    public void bar() {
        // 일부 로직...
    }
}

이 접근 방식은 코드와 Spring AOP를 완전히 결합시키며, 클래스 자체가 AOP 컨텍스트에서 사용된다는 사실을 인지하게 만듭니다. 이는 AOP의 취지에 어긋납니다. 또한 프록시를 생성할 때 추가 구성을 필요로 하며, 다음 예에서 볼 수 있듯이 다음과 같은 구성을 요구합니다:

public class Main {

    public static void main(String[] args) {
        ProxyFactory factory = new ProxyFactory(new SimplePojo());
        factory.addInterface(Pojo.class);
        factory.addAdvice(new RetryAdvice());
        factory.setExposeProxy(true);

        Pojo pojo = (Pojo) factory.getProxy();
        // 이것은 프록시에 대한 메서드 호출입니다!
        pojo.foo();
    }
}

마지막으로, AspectJ는 프록시 기반의 AOP 프레임워크가 아니기 때문에 자기 호출 문제를 가지고 있지 않다는 점에 유의해야 합니다.