10 분 소요

1. 프로젝트 준비

pom.xml파일에는 다음과 같이 aspectjweaver 의존을 추가한다. 이 모듈은 스프링이 AOP를 구현할 때 사용하는 모듈이다.

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 모듈도 함계 의존 대상에 포함된다.
aspectweaver 모듈은 AOP를 설정하는데 필요한 애노테이션을 제공하므로 이 의존을 추가해야 한다.

1
2
3
4
5
package chap07;

public interface Calculator {
	public long factorial(long num);
}

계승을 구하기 위한 인터페이스를 정의한다.
인터페이스를 구현한 첫 번째 클래스

1
2
3
4
5
6
7
8
9
10
11
12
package chap07;

public class ImpeCalculator 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
12
package chap07;

public class RecCalculator implements Calculator{
	@Override
	public long factorial(long num) {
		if(num == 0) {
			return 1;
		}else {
			return num * factorial(num-1);
		}
	}
}

2. 프록시와 AOP

앞에서 구현한 계승 구현 클래스의 실행 시간을 출력하려면 어떻게 해야 할까?
쉬운 방법은 메서드의 시작과 끝에서 시간을 구하고 이 두 시간의 차이를 출력하는 것이다. 하지만 RecCalculator 클래스는 약간 복잡해진다. 재귀 호출로 구현해서 factorial() 메서드의 시작과 끝에 시간을 구해서 차이를 출력하는 코드를 넣으면 메시지가 여러 번 출력되는 문제가 발생한다. 따라서 RecCalculator를 고려하면 실행 시간을 출력하기 위해 기존 코드를 변경하는 것보다는 차라리 메서드 실행 전후에 값을 구하는게 나을지도 모른다.
그런데 기존 코드를 수정하지 않고 코드 중복도 피할 수 있는 방법은 없을까? 이때 출현하는 것이 바로 프록시 객체이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package chap07;

public class ExeTimeCalculator implements Calculator{
	private Calculator delegate;
	
	public ExeTimeCalculator(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;
	}
}

이 클래스는 생성자를 통해 다른 Calculator 객체를 전달받아 delegate필드에 할당하고 factorial()에서 delegate.factorial() 메서드를 실행한다. 이렇게 핵심 기능의 실행은 다른 객체에게 위임하고 부가적인 기능을 제공하는 객체를 프록시(proxy)라고 부른다. 실제 핵심 기능을 실행하는 객체는 대상 객체라고 부른다.

엄밀히 말하면 지금 작성한 코드는 프록시라기 보다는 데코레이터(decorator)객체에 가깝다. 프록시는 접근 제어 관점에 초점이 맞춰져 있다면, 데코레이터는 기능 추가와 확장에 초점이 맞춰져 있기 때문이다.

  • 프록시의 특징
    • 핵심 기능은 구현하지 않는다.
    • 여러 객체에 공통으로 적용할 수 있는 기능을 구현한다.

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

2.1 AOP

AOP는 Aspect Oriented Programming의 약자로, 여러 객체에 공통으로 젹용할 수 있는 기능을 분리해서 재사용성을 높여주는 프로그래밍 기법이다.

Aspect Oriented Programmin을 우리말로는 ‘관점 지향 프로그래밍’ 정도로 많이 번역하고 있으나, 여기서 Aspect는 구분되는 기능이나 요소를 의미하기 때문에 ‘관점’보다는 ‘기능’ 내지 ‘관심’이라는 표현이 더 알맞다.

AOP의 기본 개념은 핵심 기능에 공통 기능을 삽입하는 것이다. 즉 핵심 기능의 코드를 수정하지 않으면서 공통 기능의 구현을 추가하는 것이 AOP이다.

  • 핵심 기능에 공통 기능을 삽입하는 방법에는 다음 세 가지가 있다.
    • 컴파일 시전에 코드에 공통 기능을 삽입하는 방법
    • 클래스 로딩 시점에 바이트 코드에 공통 기능을 삽입하는 방법
    • 런타임에 프록시 객체를 생성해서 공통 기능을 삽입하는 방법

