본문 바로가기

Spring 프레임워크/이론

Spring의 AOP(Aspect Oriented Programming)

스프링에서는 AOP(Aspect Oriented Programming)를 지원하고 있다. AOP는 관점 지향 프로그래밍 등으로 많이 번역되는데 객체 지향 프로그래밍, 서비스 지향 프로그래밍 등 프로그래밍 개발론의 일종이다. AOP에서는 프로그램을 핵심 기능과 공통 기능으로 분리하여 모듈화, 재사용성을 높이고 핵심 기능 개발에만 집중할 수 있다는 장점이 있다.

AOP

프로그램을 핵심 기능과 공통 기능으로 분리한다는 것은 무엇일까? 이는 애플리케이션을 개발할 때 어느 부분에 더 집중해야 하는지, 즉 어디에 좀 더 관심사(concern)를 두어야 하는지 분리하는 것이다. 구글에서 스프링 AOP를 검색하면 지겹도록 나오는 예제지만 들어보자면 어떤 메서드의 실행 시간을 측정하는 로직을 구현해야 한다고 하자. 가장 단순하게 생각할 수 있는 방법은 모든 메서드 내부에 시간 측정 로직을 구현하는 것이다.

하지만 이런 식으로 모든 메서드에 일일이 로직을 구현하는 방법은 비효율적일뿐더러 유지보수도 어렵다. 게다가 엄청난 코드 중복이 발생하기 때문에 이를 공통 기능과 핵심 기능 관점으로 분리하여 필요한 핵심 기능에 공통 기능을 적용할 수 있도록 구현하는 것이다.

여기서 핵심 기능이란 실행 시간을 측정할 메서드의 로직이며 공통 기능이란 실행 시간을 측정하는 로직일 것이다. 그럼 어떻게 이 공통 기능을 핵심 기능에 삽입할 수 있을까? 그 방법은 크게 3가지가 있다.

  • 소스 코드를 컴파일할 때 핵심 기능 코드를 수정해서 공통 기능을 삽입한다.

  • 컴파일된 핵심 기능, 즉 클래스 파일의 바이트 코드를 수정해서 공통 기능을 삽입한다.

  • 실행 시점에 핵심 기능을 다루는 프록시 객체를 생성해서 공통 기능을 삽입한다.

이 중 스프링에서 지원하는 방법은 마지막 방법이다. 프록시 객체는 디자인 패턴(프록시 패턴)의 일종으로 간단하게 말하면 실제 객체에 대한 접근을 제공하지 않고 대신 간접적으로 참조할 수 있는 객체를 제공하는 것이다. 이 객체는 실제 객체를 수정하지 않고도 추가적인 기능을 제공하거나 해당 객체에 대한 접근을 차단할 수 있다. 프록시 패턴에 대한 설명 자체는 이 링크를 참조하면 좋다.

 

Proxy

There are dozens of ways to utilize the Proxy pattern. Let’s go over the most popular uses. Access control (protection proxy). This is when you want only specific clients to be able to use the service object; for instance, when your objects are crucial p

refactoring.guru

즉 스프링 AOP에서 이 프록시 객체라는 것은 아까 분리했던 공통 기능이 핵심 기능을 대신하고 있다는 것을 의미한다. 물론 실제로 핵심 기능을 실행시키는 코드가 공통 기능 내부에 포함되어 있어야 정상적으로 실행될 것이다.

이 핵심 기능을 호출하려고 할 때 우리는 사실 프록시 객체, 즉 공통 기능을 호출하게 되며 공통 기능에서는 핵심 기능을 실행시킨 후 그 결과를 반환한다. 우리는 핵심 기능을 호출 후 핵심 기능의 실행 결과를 반환받았다고 생각하지만 실제로는 공통 기능을 호출 후 공통 기능에서 대신 실행하고 반환해준 핵심 기능의 실행 결과를 받는 형태인 것이다.

 

스프링에서는 AOP 프레임워크가 이 프록시 객체를 자동으로 만들어주기 때문에 우리는 핵심 기능과 핵심 기능을 실행하는 공통 기능만 구현하면 된다.

Spring AOP, AspectJ

스프링 AOP는 범용적인 AOP 구현을 제공하지만 특이하게 완전한 AOP 구현을 목표로 하지 않는다. 대신 실제 AOP 구현체와 스프링 컨테이너 간 통합을 지원하여 애플리케이션의 요구사항을 해결하는 것을 목표로 한다. 그래서 AspectJ 같은 AOP 프레임워크와 스프링 컨테이너, 스프링 AOP를 자연스럽게(seamlessly) 통합하여 제공할 수 있다.

 

