AOP (Aspect Oriented Programming) 를 우리말 그대로 해석해보면 관점지향 프로그래밍이다. 지식이 없는 상태에서 이 정의를 보았을 때 어떤 의미인지 와닿지 않았다. 그래서 AOP가 왜 필요한지 필요한 상황을 먼저 알아보고자 한다. 다음 그림을 보자.
현재 Class A, B, C 는 각각 자신만의 핵심 관심사항(Core Concern)이 되는 비즈니스 로직을 가지고 있다. 개발자는 이 비즈니스 로직들이 런타임 시 어느정도 시간이 소요되는지 측정하고 싶어 시간 측정 로직을 각각의 Class들에 추가했다.
여기서 문제점이 발생한다.
우선, 거의 동일한 시간 측정 로직이 모든 Class에 중복되서 발생했다는 점이다. 그리고, 이러한 시간 측정 로직이 각 Class별 핵심 비즈니스 로직과 맞물려 유지보수성과 가독성이 나빠졌다. 각각의 비즈니스 로직 사이에서 시간측정을 어떻게 해야할까? 바로 이러한 이유로 AOP가 등장한다.
공통 관심사항(Cross-Cutting-Concern)인 시간 측정 로직을 마치 자바 Class로 따로 작성하듯 추려내고 이를 필요할 때 외부 Class에서 사용하도록 한다. 그러나 이는 단순히 Class를 추려서 하는 방법과는 틀리다. 여기서 프록시 패턴이 등장한다.
프록시 패턴을 알아보기 위해 간단한 예제를 작성해 보았다.
public interface MyEvent {
void beforeEvent();
void middleEvent();
void afterEvent();
}
먼저 이벤트를 처리하는 용도라 가정된 인터페이스 타입을 정의한다.
@Service
public class MyEventImpl implements MyEvent{
@Override
public void beforeEvent() {
System.out.println("Before Event");
}
@Override
public void middleEvent() {
System.out.println("Middle Event");
}
@Override
public void afterEvent() {
System.out.println("After Event");
}
}
MyEvent 구현체를 간단히 만들었다. 각 메소드에는 비즈니스 로직이 있다고 가정하고, 각각의 메소드의 실행시간을 측정해보겠다.
@Service
public class MyEventImpl implements MyEvent{
@Override
public void beforeEvent() throws InterruptedException {
long before = System.currentTimeMillis();
Thread.sleep(1000);
System.out.println("Before Event");
System.out.println(System.currentTimeMillis() - before);
}
@Override
public void middleEvent() throws InterruptedException {
long before = System.currentTimeMillis();
Thread.sleep(2000);
System.out.println("Middle Event");
System.out.println(System.currentTimeMillis() - before);
}
@Override
public void afterEvent() throws InterruptedException {
long before = System.currentTimeMillis();
Thread.sleep(3000);
System.out.println("After Event");
System.out.println(System.currentTimeMillis() - before);
}
}
측정시간이 매우 적어 Thread.sleep() 을 통해 일부러 실행 시간을 늘려봤다. 작동은 잘 되지만 코드를 보면 똑같은 실행시간 측정 로직이 반복되고 있다. 이를 프록시 패턴을 통해 리펙토링을 해보자.
우선 프록시 패턴을 그림을 통해 살펴보겠다.
클라이언트는 인터페이스 타입 객체를 사용하는 상황이다.
그림의 Real Subject가 실제 기능이 있는 인터페이스의 구현 객체이며, 같은 인터페이스 타입의 Proxy 객체가 Real Subject 객체를 참조하고 있다.
클라이언트는 프록시 객체를 주입받아 사용하게 되며, 프록시 객체는 실제 기능이 담긴 구현 객체를 감싸서 클라이언트의 요청을 처리하게 된다.
이 패턴의 목적은 접근제어, 혹은 부가기능을 위한 목적으로 사용된다.
@Primary
@Service
public class ProxyMyEvent implements MyEvent{
@Autowired MyEvent myEventImpl;
@Override
public void beforeEvent() throws InterruptedException {
long before = System.currentTimeMillis();
myEventImpl.beforeEvent();
System.out.println(System.currentTimeMillis() - before);
}
@Override
public void middleEvent() throws InterruptedException {
long before = System.currentTimeMillis();
myEventImpl.middleEvent();
System.out.println(System.currentTimeMillis() - before);
}
@Override
public void afterEvent() throws InterruptedException {
long before = System.currentTimeMillis();
myEventImpl.afterEvent();
System.out.println(System.currentTimeMillis() - before);
}
}
@Primary 어노테이션을 통해 Proxy 객체가 MyEvent Type으로 클라이언트에서 주입받도록 설정한다. 그리고 Proxy에서 MyEventImpl 구현체를 구현체 이름으로 주입 받고, 기능을 위임(Delegate)하는 방식으로 구현한다.
그 대신 Proxy 객체가 시간을 측정하는 로직을 대신 실행한다.
이를통해 클라이언트 코드를 건드리지 않고 부가기능을 추가할 수 있으며, 핵심 비즈니스 로직과 시간 측정 로직을 서로 분리할 수 있다.
하지만, 아직 문제점이 많다. 중복이 여전히 생긴다는 점과, 이로 인한 Proxy Class를 매번 만드는 비용과 수고를 감수해야 한다. 만약 시간을 측정하고 싶은 인터페이스의 구현체가 많다면 이를 위해 각각의 Proxy 클래스를 만들어 주어야 할 것이다.
이번 포스팅에서는 위와 같은 문제점을 Spring Annotation 기반의 AOP를 통해 해결해 볼 것이다.
그 전에, 기본 개념을 알게 된 상태에서 AOP 용어를 살펴보자.
Aspect
AOP 기술을 통해 흩어진 기능을 모은 모듈
Advice
Aspect 모듈이 실행해야 하는 일
JoinPoint
advice가 실행되어야 하는 지점
보통 생성자 생성 전, 생성 후, 필드에 접근 전, 메소드 실행시점, 등이 JoinPoint가 될 수 있음
다음은 코드 부분이다.
build.gradle 파일에 의존성을 하나 추가해준다.
implementation 'org.springframework.boot:spring-boot-starter-aop'
그 후 코드를 작성해 준다.
@Component
@Aspect
public class SimpleAspect {
@Around("@annotation(MyAnnotation)")
public Object logPerf(ProceedingJoinPoint pjp) throws Throwable{
long before = System.currentTimeMillis();
Object ret = pjp.proceed();
System.out.println(System.currentTimeMillis() - before);
return ret;
}
}
Aspect 모듈임을 알려주기 위한 @Aspect를 명시하고, @Component를 통해 bean으로 등록해준다.
logPerf 메소드의 매개변수인 ProceedingJoinPoint는 advice가 적용되는 대상이다.
그 후 proceed()를 통해 메소드를 실행해 주고, 실행 결과를 리턴해준다.
여기서 advice로 시간 측정 로직을 넣어주었다.
@Around 어노테이션은 메소드를 감싸는 형태로 정의된다.
메소드 호출 이전, 이후에도 advice를 적용할 수 있고, 예외 처리도 할 수 있는 다용도 어노테이션이다.
우리는 @Around 어노테이션에 이 advice를 어디에 적용할 지 JoinPoint를 지정해 줄 수 있다.
여기서 우리는 사용자 정의 어노테이션을 만들어서 @Around 어노테이션에 JoinPoint를 알려주는 방법을 사용해 볼 것이다.
이를 위해 간단한 어노테이션을 만든다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface MyAnnotation {
}
그 후에 시간을 측정하고 싶은 메소드에 방금 만든 어노테이션을 명시해준다.
@Service
public class MyEventImpl implements MyEvent{
@Override
@MyAnnotation
public void beforeEvent() throws InterruptedException {
Thread.sleep(1000);
System.out.println("Before Event");
}
@Override
@MyAnnotation
public void middleEvent() throws InterruptedException {
Thread.sleep(2000);
System.out.println("Middle Event");
}
@Override
public void afterEvent() throws InterruptedException {
Thread.sleep(3000);
System.out.println("After Event");
}
}
어노테이션을 붙이고 실행해보면, 어노테이션을 붙였던 beforeEvent와 middleEvent 메소드에서는 시간측정이 되었지만, afterEvent에서는 시간측정이 안되고 있는 모습을 확인할 수 있다.
@Around 어노테이션 뿐 아니라 @Before 어노테이션을 통해 해당 메소드 시작 전에 AOP를 적용할 수 있으며,
@AfterReturning, @AfterThrowing 도 마찬가지로 적용할 수 있다.
이 Aspect 들은 특정 도메인에 관한 것을 추상화 한다기 보다는, 여러 계층, 여러 도메인에 거쳐서 등장하는 코드들(Cross-cutting concerns)을 Aspect로 만들어서 적용한다는 사실을 기억해야 한다.
지금까지 간단하게 AOP 탄생 배경과, 스프링 AOP 의 간단한 활용 방법을 공부해보았다.
사실 AOP 는 깊게 파고들면 파고들수록 더 수많은 지식과 알아야 할 점들이 존재한다. 공부를 거듭하고 나서 더 많은 지식을 얻게 되면 다른 포스팅에서 내용을 추가해 보도록 하겠다.
좋은 글 감사드립니다!