AOP - Aspect-Oriented Programming


본 카테고리는 스프링 프레임워크를 다룬다.

좀 더 자세한 내용은 아래의 공식 사이트를 참고하자.

참고 스프링 프레임워크 공식 사이트


AOP - 관점 지향 프로그래밍

먼저 실습 코드를 통해 AOP를 알아보자.

스프링 프로젝트 생성시 만들어지는 pom.xml 파일에 aspectjweaver 의존을 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.0.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.8.13</version>
</dependency>
</dependencies>

스프링 프레임워크의 AOP 기능은 spring-aop 모듈이 제공하는데
spring-context 모듈을 의존 대상에 추가하면 spring-aop도 자동으로 포함된다.

별도로 aspectjweaver를 추가한 이유는 AOP를 설정하는 데 필요한 어노테이션을 제공해주기 때문이다.

의존성을 설정한 뒤 실습을 위해 아래와 같은 코드를 작성하자.

1
2
3
4
// 입력된 num의 팩토리얼값을 구하는 인터페이스
public interface Calculator {
public long factorial(long num);
}
1
2
3
4
5
6
7
8
9
10
11
// Iterator를 이용해 Calculator 인터페이스를 구현한 클래스
public class IteratorCalculator implements Calculator {
@Override
public long factorial(long num) {
long result = 1;
for (int i = 1; i <= num; i++) {
result *= i;
}
return result;
}
}
1
2
3
4
5
6
7
8
9
10
11
// Recursive를 이용해 Calculator 인터페이스를 구현한 클래스
public class RecursiveCalculator implements Calculator {
@Override
public long factorial(long num) {
if (num == 0) {
return 1;
} else {
return num * factorial(num -1);
}
}
}

만약 IteratorCalculator 클래스의 factorial() 메소드 실행 시간을 측정하려면 어떻게 해야할까?

가장 단순하게 접근하는 방법은 메소드의 처음과 끝에서 시간을 재보는 것이다.

아래의 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Iterator를 이용해 Calculator 인터페이스를 구현한 클래스
public class IteratorCalculator implements Calculator {
@Override
public long factorial(long num) {
long start = System.currentTimeMillis();
long result = 1;
for (int i = 1; i <= num; i++) {
result *= i;
}

long end = System.currentTimeMillis();

System.out.printf("IteratorCalculator.factorial(%d) 실행시간 = %d\n", num, (end - start));

return result;
}
}

RecursiveCalculator의 경우 좀 더 복잡해진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Recursive를 이용해 Calculator 인터페이스를 구현한 클래스
public class RecursiveCalculator implements Calculator {
@Override
public long factorial(long num) {
long start = System.currentTimeMillis();
try {
if (num == 0) {
return 1;
} else {
return num * factorial(num -1);
}
} finally {
long end = System.currentTimeMillis();
System.out.printf("RecursiveCalculator.factorial(%d) 실행시간 = %d\n", num, (end - start));
}
}
}

위와 같이 작성하면 num이 2이 주어졌을 때 factorial(2), factorial(1), factorial(0)이 호출되며 3번 출력되게 된다.

이런 경우 차라리 객체를 생성하고 factorial() 메소드의 실행 전, 후의 시간을 측정하는 것이 더 나을 수도 있다.

아래와 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
IteratorCalculator iteratorCalculator = new IteratorCalculator();
long start1 = System.currentTimeMillis();
long result1 = iteratorCalculator.factorial(4);
long end1 = System.currentTimeMillis();
System.out.printf("IteratorCalculator.factorial(4) 실행시간 = %d\n", (end1 - start1));

RecursiveCalculator recursiveCalculator = new RecursiveCalculator();
long start2 = System.currentTimeMillis();
long result2 = recursiveCalculator.factorial(4);
long end2 = System.currentTimeMillis();
System.out.printf("RecursiveCalculator.factorial(4) 실행시간 = %d\n", (end2 - start2));

보다 간결해지긴 했지만, 실행시간의 정밀도를 ms에서 ns로 올리는 경우 4군데의 코드를 수정해야하는 문제가 있다.

기존 코드의 수정을 피하고 중복도 피할 수 있는 방법은 없을까?

이를 해결하기 위한 프록시 객체를 알아보자.