첫 번째 방법은 AOP 개발 도구가 소스 코드를 컴파일 하기 전에 공통 구현 코드를 소스에 삽입하는 방식으로 동작한다.
두 번째 방법은 클래스를 로딩할 때 바이트 코드에 공통 기능을 클래스에 삽입하는 방식으로 동작한다.
이 두 가지는 스프링 AOP에서는 지원하지 않으며 AspectJ와 같이 AOP 전용 도구를 사용해서 적용할 수 있다.
스프링이 제공하는 AOP방식은 프록시를 이용한 세 번째 방식이다. 프록시 방식은 중간에 프록시 객체를 생선한다. 그리고 실제 객체의 기능을 실행하기 전,후에 공통 기능을 호출한다.
스프링 AOP는 프록시 객체를 자동으로 만들어준다. 따라서 상위 타입의 인터페이스를 상속받은 프록시 클래스를 직접 구현할 필요가 없다. 단지 공통 기능을 구현한 클래스만 알맞게 구현하면 된다.

2.2 AOP 주요 용어

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

2.3 Advice의 종류

스프링은 프록시를 이용해서 메서드 호출 시점에 Aspect를 적용하기 때문에 구현 가능한 Advice의 종류는 아래와 같다.

  • Before Advice
    대상 객체의 메서드 호출 전에 공통 기능을 실행
  • After Returning Advice
    대상 객체의 메서드가 Exception 없이 실행된 이후에 공통 기능을 실행
  • After Throwing Advice
    대상 객체의 메서드를 실행하는 도중 Exception이 발생한 경우에 공통 기능 실행
  • After Advice
    Exception 발생 여부에 상관없이 대상 객체의 메서드 실행 후 공통 기능을 실행한다.
  • Around Advice
    대상 객체의 메서드 실행 전, 후 또는 Exception 발생 시전에 공통 기능을 실현하는데 사용된다.

이 중에서 널리 사용되는 것은 Around Advice이다. 이유는 대상 객체의 메서드를 실행하기 전/후, 익셉션 발생 시점 등 다양한 시점에 원하는 기능을 삽입할 수 있기 때문이다. 이 페이지에서는 Around Advice의 구현 방법에 대해서만 다루어보겠다.

3. 스프링 AOP 구현

스프링 AOP를 이용해서 공통 기능을 구현하고 적용하는 방법은 다음과 같은 절차만 따르면 된다.

  • Aspect로 사용할 클래스에 @Aspect 애노테이션을 붙인다.
  • @Pointcut 애노테이션으로 공통 기능을 적용할 Pintcut을 정의한다.
  • 공통 기능을 구현한 메서드에 @Around 애노테이션을 적용한다.

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

공통 기능을 제공하는 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
32
package aspect;

import java.util.Arrays;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Aspect
public class ExeTimeAspect {
	@Pointcut("execution(public * chap07..*(..))")
	private void publicTarget() {
	}
	
	@Around("publicTarget()")
	public Object measure(ProceedingJoinPoint joinPoint) throws Throwable{
		long start = System.nanoTime();
		try {
			Object result = joinPoint.proceed();
			return result;
		} finally {
			long finish = System.nanoTime();
			Signature sig = joinPoint.getSignature();
			System.out.printf("%s.%s(%s) 실행 시간 : %d ns\n", 
					joinPoint.getTarget().getClass().getSimpleName(),
					sig.getName(), Arrays.toString(joinPoint.getArgs()),
					(finish-start));
		}
	}
}
  • @Pintcut은 공통 기능을 적용할 대상을 설정한다.
  • @Around는 Around Advice를 설정한다. 이 때 애노테이션의 값이 “publicTarget()”인데 이는 publicTarget()메서드에 정의한 Pointcut에 공통 기능을 적용한다는 것을 의미한다.
  • measure() 메서드의 ProceedingJoinPoint 타입 파라미너는 프록시 대상 객체의 메서드를 호출할 때 사용한다. proceed()메서드를 사용해서 실제 대상 객체의 메서드를 호출한다. 이 메서드를 호출하면 대상 객체의 메서드가 실행되므로 이 코드의 이전과 이후에 공통 기능을 위한 코드를 위치시키면 된다.

