AOP

정의정·2023년 7월 13일
1

Spring

목록 보기
12/12
post-thumbnail

💡 AOP(Aspect Oriented Programming)

AOPAspect Oriented Programming의 약자다. 여러 객체에서 공통으로 적용할 수 있는 기능의 구현을 분리해 재사용성을 높여주는 프로그래밍 기법을 말한다.

스프링 프레임워크의 AOP 기능은 spring-aop 모듈이 제공한다. spring-context 모듈을 의존 대상에 추가하면 함께 의존 대상에 포함되므로 따로 추가하지 않아도 된다.

대신 aspectweaver은 AOP를 설정하는데 필요한 애노테이션을 제공하기 때문에 의존을 추가하도록 하자.

📌 순서

  1. 프록시(Proxy)
  2. AOP
  3. Proxy 예제
  4. Aspect 예제
  5. 프록시 생성 방식
  6. Advice 순서
  7. @Pointcut 재사용

🌟 프록시(Proxy)

핵심 기능의 실행은 다른 객체에 위임하고 부가적인 기능을 제공하는 객체를 프록시라고 부른다. 핵심 기능을 구현하지 않는 대신 여러 객체에 공통으로 적용할 수 있는 기능을 구현한다.


🌟 AOP

AOP는 앞서 서론에서 언급한 대로, 여러 객체에 공통으로 적용할 수 있는 기능을 분리해서 재사용성을 높여주는 프로그래밍 기법이다.

관점 지향 프로그래밍을 통해 핵심 기능을 구현한 코드의 수정 없이 공통 기능을 적용할 수 있도록 한다.

1. AOP 주요 용어

Advice

언제 공통 관심 기능을 핵심 로직에 적용할 지를 정의하고 있다. '메서드를 호출하기 전'(언제)에 '트랜잭션 시작'(공통 기능) 기능을 적용한다는 것을 정의한다.

Joinpoint

Advice를 적용 가능한 지점을 의미한다. 메서드 호출, 필드 값 변경 등이 Joinpoint에 해당한다. 스프링은 프록시를 이용해서 AOP를 구현하기 때문에 메서드 호출에 대한 Joinpoint만 지원한다.

Pointcut

Joinpoint의 부분 집합으로서 실제 Advice가 적용되는 Joinpoint를 나타낸다. 스프링에서는 정규 표현식이나 AspectJ의 문법을 이용하여 Pointcut을 정의할 수 있다.

Weaving

Advice를 핵심 로직 코드에 적용하는 것을 weaving이라고 한다.

Aspect

여러 객체에 공통으로 적용되는 기능을 Aspect라고 한다. 트랜잭션이나 보안 등이 Aspect의 좋은 예다.

3. Advice

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

Before Advice

대상 객체의 메서드 호출 전에 공통 기능을 실행한다.

After Returning advice

대상 객체의 메서드가 익셉션 없이 실행된 이후에 공통 기능을 실행한다.

After Trowing Advice

대상 객체의 메서드를 실행하는 도중 익셉션이 발생한 경우에 공통 기능을 실행한다.

After Advice

익셉션 발생 여부에 상관없이 대상 객체의 메서드 실행 후 공통 기능을 실행한다.

Around Advice

대상 객체의 메서드 실행 전, 후 또는 익셉션 발생 시점에 공통 기능을 실행하는데 사용된다.


🌟 Proxy 예제

이론적인 부분을 어느정도 다뤄봤으니 간단한 예제를 통해 살펴보도록 한다. 스프링 AOP를 이용해 공통 기능을 구현하고 적용해보자.

1. aspectweaver 의존 추가하기

pom.xml 파일에 다음과 같이 aspectjweaver에 대한 의존을 추가한다.

pom.xml 의존 추가✏️

	<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.9.19</version>
		</dependency>
	</dependencies>

2. 예제에 필요한 클래스 작성

계승을 구하기 위한 Calculator 클래스를 calc 패키지에 생성하고 아래와 같이 작성하자.

Calculator.java✏️

public interface Calculator {

	public long factorial(long num);
}