프록시와 AOP

상술했듯 기존 코드의 수정과 코드 중복을 피하기 위해 프록시 객체를 사용한다.

아래의 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ExecuteTimeCalculator implements Calculator {
private Calculator delegate;

public ExecuteTimeCalculator(Calculator delegate) {
this.delegate = delegate;
}

@Override
public long factorial(long num) {
long start = System.nanoTime();
long result = delegate.factorial(num);
long end = System.nanoTime();
System.out.printf("%s.factorial(%d) 실행시간 = %d\n", delegate.getClass().getSimpleName(), num, (end-start));
return result;
}
}
1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
ExecuteTimeCalculator cal1 = new ExecuteTimeCalculator(new IteratorCalculator());
System.out.println(cal1.factorial(20));

ExecuteTimeCalculator cal2 = new ExecuteTimeCalculator(new RecursiveCalculator());
System.out.println(cal2.factorial(20));
}
}

ExecuteTimeCalculatorCalculator 인터페이스를 구현하며, 생성자를 통해 다른 Calculator 객체를 전달받아 delegate에 저장한 후 사용한다.

위의 코드를 살펴보면 다음과 같은 내용을 파악할 수 있다.

  1. 기존 IteratorCalculator 클래스와 RecursiveCalculator 클래스의 코드 변경이 없다.
  2. 실행시간의 코드 중복이 없었다.
  3. nanoTime을 다시 currentTimeMillis로 변경할 때는 ExecuteTimeCalculator만 변경하면 된다.

위와 같이 핵심 기능의 실행은 타 객체에 위임하고, 부가적인 기능을 제공하는 객체를 프록시(proxy) 라고 부른다.

이 프록시의 가장 큰 특징은 핵심 기능을 구현하지 않는 것이다.

이처럼 공통 기능 구현과 핵심 기능 구현을 분리하는 것이 AOP의 핵심이다.

AOP

AOP는 이 포스팅의 타이틀에서 볼 수 있듯 Aspect Oriented Programming의 약자로 관점 지향 프로그래밍이라고 해석한다.

이 AOP는 여러 객체에 공통적으로 적용할 수 있는 기능을 분리해서 재사용성을 높이는 것에 주안점을 두는 기법으로, 위의 프록시에서 체험했듯, 핵심 기능과 공통 기능의 구현을 분리함으로써 핵심 기능을 구현한 코드의 수정 없이 공통 긴으을 적용할 수 있게 만들어준다.

AOP의 기본 개념은 핵심 기능에 공통 기능을 삽입하는 것이다.

즉, 핵심 기능의 코드를 수정하지 않으면서 공통 기능의 구현을 추가하는 것이 AOP이다.

핵심 기능에 공통 기능을 넣는 방법으로는 아래의 3가지가 있다.

  1. 컴파일 시점에 코드에 공통 기능을 삽입하는 방법
  2. 클래스 로딩 시점에 바이트 코드에 공통 기능을 삽입하는 방법
  3. 런타임에 프록시 객체를 생성해서 공통 기능을 삽입하는 방법

위의 첫 번째 방법과 두 번째 방법은 AspectJ와 같은 AOP 전용 도구를 사용해서 적용할 수 있다.

스프링 AOP에서 제공하는 방식으 3번째 방법인 런타임에 삽입하는 방법이며, 프록시를 이용해서 삽입한다.

스프링 AOP는 프록시 객체를 동으로 만들어주며, 위의 프록시 예제처럼 ExecuteTimeCalculator 클래스처럼 사우이 타입의 인터페이스를 구현한 프록시 클래스를 직접 작성할 필요는 없다.

단지 공통 기능을 구현한 클래스만 맞게 구현하면 된다.

이 공통 기능을 스프링 AOP에서는 Aspect 라고 하는데, 이와 관련된 용어는 아래를 참고하면 된다.