자바에서 메서드 이름과 파라미터를 합쳐서 메서드 시그너처라고 한다. 메서드 이름이 다르거나 파라미터 타입, 개수가 다르면 시그너처가 다르다고 표현한다.

다음은 스프링 설정 클래스 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

import aspect.ExeTimeAspect;
import chap07.Calculator;
import chap07.RecCalculator;

@Configuration
@EnableAspectJAutoProxy
public class AppCtx {
	@Bean
	public ExeTimeAspect exeTimeAspect() {
		return new ExeTimeAspect();
	}
	
	@Bean
	public Calculator calculator() {
		return new RecCalculator();
	}
}

@Aspect 애노테이션을 붙인 클래스를 공통 기능으로 적용하려면 @EnableAspectJAutoProxy 애노테이션을 설정 클래스에 붙여야 한다. 이 애노테이션을 추가하면 스프링은 @Aspect 애노테이션이 붙은 빈 객체를 찾아서 빈 객체의 @Pointcut 설정과 @Around 설정을 사용한다.

@Around 애노테이션은 Pointcut으로 publicTarget() 메서드를 설정했다. publicTarget() 메서드의 @Pointcut은 chap07 패키지나 그 하위 패키지에 속한 빈 객체의 public 메서드를 설정한다. Calculator 타입이 chap07 패키지에 속하므로 calculator 빈에 ExeTimeAspect 클래스에 정의한 공통 기능인 measure()를 적용한다.

다음은 AppCtx 설정을 사용해서 스프링 컨테이너를 생성한 예이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

import chap07.Calculator;
import chap07.RecCalculator;
import config.AppCtx;

public class MainProxy {
	public static void main(String[] args) {
		AnnotationConfigApplicationContext ctx = 
				new AnnotationConfigApplicationContext(AppCtx.class);
		
		Calculator cal = ctx.getBean("calculator", Calculator.class);
		long fiveFact = cal.factorial(5);
		System.out.println("cal.factorial(5) = " + fiveFact);
		System.out.println(cal.getClass().getName());
		ctx.close();
	}
}

실행하면 다음과 같은 문구가 콘솔에 출력될 것이다. aop1 여기서 세 번째 줄은 main에서 출력한 코드이다. 이 출력 결과를 보면 Calculator타입이 RecCalculator 클래스가 아니고 $Proxy17이다. 이 타입은 스프링이 생성한 프록시 타입이다.
다음은 cal.factorial(5) 코드를 호출할 때 실행되는 과정이다. aop2

3.2 ProceedingJoinPoint 메서드

Around Advice에서 사용할 공동 기능 메서드는 대부분 파라미터로 전달받은 ProceddingJoinPoint의 proceed() 메서드만 호출하면 된다./ 물론 호출되는 대상 객체에 대한 정보, 실행되는 메서드에 대한 정보, 메서드를 호출할 때 전달된 인자에 대한 정보가 필요할 때가 있다. 이들 정보에 접근할 수 있도록 ProceedingJoinPoin 인터페이스는 다음 메서드를 제공한다.

  • Signature getSignature() : 호출되는 메서드에 대한 정보를 구한다.
  • Object getTarget() : 대상 객체를 구한다.
  • Object[] getArgs() : 파라미터 목록을 구한다.

org.aspectj.lang.Signature 인터페이스는 다음 메서드를 제공한다.

  • String getName() : 호출되는 메서드의 이름을 구한다.
  • String toLongStirng() : 호출되는 메서드를 완전하게 표현한 문장을 구한다.(메서드의 리턴 타입, 파라미터 타입이 모두 표시된다.)
  • String toShortString() : 호출되는 메서드를 축약해서 표현한 문장을 구한다(기본 구현은 메서드의 이름만을 구한다.)

4. 프록시 생성 방식

Main 클래스의 코드를 다음과 같이 변경해보자.

1
2
3
4
//수정 전
Calculator cal = ctx.getBean("calculator", Calculator.class);
//수정 후
RecCalculator cal = ctx.getBean("calculator", RecCalculator.class);