그리고 for문을 이용해 계승 값을 구하는 ImpeCalculator 클래스를 작성한다.

ImpeCalculator.java✏️

public class ImpeCalculator implements Calculator {

	@Override
	public long factorial(long num) {
		long result = 1;
		for (long i = 1; i <= num; i++) {
			result *= i;
		}
		return result;
	}
}

재귀호출을 이용해 계승을 구하는 RecCalculator 클래스도 작성한다.

RecCalculator.java✏️

public class RecCalculator implements Calculator {

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

이제 계승 구현 클래스의 실행 시간을 출력할 것이다.

3. ExeTimeCalculator 클래스 작성

나노초 단위로 실행 시간을 출력하기 위해 프록시 객체를 이용하자.

ExeTimeCalculator.java✏️

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;
	}
}

이제 이 객체를 실행할 메인 메서드를 작성해보자.

4. MainProxy 클래스 작성 및 실행

MainProxy 클래스는 main 패키지에 다음과 같이 작성한다.

MainProxy.java✏️

public class MainProxy {

	public static void main(String[] args) {
		ExeTimeCalculator ttCal1 = new ExeTimeCalculator(new ImpeCalculator());
		System.out.println(ttCal1.factorial(20));

		ExeTimeCalculator ttCal2 = new ExeTimeCalculator(new RecCalculator());
		System.out.println(ttCal2.factorial(20));
	}
}

이제 이 클래스를 실행시켜보자.

결과

ImpeCalculator.factorial(20) 실행 시간 = 3100
2432902008176640000
RecCalculator.factorial(20) 실행 시간 = 3000
2432902008176640000

ImpeCalculator, RecCalculator 클래스 코드 변경 없이 두 클래스의 메서드 실행 시간을 구할 수 있었다. 이를 가능케 한 것이 프록시 객체다. 공통 기능을 따로 구분했기 때문이다.


🌟 Aspect 예제

1. Aspect 구현 클래스 작성

이제 스프링 AOP를 이용해 공통 기능을 구현하고 적용해보자.
우선 공통 기능을 제공하는 Aspect 구현 클래스를 aspect 패키지에 다음처럼 작성한다.

ExeTimeAspect.java✏️

@Aspect
public class ExeTimeAspect {

	@Pointcut("execution(public * calc..*(..))")
	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));
		}
	}
}

@Aspect 애노테이션을 적용한 클래스는 AdvicePointcut을 함께 제공한다.

@Pointcut 애노테이션은 공통 기능을 적용할 대상을 설정한다. "execution(public calc..(..))"를 값으로 줌으로써 calc 패키지와 그 하위 패키지에 위치한 타입의 public 메서드를 Pointcut으로 설정한다.

execution 명시자 표현식✔️

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

'*'는 모든 값을 표현할 수 있고, '..'는 0개 이상이라는 의미를 표현할 수 있다.

@Around 애노테이션은 Around Advice를 설정한다. 애노테이션의 값을 "publicTarget()"으로 적는 것은 publicTarget() 메서드에 정의한 Pointcut에 공통 기능을 적용한다는 뜻이다.

measure() 메서드의 ProceedingJoinPoint 타입 파라미터는 프록시 대상 객체의 메서드를 호출할 때 사용한다. 대상 객체의 메서드를 호출한 이전과 이후에 공통 기능을 위한 코드를 적는다.

ProceedingJoinPoint의 getSignature(), getTarget(). getArgs() 등의 메서드는 각각 시그니처, 대상 객체, 인자 목록을 구하는데 사용한다.

2. 스프링 설정 클래스에 추가

스프링 설정 클래스인 AppCtx 클래스에 필요한 코드를 추가해보자.

AppCtx.java 에 설정 추가✏️

@Configuration
@ComponentScan(basePackages = { "spring" })
@EnableAspectJAutoProxy
public class AppCtx {
	
	@Bean
	public ExeTimeAspect exeTimeAspect() {
		return new ExeTimeAspect();
	}

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

	...생략...
   
}