먼저 AOP에서 사용하는 개념을 알아볼 필요가 있다. 언급했듯이 AOP는 핵심 기능과 공통 기능을 분리하여 필요시 적용하는데 이 공통 기능을 핵심 기능에서 언제 적용할지 지정해야 한다. 이를 Advice라 하며 핵심 기능을 실행하기 전이나 실행한 후 등 언제 공통 기능을 적용할지 지정하는 것이다. 스프링에서는 프록시 객체를 이용하여 핵심 기능 메서드를 호출하는 방식이기 때문에 총 5가지 타이밍에 공통 기능을 적용할 수 있다.

  • Before Advice: 핵심 기능 메서드 호출 전에 공통 기능을 실행한다.

  • After Returning Advice: 핵심 기능 메서드가 예외 발생 없이 잘 실행된 경우 공통 기능을 실행한다.

  • After Throwing Advice: 핵심 기능 메서드 실행 중 예외가 발생한 경우 공통 기능을 실행한다.

  • After Advice: 핵심 기능 메서드 호출 후에 공통 기능을 실행한다.

  • Around Advice: 핵심 기능 메서드 호출 전, 후에 공통 기능을 실행한다.

이 중 Around Advice가 메서드 호출 전, 후, 예외 발생 후 등 다양한 시점에 공통 기능을 삽입할 수 있기 때문에 언급한 성능 모니터링(실행시간 측정), 캐싱 등을 구현하는 데 사용할 수 있다.

 

이런 Advice를 적용 가능한 지점을 Joinpoint라고 하는데 메서드 호출, 필드 값 변경 등 여러 부분에 적용할 수 있다. 그러나 스프링 AOP는 프록시 객체에서 메서드를 호출하는 방식이기 때문에 메서드 호출 부분만 Joinpoint로 적용 가능하다. 이런 Joinpoint 중에서 실제로 Advice가 적용(Weaving)된 Joinpoint를 Pointcut이라 한다. 그리고 그 클래스를 Target Object라고 한다.

 

마지막으로 Pointcut에 적용되는 공통 기능들을 Aspect라고 한다. 이 Aspect를 정의하려면 공통 기능을 구현한 클래스에 @Aspect 어노테이션을 붙인다. 그리고 AspectJ를 사용하기 위해 설정 클래스에 @EnableAspectJAutoProxy 어노테이션을 붙여서 AOP를 적용할 수 있다.

implementation 'org.aspectj:aspectjweaver:1.8.13'

AspectJ는 기본적으로는 포함되어 있지 않기 때문에 위처럼 의존성을 추가해줘야 한다.

@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppContext {
    @Bean
    public String pattern(){
        return "Hello, %s";
    }
}

 

import org.aspectj.lang.annotation.Aspect;