Aspect
여러 객체에 공통으로 적용되는 기능을 Aspect라고 한다. 트랜잭션이나 보안 등이 Aspect의 좋은 예이다.
Advice
언제 공통 관심 기능을 핵심 로직에 적용할 지를 정의하고 있다. 예를 들어 메소드 호출하기 전(언제)트랜잭션 시작(공통 기능) 기능을 적용하는 것을 정의한다.
JoinPoint
Advice를 적용 가능한 지점을 의미한다. 메서드 호출, 필드 값 변경 등이 Joinpoint에 해당한다. 스프링은 프록시를 이용해서 AOP를 구현하기 때문에 메서드 호출에 대한 Joinpoint만 지원한다.
Pointcut
Joinpoint의 부분 집합으로서 실제로 Advice가 적용되는 Joinpoint를 나타낸다. 스프링에서는 정규 표현식이나 AspectJ의 문법을 이용하여 Pointcut을 정의할 수 있다.
Weaving
Advice를 핵심 로직 코드에 적용하는 것을 Weaving이라고 한다.

Advice의 종류

스프링은 프록시를 이용해 메소드 호출 시점에 Aspect를 적용한다.

때문에 구현 가능한 Advice의 종류는 아래와 같다.

Before Advice
대상 객체의 메소드 호출 전에 공통 기능을 실행한다.
After Returning Advice
대상 객체의 메소드가 익셉션 없이 실행된 이후에 공통 기능을 실행한다.
After Throwing Advice
대상 객체의 메소드를 실행하는 도중 익셉션의 발생한 경우에 공통 기능을 실행한다.
After Advice
익셉션 발생 여부와 상관없이 대상 객체의 메소드 실행 후 공통 기능을 실행한다.
(try-catch-finally의 finally블록과 비슷하다.)
Around Advice
대상 객체의 메서드 실행 전, 후 또는 익셉션 발생 시점에 공통 기능을 실행하는데 사용된다.

위의 Advice 중 가장 널리 사용되는 건 다양한 시점에 원하는 기능을 삽입할 수 있는 Around Advice 이다.

스프링 AOP 구현

스프링 AOP를 이용해서 공통 기능을 구현하고 적용하는 방법은 아래와 같은 절차로 진행된다.

  1. Aspect로 사용할 클래스에 @Aspect 어노테이션을 붙인다.
  2. @Pointcut 어노테이션으로 공통 기능에 적용할 Pointcut을 정의한다.
  3. 공통 기능을 구현한 메소드에 @Around 어노테이션을 적용한다.

@Aspect, @Pointcut, @Around를 이용한 AOP 구현

먼저 공통 기능을 제공하는 Aspect 구현 클래스를 만들고 자바의 설정(혹은 XML)을 이용해서 어디에 Aspect를 적용할지 설정하면 된다.

프록시는 스프링 프레임워크가 알아서 만들어주므로 @Aspect 어노테이션을 이용해 구현 클래스만 만들어 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// @Aspect를 적용한 클래스는 Advice와 Pointcut을 함께 제공한다.
@Aspect
public class ExecuteTimeAspect {

// @Pointcut을 이용해 공통 기능을 적용할 대상을 설정한다
// 이 코드는 PACKAGE_NAME에 지정된 패키지와 그 하위 패키지에 위치한 public 타입의 메소드를 지정한 상태이다.
@Pointcut("execution(public * [PACKAGE_NAME]..*(..))")
private void publicTarget() {}

// @Around를 이용해 Around Advice를 설정한다.
// 이 코드는 publicTartget() 메소드에 정의한 Pointcut의 공통 기능을 적용한 상태이다.
@Around("publicTarget()")
public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();

try {
Object result = joinPoint.proceed();
// proceed() 메소드를 이용해 실제 대상 객체의 메소드를 호출한다.
return result;
} finally {
long finish = System.nanoTime();
Signature signature = joinPoint.getSignature();

System.out.printf("%s.%s(%s) 실행시간 = %d ns\n",
joinPoint.getTarget().getClass().getSimpleName(),
signature.getName(),
Arrays.toString(joinPoint.getArgs()),
(finish - start));
}
}
}

위의 코드는 PACKAGE_NAME 패키지와 그 하위 패키지에 속하는 빈 객체에 public 타입의 measure 메소드를 적용시키는 코드이다.

measure 메소드를 공통 기능으로 구현하였으니, 그 다음으로 스프링 설정 클래스를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
@EnableAspectJAutoProxy
public class ApplicationContext {
@Bean
public ExecuteTimeAspect executeTimeAspect() {
return new ExecuteTimeAspect();
}

@Bean
public Calculator calculator() {
return new RecursiveCalculator();
}
}