@EnableAspectJAutoProxy 애노테이션을 붙여야 스프링이 @Aspect 애노테이션이 붙은 빈 객체를 찾아 빈 객체의 @Pointcut 설정과 @Around 설정을 사용한다.

3. MainAspect 클래스 작성 및 실행

이제 메인 메서드를 작성하고 실행할 것이다. 다음과 같이 작성하자.

MainAspect.java✏️

public class MainAspect {

	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();
	}
}

실행해보면 다음처럼 출력되는 것을 확인할 수 있다.

결과✔️

RecCalculator.factorial([5]) 실행 시간 = 29000 ns
cal.factorial(5) = 120
com.sun.proxy.$Proxy24

🌟 프록시 생성 방식

getBean() 메서드에 RecCalculator 타입을 사용할 수 없을까?

MainAspext 클래스의 getBean() 메서드 호출 코드와 AppCtx 파일을 조금만 수정하면 될 것 같지만, 익셉션이 발생한다.

스프링은 AOP를 위한 프록시 객체를 생성할 때 실제 생성할 빈 객체가 인터페이스를 상속할 경우 인터페이스를 이용해 프록시를 생성하기 때문이다.

1. 프록시 생성에 클래스 이용하기

만약 빈 객체가 인터페이스를 상속하지만, 인터페이스가 아닌 클래스를 이용해 프록시를 생성하고 싶다면 따로 설정이 필요하다.

AppCtx.java 에 코드 추가✏️

@Configuration
@ComponentScan(basePackages = { "spring" })
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class AppCtx {
	
	@Bean
	public ExeTimeAspect exeTimeAspect() {
		return new ExeTimeAspect();
	}

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

	...생략...
   
}

MainAspect.java 수정✏️

public class MainAspect {

	public static void main(String[] args) {
		AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppCtx.class);

		RecCalculator cal = ctx.getBean("calculator", RecCalculator.class);
		long fiveFact = cal.factorial(5);
		System.out.println("cal.factorial(5) = " + fiveFact);
		System.out.println(cal.getClass().getName());
		ctx.close();
	}
}

이렇게 하면 실제 클래스를 이용해 빈 객체를 구할 수 있게 된다.


🌟 Advice 순서

한 Pointcut에 여러 Advice를 적용하고 싶다면, 어떻게 하면 좋을까?

1. 캐시 구현 클래스 작성

간단하게 캐시를 구현할 공통기능을 담은 CacheAspect 클래스를 작성해보자.

CacheAspect.java✏️

@Aspect
public class CacheAspect {

	private Map<Long, Object> cache = new HashMap<>();

	@Pointcut("execution(public * calc..*(long))")
	public void cacheTarget() {

	}

	@Around("cacheTarget()")
	public Object execute(ProceedingJoinPoint joinpoint) throws Throwable {
    	// 첫 번째 인자를 Long 타입으로 구한다. 
		Long num = (Long) joinpoint.getArgs()[0];
        // 구한 키값이 cache에 존재하지 않으면 키에 해당하는 값을 구해서 리턴한다. 
		if (cache.containsKey(num)) {
			System.out.printf("CacheAspect: Cache에서 구함[%d]\n", num);
			return cache.get(num);
		}

		// 키값이 cache에 존재하지 않으면 프록시 대상 객체를 실행한다. 
		Object result = joinpoint.proceed();
        // 프록시 대상 객체를 실행한 결과를 cache에 추가한다. 
		cache.put(num, result);
		System.out.printf("CacheAspect: Cache에 추가[%d]\n", num);
        // 프록시 대상 객체의 실행 결과를 리턴한다. 
		return result;
	}
}

자세한 코드는 주석을 보며 이해하면 된다.

2. 스프링 설정 클래스에 추가

AppCtx 클래스에 코드를 추가하자.

AppCtx.java 설정 추가✏️

@Configuration
@ComponentScan(basePackages = { "spring" })
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class AppCtx {
	
	@Bean
	public CacheAspect cacheAspect() {
		return new CacheAspect();
	}

	@Bean
	public ExeTimeAspect exeTimeAspect() {
		return new ExeTimeAspect();
	}

	@Bean
	public Calculator calculator() {
		return new RecCalculator();
	}
   
	...생략...
   
}