@Aspect
@Component
public class RuntimeMeasure {
    ...

이제 이 RuntimeMeasure 클래스는 공통 기능을 제공하는 Aspect 클래스다. @Component 어노테이션을 사용한 이유는 설정 클래스에서 Bean 객체로 등록하지 않고 컴포넌트 스캔 방식을 사용한 것이기 때문에 직접 객체로 등록하는 경우 생략할 수 있다. 중요한 것은 스프링 컨테이너가 프록시 객체를 만들려면 이 공통 기능을 Bean 객체로 포함하고 있어야 하기 때문에 자동으로든 수동으로든 컨테이너에 등록하는 것이다.

public class RuntimeMeasure {
    @Around("execution(public * springdemo.basic..*(..))")
    private Object measure(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
        long start = System.nanoTime();
        ...

그럼 이제 공통 기능을 구현하고 이를 핵심 기능의 어디에 적용할지 Advice로 지정해줘야 한다. 위에서 언급한 Around Advice를 사용하기 위해서 @Around 어노테이션을 사용할 수 있다. 문서를 참고하면 위에서 언급한 다른 Advice도 어노테이션으로 등록되어 있다.

 

org.aspectj.lang.annotation package summary - aspectjrt 1.9.6 javadoc

Latest version of org.aspectj:aspectjrt https://javadoc.io/doc/org.aspectj/aspectjrt Current version 1.9.6 https://javadoc.io/doc/org.aspectj/aspectjrt/1.9.6 package-list path (used for javadoc generation -link option) https://javadoc.io/doc/org.aspectj/as

javadoc.io

그런데 어노테이션의 속성으로 전달된 문자열은 무슨 의미일까? 바로 앞서 언급한 Pointcut, 즉 Joinpoint 중에서 실제로 공통 기능을 적용할 핵심 기능들을 지정하는 패턴이다.

 

우리가 AOP를 이용하여 궁극적으로 원하는 것은 이 공통 기능을 여기저기에 분산되어 있는 핵심 기능들에 효율적으로 적용하는 것이다. 이를 위해서 공통 기능을 적용할 핵심 기능들을 일정한 패턴으로 분류하기 위해 사용하는 정규식이 위에서 사용한 execution 명시자다. 현재 프로젝트의 구조는 다음과 같다.

위에서 작성한 Aspect 클래스인 RuntimeMeasure는 springdemo.basic.aop 패키지에 등록되어 있다. 여기서 springdemo.basic 패키지에 있는 모든 public 메서드에 이 공통 기능을 적용하고 싶다면 어떻게 해야 할까? 이는 간단한 execution 패턴으로 다음처럼 나타낼 수 있다.

execution(public * springdemo.basic..*(..))

이 패턴은 순서대로 수식어(public, private. 생략 가능), 메서드의 반환형(String, Object 등), 클래스 이름(패키지 명시. 생략 가능), 메서드 이름, 파라미터 패턴을 의미한다. 그리고 '*' 문자는 어떤 값이든 상관없다는 것을, ".."은 0개 이상이라는 것을 의미한다. 위의 패턴을 해석하면 다음과 같다.

  • 수식어: "public". public 메서드로 한정한다.

  • 반환형: "*". 어떤 값을 반환하는 메서드도 상관없다.

  • 클래스 이름: "springdemo.basic..". springdemo.basic 패키지 하위의 모든 클래스를 대상으로 한다.

  • 메서드 이름: "*". 어떤 이름의 메서드도 상관없다.

  • 파라미터 패턴: "(..)". 파라미터가 아예 없든 몇 개나 있든 상관없다.

즉 이 RuntimeMeasure라는 공통 기능을 springdemo.basic 패키지 안에 있는 모든 클래스의 public 메서드, 핵심 기능에 적용하라는 의미가 된다. 실제로 이를 실행시켜 보면 springdemo.basic 패키지 안에 있는 AppContext, Greeter 클래스의 public 메서드들의 실행 시간이 출력되는 것을 볼 수 있다.

놓칠 수 있지만 이런 AOP는 스프링 컨테이너에 등록된 Bean 객체에만 해당된다. 동일한 조건의 일반 객체를 생성해서 아무리 메서드를 호출해도 Bean 객체가 아니라면 핵심 기능으로 취급되지 않기 때문에 위에서 설정한 공통 기능은 적용되지 않는다.

public class Hater {
    public void hate(){
        System.out.println("I hate you.");
    }
}
...
Greeter g1 = ctx.getBean("greeter", Greeter.class);
Hater h1 = new Hater();

g1.greet("Jason");
h1.hate();
...

핵심 기능으로 등록된 Greeter 클래스의 greet 메서드는 AOP가 적용되어 실행 시간이 측정되었지만 일반 자바 클래스(POJO. Plain Old Java Object)인 Hater 클래스의 hate 메서드는 AOP 코드가 적용되지 않은 것을 볼 수 있다.

 

이 핵심 기능에 다른 공통 기능을 추가하려면 어떻게 해야 할까? 위에서 사용한 expression 패턴을 복사해서 다른 공통 기능의 @Around 태그에 붙여 넣어야 할까? 이러면 패턴을 수정할 때 일일이 찾아서 수정해야 한다는 단점이 있다. 그래서 이 expression 자체를 @Pointcut이라는 어노테이션으로 분리하여 여러 공통 기능에서 사용할 수 있다.

public class CoreMethods {
    @Pointcut("execution(public * springdemo.basic..*(..))")
    public void allPublicSpringdemoBasicMethods(){}
}
@Aspect
@Component
public class RuntimeMeasure {
    @Around("CoreMethods.allPublicSpringdemoBasicMethods()")
    private Object measure(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
        ...

위에서 사용한 expression 패턴을 @Pointcut 어노테이션에 전달해서 메서드에 붙여두면 나중에 다른 공통 기능 클래스에서 @Around 어노테이션에 해당 메서드까지 경로를 전달해서 활용할 수 있다. 위의 코드에서는 패턴을 CoreMethods 클래스의 allPublicSpringdemoBasicMethods 메서드에 달린 @Pointcut 어노테이션으로 분리했다. 그리고 패턴에 적용할 공통 기능 클래스의 @Around 어노테이션에서 해당 메서드를 참조하면 메서드의 @Pointcut 어노테이션이 가리키는 패턴으로 핵심 기능들을 불러올 수 있다.

 

지금은 같은 패키지 내부에 있는 CoreMethods에서 참조하기 때문에 위처럼 사용할 수 있지만 다른 패키지에 있는 공통 기능 클래스에서 이를 사용하려면 전체 경로를 명시해야 한다. 작성된 전체 코드는 다음과 같다.

public class CoreMethods {
    @Pointcut("execution(public * springdemo.basic..*(..))")
    public void allPublicSpringdemoBasicMethods(){}
}
@Aspect
@Component
public class RuntimeMeasure {
    @Around("CoreMethods.allPublicSpringdemoBasicMethods()")
    private Object measure(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
        long start = System.nanoTime();
        try {
            return proceedingJoinPoint.proceed();
        } finally {
            long finish = System.nanoTime();
            Signature signature = proceedingJoinPoint.getSignature();
            System.out.printf("%s.%s(%s) runtime: %d ns\n",
                    proceedingJoinPoint.getTarget().getClass().getSimpleName(),
                    signature.getName(), Arrays.toString(proceedingJoinPoint.getArgs()),
                    finish - start);
        }
    }
}

ProceedingJoinPoint

위의 공통 기능 코드를 보면 ProceedingJoinPoint 객체의 proceed 메서드를 사용하는 것을 볼 수 있다. 이 메서드를 호출하면 실제로 핵심 기능 코드를 실행하는 것이며 메서드의 반환 값은 핵심 기능 코드의 반환 값이 된다.

 

이 ProceedingJoinPoint는 핵심 기능 실행뿐 아니라 호출하는 핵심 기능의 정보를 제공하는 인터페이스로 JoinPoint 인터페이스를 상속한다. 그래서 JoinPoint 인터페이스의 getSignature, getTarget 메서드를 이용해 실행하게 될 핵심 기능의 클래스나 메서드 이름, 파라미터 정보를 얻어올 수 있다.

공통 기능 중첩

그런데 proceed 메서드의 문서를 읽어보면 "Proceed with the next advice or target method invocation"이라는 설명을 볼 수 있다. 여기서 말하는 target method란 핵심 기능 메서드일 것이다. 그러면 next advice, 즉 다음 Advice로 진행한다는 것은 무엇일까? 이는 위에서 언급했던 것처럼 여러 개의 공통 기능이 핵심 기능에 적용되는 경우 순서대로 실행한다는 것이다.

 

위의 코드에서는 실행할 핵심 기능의 실행 시간과 정보를 출력하는 로직이 같이 구현되어 있다. 이를 따로 분리하여 별도의 공통 기능으로 만들고 기존의 실행 시간 측정 공통 기능과 같이 실행하려면 다음처럼 구현할 수 있다.

@Aspect
@Component
public class MethodInformation {
    @Around("CoreMethods.allPublicSpringdemoBasicMethods()")
    private Object measure(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
        try {
            return proceedingJoinPoint.proceed();
        } finally {
            Signature signature = proceedingJoinPoint.getSignature();
            System.out.printf("    %s(%s)\n", signature.getName(),
                    Arrays.toString(proceedingJoinPoint.getArgs()));
        }
    }
}
@Aspect
@Component
public class ConcernInformation {
    @Around("CoreMethods.allPublicSpringdemoBasicMethods()")
    private Object measure(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
        try {
            return proceedingJoinPoint.proceed();
        } finally {
            Signature signature = proceedingJoinPoint.getSignature();
            System.out.printf("Currently running: %s\n",
                    proceedingJoinPoint.getTarget().getClass().getSimpleName());
        }
    }
}
@Aspect
@Component
public class RuntimeMeasure {
    @Around("CoreMethods.allPublicSpringdemoBasicMethods()")
    private Object measure(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
        long start = System.nanoTime();
        try {
            return proceedingJoinPoint.proceed();
        } finally {
            long finish = System.nanoTime();
            System.out.printf("    Runtime: %d ns\n", finish - start);
        }
    }
}

MethodInformation, ConcernInformation, RuntimeMeasure 3개의 @Aspect 클래스에 각각의 기능을 분리했다. 그리고 실행시켜보면 잘 동작하는 것을 볼 수 있다.

이는 3개의 공통 기능이 순서대로 다음 공통 기능을 자신의 핵심 기능으로 삼아서 실행시킨 것이다.

이전에는 RuntimeMeasure 공통 기능에서 pattern 메서드와 greet 메서드를 핵심 기능으로 직접 호출했다. 하지만 지금처럼 여러 개의 공통 기능이 적용된 경우 위의 그림처럼 중첩되어 핵심 기능으로 다른 공통 기 능(RuntimeMeasure의 경우 MethodInformation)을 호출하게 되는 것이다.

 

그런데 출력되는 순서가 마음에 들지 않는다. 이는 각 공통 기능들이 실행되는 순서를 지정하지 않았기 때문인데 순서를 지정하려면 @Order 어노테이션을 사용할 수 있다.

@Aspect
@Component
@Order(3)
public class ConcernInformation {
    ...
@Aspect
@Component
@Order(2)
public class MethodInformation {
    ...
@Aspect
@Component
@Order(1)
public class RuntimeMeasure {
    ...

공통 기능의 Order 어노테이션에 넘긴 값이 클수록 먼저 실행되는 것을 볼 수 있다.

AOP의 Proxy Object

스프링 AOP는 프록시 객체를 만들어 공통 기능을 핵심 기능에 붙여준다고 하였다. 위의 실행 결과에서도 볼 수 있듯이 컨테이너에 등록된 핵심 기능을 직접 출력시켜보면 클래스 이름이 뭔가 다른 것을 볼 수 있다.

System.out.println("Class of Greeter g1: " + g1.getClass().getName());
System.out.println("Class of Greeter: " + Greeter.class.getName());

객체 g1은 컨테이너에서 getBean 메서드로 꺼낸 Bean 객체로 현재는 AOP가 설정되어 CGLIB 라이브러리를 이용한 프록시 객체로 생성된 것을 볼 수 있다. 스프링에서는 JDK 동적 프록시나 CGLIB 라이브러리를 이용하여 프록시 객체를 생성하는데 특정 인터페이스를 구현하지 않은 클래스는 CGLIB 라이브러리를 사용한다. AOP를 해제하고 실행해보면 다음처럼 프록시 객체가 아니라 일반적인 Greeter 클래스의 인스턴스인 것을 볼 수 있다.

언급했듯이 스프링에서는 실행 시간에 프록시 객체를 생성해서 핵심 기능을 공통 기능으로 감싸기 때문에 위처럼 좀 달라진 클래스로 나타난 것이다. 이런 클래스의 특징은 인터페이스를 구현한 클래스를 핵심 기능으로 프록시 객체를 생성할 때 나타난다.

 

예를 들어 Greetable 인터페이스를 구현한 Greeter가 있고 이를 핵심 기능으로 사용한다고 하자. 컨테이너에서 getBean 메서드를 이용해 이 Greeter 클래스의 Bean 객체를 꺼낼 수 있을까?

Greeter g1 = ctx.getBean("greeter", Greeter.class);

시도한 결과 아래처럼 BeanNotOfRequiredTypeException가 발생했다.

Exception in thread "main" org.springframework.beans.factory.BeanNotOfRequiredTypeException:
    Bean named 'greeter' is expected to be of type 'springdemo.basic.Greeter'
    but was actually of type 'com.sun.proxy.$Proxy21'

이번에는 클래스가 인터페이스를 구현했기 때문에 CGLIB이 아닌 JDK의 프록시를 사용했다. 아무튼 이 예외를 읽어보면 컨테이너에서 꺼내려고 한 Bean 객체 "greeter"는 프록시 객체기 때문에 Greeter 클래스로 꺼낼 수 없다는 것이다. 왜 이럴까? 이는 스프링이 핵심 기능에 AOP를 적용하여 프록시 객체로 컨테이너에 등록할 때 해당 객체가 어떤 인터페이스를 구현했다면 그 인터페이스로 프록시 객체를 생성하기 때문이다.

Greetable g1 = ctx.getBean("greeter", Greetable.class);

그래서 Greeter 클래스의 Bean 객체 "greeter"가 AOP에 적용된다고 해도 실제로는 Greeter 클래스가 구현한 Greetable 인터페이스로 프록시 객체가 생성되는 것이다.

이 둘은 명백히 다른 클래스기 때문에 getBean 메서드로 꺼낼 때 Greeter 클래스로 변환할 수 없다. 부모 클래스와 자식 클래스가 아닌 자식 클래스끼리기 때문이다.

 

 

[참고 | docs.spring.io/spring-framework/docs/4.2.5.RELEASE/spring-framework-reference/html/aop.html#aop-introduction-defn]