@Aspect 어노테이션을 적용한 ExecuteTimeAspect 클래스를 공통 기능으로 적용하기 위해 설정 클래스에 @EnableAspectJAutoProxy 어노테이션을 추가한다.

@EnableAspectJAutoProxy 어노테이션은 @Aspect가 적용된 빈 객체를 찾아서 빈 객체의 @Pointcut 설정과 @Around 설정을 사용할 수 있게 해준다.

실제로 실행하기 위한 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ApplicationContext.class);

Calculator calculator = context.getBean("calculator", Calculator.class);
System.out.println(calculator.factorial(5));
System.out.println(calculator.getClass().getName());
}
}

ProceedingJoinPoint의 메소드

Around Advice에서 사용할 공통 기능 메소드는 대부분 파라미터를 통해 전달받은 ProceedingJoinPoint의 proceed() 메소드의 호출로 동작한다.

위에서 다룬 예제를 다시 한 번 확인해보면 17번째 라인에서 proceed() 메소드를 호출하고 있음을 알 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// @Aspect를 적용한 클래스는 Advice와 Pointcut을 함께 제공한다.
@Aspect
public class ExecuteTimeAspect {

// @Pointcut을 이용해 공통 기능을 적용할 대상을 설정한다
// 이 코드는 PACKAGE_NAME에 지정된 패키지와 그 하위 패키지에 위치한 public 타입의 메소드를 지정한 상태이다.
@Pointcut("execution(public * [PACKAGE_NAME]..*(..))")
private void publicTarget() {}

// @Around를 이용해 Around Advice를 설정한다.
// 이 코드는 publicTartget() 메소드에 정의한 Pointcut의 공통 기능을 적용한 상태이다.
@Around("publicTarget()")
public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();

try {
Object result = joinPoint.proceed(); // Line 17
// proceed() 메소드를 이용해 실제 대상 객체의 메소드를 호출한다.
return result;
} finally {
long finish = System.nanoTime();
Signature signature = joinPoint.getSignature();

System.out.printf("%s.%s(%s) 실행시간 = %d ns\n",
joinPoint.getTarget().getClass().getSimpleName(),
signature.getName(),
Arrays.toString(joinPoint.getArgs()),
(finish - start));
}
}
}

개발 도중에 호출되는 객체에 대한 정보, 실행되는 메소드에 대한 정보, 메소드를 호출할때 전달된 파라미터에 대한 정보가 필요한 경우가 있는데, 이 정보들을 접근할 수 있는 메소드를 ProceedingJoinPoint 인터페이스에서 제공하는 메소드를 통해 접근할 수 있다.

1
2
3
Signature getSignature() // 호출되는 메소드에 대한 정보를 구한다.
Object getTarget() // 대상 겍체를 구한다.
Ojbect[] getArgs() // 파라미터 목록을 구한다.

org.aspectj.lang.Signature를 통해 구현하는 경우 아래의 메소드로 접근한다.

1
2
3
String getName() // 호출되는 메소드의 이름을 구한다.
String toLongString() // 호출되는 메소드를 완전하게 표현한 문장을 구한다. (리턴 타입, 파라미터 타입이 표시된다)
String toShortString() // 호출되는 메소드를 축약해서 표현한 문장을 구한다.

프록시 생성 방식

아래 코드의 변경사항을 확인해보자.

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ApplicationContext.class);

// Calculator calculator = context.getBean("calculator", Calculator.class);
RecursiveCalculator recursiveCalculator = context.getBean("calculator", RecursiveCalculator.class);
System.out.println(calculator.factorial(5));
System.out.println(calculator.getClass().getName());
}
}

getBean() 메소드에 Calculator 타입 대신 RecursiveCalculator타입을 사용하도록 수정한 것을 확인할 수 있다.

이 코드는 BeanNotOfRequiredTypeException이라는 오류 메시지를 출력하며 실행이 되지 않는다.

이는 RecursiveCalculator에 해당하는 타입의 빈 객체를 할당했다 하더라도 calculator에 해당하는 타입을 빈 객체로 주입하기 때문이다.