자바 설정 파일을 보면 다음과 같이 “calculator” 빈을 생성할 때 사용한 타입이 RecCalculator 클래스이므로 문제가 없어 보인다.
하지만 정상 실행될 것이라는 예상과 달리 다음과 같은 익셉션이 발생한다. aop3 익셉션 메시지를 보면 getBean() 메서드에 사용한 타입이 RecCalculator인에 반해 실제 타입은 $Proxy17이라는 메시지가 나온다. $Proxy17은 스프링이 런타이에 생성한 프록시 객체의 클래스 이름이다. 이 $Proxy17 클래스는 RecCalculator 클래스가 상속받은 Calculator 인터페이스를 상속받게 된다. 다음과 같은 계층 구조를 갖는다. aop4 스프링은 AOP를 위한 프록시 객체를 생성할 때 실제 생성할 빈 객체가 인터페이스를 상속하면 인터페이스를 이요해서 프록시를 생성한다.

빈 객체가 인터페이스를 상속할 때 인터페이스가 아닌 클래스를 이욯새ㅓ 프록시를 생성하고 싶다면 다음과 같이 설정하면 된다.

1
2
3
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class AppCtx{...}

@EnableAspectJAutoProxy 애노테이션의 proxyTargetClass 속성을 true로 지정하면 인터페이스가 아닌 자바 클래스를 상속받아 프록시를 생성한다.

4.1 execution 명시자 표현식

Aspect를 적용할 위치를 지정할 때 사용한 Pointcut 설정을 보면 execution 명시자를 사용했다.

1
2
@Pointcut("execution(public * chap07..*(..))")
private void publicTarget(){}

<span style=”color:red”‘>execution 명시자는 Advice를 적용할 메서드를 지정할 때 사용한다.</span> 기본 형식은 다음과 같다.

1
execution(수식어패턴? 리턴타입패턴 클래스이름패턴?메서드이름패턴(파라미터패턴))

‘수식어패턴’은 생략 가능하며 public, protected 등이 온다. 스프링 AOP는 public 메서드에만 적용할 수 있기 때문에 사실상 public만 의미있다.

‘리턴타입패턴’은 리턴 타입을 명시한다. ‘클래스이름패턴’과 ‘메서드이름패턴’은 클래스 이름 및 메서드 이름을 패턴으로 명시한다. ‘파라미터패턴’은 매칭될 파라미터에 대해서 명시한다.

각 패턴은 ‘*’을 이용하여 모든 값을 표현할 수 있다. 또한 ’..’을 이용하여 0개 이상이라는 의미를 표현할 수 있다.

4.2 Advice 적용 순서

한 Pointcut에 여러 Advice를 적용할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Aspect
public class CacheAspect {
	private Map<Long, Object> cache = new HashMap<>();
	
	@Pointcut("execution(public * chap07..*(long))")
	public void cacheTarget() {
	}
	
	@Around("cacheTarget()")
	public Object execute(ProceedingJoinPoint joinPoint) throws Throwable{
		Long num = (Long)joinPoint.getArgs()[0];
		if(cache.containsKey(num)) {
			System.out.printf("CacheAspect: Cache에서 구함[%d]\n", num);
			return cache.get(num);
		}
		
		Object result = joinPoint.proceed();
		cache.put(num, result);
		System.out.printf("CacheAspect: Cache에 추가[%d]\n", num);
		return result;
	}
}

CacheAspect 클래스는 간단하게 캐시를 구현한 공통 기능이다.
@Around값으로 cacheTarget()메서드를 지정했다. @Pointcut 설정은 첫 번재 인자가 long인 메서드를 대상으로 한다. 따라서 execute()메서드는 앞서 작성한 Calculator의 factorial(long)메서드에 적용된다.

다음은 설정 클래스이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
@EnableAspectJAutoProxy
public class AppCtxCache {
	@Bean
	public CacheAspect cacheAspect() {
		return new CacheAspect();
	}
	@Bean
	public ExeTimeAspect exeTimeAspect() {
		return new ExeTimeAspect();
	}
	@Bean
	public Calculator calculator() {
		return new RecCalculator();
	}
}