3. MainAspect 클래스 작성 및 실행

이제 MainAspectWithCache라는 메인 메서드를 작성하고 실행해보자.

MainAspectWithCache.java✏️

public class MainAspectWithCache {

	public static void main(String[] args) {
		AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppCtx.class);

		RecCalculator cal = ctx.getBean("calculator", RecCalculator.class);
		cal.factorial(7);
		cal.factorial(7);
		cal.factorial(5);
		cal.factorial(5);
		ctx.close();
	}
}

실행시켜보면 다음과 같은 출력 결과를 확인할 수 있다.

결과✔️

RecCalculator.factorial([7]) 실행 시간 = 8008700 ns
CacheAspect: Cache에 추가[7]
CacheAspect: Cache에서 구함[7]
RecCalculator.factorial([5]) 실행 시간 = 4200 ns
CacheAspect: Cache에 추가[5]
CacheAspect: Cache에서 구함[5]

4. @Order를 이용한 적용 순서 지정

프록시의 적용 순서를 지정하고 싶다면 @Order 애노테이션을 사용하면 된다. 사용해보자.

ExeTimeAspect.java 에 @Order(1) 적용✏️

// ExeTimeAspect.java
@Aspect
@Order(1)
public class ExeTimeAspect {
	...생략...
}

CacheAspect.java 에 @Order(2) 적용✏️

@Aspect
@Order(2)
public class CacheAspect {
	...생략...
}

@Order 애노테이션 값이 작으면 먼저 적용하고, 크면 나중에 적용한다.

다시 실행 결과를 살펴보자.

결과✔️

CacheAspect: Cache에 추가[7]
RecCalculator.factorial([7]) 실행 시간 = 10084900 ns
CacheAspect: Cache에서 구함[7]
RecCalculator.factorial([7]) 실행 시간 = 128400 ns
CacheAspect: Cache에 추가[5]
RecCalculator.factorial([5]) 실행 시간 = 176200 ns
CacheAspect: Cache에서 구함[5]
RecCalculator.factorial([5]) 실행 시간 = 107900 ns

🌟 @Pointcut 재사용

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

다른 클래스에 위치한 @Around 애노테이션에서 publicTarget() 메서드의 Pointcut을 사용하고 싶다면 publicTarget() 메서드를 public으로 바꿔야 한다.

그 후 다른 클래스에 위치한 @Around 애노테이션의 값을 알맞게 지정해주면 된다.

1. 별도의 클래스에 Pointcut 정의하기

여러 Aspect에서 공통으로 사용하는 Pointcut이 있다면 별도 클래스에 Pointcut을 정의하고, 각 Aspect 클래스에서 해당 Pointcut을 사용하도록 구성할 수 있다.

예시를 보자.

CommonPointcut.java✏️

public class CommonPointcut {

	@Pointcut("execution(public * calc..*(..))")
	public void commonTarget() {

	}
}

ExeTimeAspect.java 수정✏️

@Aspect
@Order(1)
public class ExeTimeAspect {

	@Around("CommonPointcut.commonTarget()")
	public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
		...생략...
	}

}

ExeTimeAspect.java 수정✏️

@Aspect
@Order(2)
public class CacheAspect {

	private Map<Long, Object> cache = new HashMap<>();

	@Around("CommonPointcut.commonTarget()")
	public Object execute(ProceedingJoinPoint joinpoint) throws Throwable {
		...생략...
	}
}

이렇게 하면 Pointcut 관리가 편해진다는 장점이 있다.

🎀 줄이며...

Proxy와 스프링 AOP에 대해 다뤄보았다.
공부하면서 어려운 감이 있었지만, 천천히 실행 흐름을 중심으로 공부하다보니 잘 이해할 수 있었다.

다음 포스트에서는 DB연동에 대한 부분을 공부하도록 하겠다.

📖 교재 및 출처

  • 초보 웹 개발자를 위한 스프링5 프로그래밍 입문 | 최범균님 저
profile
배움 기록

0개의 댓글