이 설정 클래스를 이용하는 Main클래스는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MainAspectWithCache {
	public static void main(String[] args) {
		AnnotationConfigApplicationContext ctx =
				new AnnotationConfigApplicationContext(AppCtxCache.class);
		
		Calculator cal = ctx.getBean("calculator", Calculator.class);
		cal.factorial(7);
		cal.factorial(7);
		cal.factorial(5);
		cal.factorial(5);
		ctx.close();
	}
}

실행하면 아래와 같은 결과가 출력될 것이다. aop5 결과를 보면 첫 번째 factorial(7)을 실행할 때와 두 번째 factorial(7)을 실행할 때 콘솔에 출력되는 내용이 다르다. 첫 번째 실행 결과는 ExeTimeAspect와 CacheAspect가 모두 적용되었고, 두 번째 실행 결과는 CacheAspect만 적용되었다. 이렇게 천 번째와 두 번째 실행 결과가 다른 이유는 Advice를 다음 순서로 적용했기 때문이다. aop6 Main에서 구한 calculator 빈은 실제로는 CacheAspect 프록시 객체이다. 근데 CacheAspect 프록시 객체의 대상 객체는 ExeTimeAspect의 프록시 객체이다. 그리고 ExeTimeAspect 프록시의 대상 객체가 실제 대상 객체이다.

4.3 @Order 애노테이션

어떤 Aspect가 먼저 적용될지는 스프링 프레임워크나 자바 버전에 따라 달라질 수 있기 때문에 적용 순서가 중요하다면 직접 순서를 지정해야 한다. 이럴 때 사용하는 것이 @Order 애노테이션이다. @Aspect 애노테이션과 함계 @Order애노테이션을 클래스에 붙이면 @Order 애노테이션에 지정한 값에 따라 적용 순서를 결정한다.
이 때 값이 작으면 먼저 적용하고 크면 나중에 적용한다.

1
2
3
4
5
6
7
@Aspect
@Order(1)
public classs ExeTimeAspect(){...}

@Aspect
@Order(2)
public classs CacheAspect(){...}

다음과 같이 적용한다면 ExeTimeAspect가 먼저 적용되고 그 다음에 CacheAspect가 적용된다. aop7 Main클래스를 실행하면 factorial(7)을 처음 실행할 때와 두 번째 실행할 때 모두 두 Aspect가 적용되는 것을 확인할 수 있다. aop8

4.4 @Around의 Pointcut 설정과 @Pointcut 재사용

@Pointcut 애노테이션이 아닌 @Around 애노테이션에 execution 명시자를 직접 지정할 수도 있다. 다음은 설정 예이다.

1
2
3
4
5
6
7
@Aspect
public class CacheAspect{
	@Around("execution(public * chap07..*(..))")
	public Object execute(ProceedingJoinPoint joinPoint) throws Throwable{
		...
	}
}

만약 같은 Pointcut을 여러 Advice가 함께 사용한다면 공통 Pointcut을 재사용할 수도 있다.

1
2
3
4
5
6
7
8
9
@Aspect
public class ExeTimeAspect{
	@Pointcut("exection(public * chap07..*(..))")
	private void publicTarget(){}
	@Around("publicTarget()")
	public Object execute(ProceedingJoinPoint joinPoint) throws Throwable{
		...
	}
}

publicTarget() 메서드는 private인데 이 경우 같은 클래스에 있는 @Around 애노테이션에서만 해당 설정을 사용할 수 있다.
다른 클래스에 위치한 @Around 애노테이션에서 publicTarget() 메서드의 Pointcut을 사용하고 싶다면 publicTarget() 메서드를 public으로 바꾸면 된다.
그리고 해당 Pointcut의 완전한 클래스 이름을 포함한 메서드 이름을 @Around 애노테이션에서 사용하면 된다.
밑에 코드가 예시이다.

1
2
3
4
5
6
7
@Aspect
public class CacheAspect{
	@Around("aspect.ExeTimeAspect.publicTarget()")
	public Object execute(ProceedingJoinPoint joinPoint) throws Throwable{
		...
	}
}

Ref.

  • 최범균, 스프링프로그래밍입문5, 가메출판사.

카테고리:

업데이트: