전통적인 객체지향 기술의 설계 방법(추상화와 메서드 추출 등)으로는 독립적인 모듈화가 불가능한 부가기능을 모듈화 하기 위해 Aspect라는 개념을 도입했다.
Aspect란 그 자체로 애플리케이션의 핵심기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다.
아래의 그림을 살펴보면 Aspect를 통해 부가기능을 분리하는 방식을 살펴볼 수 있다.
왼쪽을 보면 부가기능이 핵심기능의 모듈에 침투해 들어가면서 설계와 코드가 모두 지저분해진 것을 볼 수 있다.
오른쪽과 같이 핵심기능과 부가기능을 Aspect를 통해 분리하여 설계와 개발 시에는 다른 특성을 띈 Aspect들을 독립적인 관점으로 작성할 수 있고, 런타임 시에는 왼쪽과 같이 각 부가기능들이 필요한 위치에 다이내믹하게 참여하게 된다.
이렇게 Aspect를 만들어 설계하고 개발하는 방법을 Aspect 지향 프로그래밍 즉, AOP라고 부른다.
AOP는 기존의 OOP 방식으로는 부가기능의 모듈화가 어렵다는 단점을 극복하기 위해 등장하였다.
매개변수로 이름을 받아 해당 이름에게 인사를 출력하는 핵심기능과 모든 철자를 대문자로 바꾸는 부가기능을 지닌 클래스에서 부가기능을 분리하는 간단한 예제를 통해 AOP에 대해 좀 더 깊게 알아보자. (간단한 예제라 부가기능의 분리의 필요성이 잘 느껴지지 않을 수 있다. 대문자 변환 기능을 트랜잭션 경계설정 기능이나 분산락 적용 기능 등으로 치환하여 생각하면 좀 더 와닿을 것이다.)
매개변수로 이름을 받아 이름 앞에 인삿말을 추가하여 출력하는 간단한 클래스가 있다.
Hello.java
public class Hello {
public String sayHello(String name) {
return "Hello " + name;
}
public String sayHi(String name) {
return "Hi " + name;
}
public String sayThankYou(String name) {
return "Thank You " + name;
}
}
이 클래스의 각 메서드에 리턴값의 모든 철자를 대문자로 바꿔서 출력해야 되는 상황이 발생했다.
아래와 같이 모든 메서드에 toUpperCase()
를 달아서 이를 구현하였다.
대문자 변환 로직이 추가된 Hello.java
public class Hello {
public String sayHello(String name) {
return ("Hello " + name).toUpperCase();
}
public String sayHi(String name) {
return ("Hi " + name).toUpperCase();
}
public String sayThankYou(String name) {
return ("Thank You " + name).toUpperCase();
}
}
해당 코드에는 어떠한 문제점이 존재할까?
Hello 클래스는 단일책임원칙을 위배하고 있다.
Hello 클래스의 모든 메서드는 주 기능인 인삿말을 출력하는 역할과는 무관한 대문자 변환 로직을 품고 있다.
또한 대문자변환 로직의 중복이 발생했다.
이는 해당 로직을 메서드로 추출함으로써 어느정도 해결할 수 있지만 다른 클래스에서 해당 기능을 사용할려면 중복이 또 발생하게 되고, 메서드 추출을 하더라도 부가기능이 여전히 Hello 클래스에 남아있다. (대문자 변환 기능을 트랜잭션 경계설정 기능으로 변환하면 좀더 와닿을 수 있다. 모든 메서드에 트랙잭션 경제설정 로직이 존재한다면 엄청 복잡해질 것이다.)
어떻게 하면 부가기능을 주요기능에서 분리할 수 있을까?
대문자 변환 코드를 Hello 밖으로 빼내게 되면 Hello 클래스를 직접적으로 사용하는 클라이언트 코드(ex. Controller)에서는 대문자 변환 기능이 빠진 Hello를 사용하게 될 것이다. 구체적인 구현 클래스를 직접 참조하는 경우의 전형적인 단점이다.
직접 사용하는 것이 문제라면 간접적으로 사용하면 어떨까?
DI 개념을 활용하여 인터페이스를 도입하여 약한 결합을 갖도록 바꿀 수 있다. 인터페이스의 구현 클래스를 두개로 두어 메인 로직과 부가 기능을 분리하여 보자.
Hello interface
public interface Hello {
String sayHello(String name);
String sayHi(String name);
String sayThankYou(String name);
}
HelloImpl
public class HelloImpl implements Hello {
public String sayHello(String name) {
return "Hello " + name;
}
public String sayHi(String name) {
return "Hi " + name;
}
public String sayThankYou(String name) {
return "Thank You " + name;
}
}
HelloUppercase
@RequiredArgsConstructor
public class HelloUppercase implements Hello {
private final Hello hello;
public String sayHello(String name) {
return hello.sayHello(name).toUpperCase();
}
public String sayHi(String name) {
return hello.sayHi(name).toUpperCase();
}
public String sayThankYou(String name) {
return hello.sayThankYou(name).toUpperCase();
}
}
HelloImpl 클래스는 Hello 인터페이스를 구현한 비즈니스 로직만을 지닌 깔끔한 클래스이다.
대문자 변환로직은 HelloUppercase를 통해 구현하였다. HelloUppercase는 Hello의 비즈니스 로직들은 전혀 갖지 않고 다른 Hello의 구현체에 기능을 위임한다. 이를 위해 Hello 오브젝트를 DI 받을 수 있도록 만든다.
Config.java
@Bean
public Hello hello(HelloImpl helloImpl) {
return new HelloUppercase(helloImpl);
}
위와 같이 빈을 등록하면 클라이언트가 Hello라는 인터페이스를 통해 비즈니스 로직에 부가기능인 대문자 변환 기능을 추가한 작업을 수행할 수 있다.
여기서 HelloUppercase와 같이 자신이 클라이언트가 사용하려하는 실제 대상인 것처럼 위장해서 클라언트의 요청을 받아주는 것을 프록시라고 부른다.
또한 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃이라고 부른다.
프록시는 사용 목적에 따라 두 가지로 구분된다.
디자인 패턴에서는 각 목적에 따라 프록시를 사용하는 다른 패턴으로 구분한다.
앞서 구현한 HelloUppercase와 같은 경우에는 타깃에 대문자 변환이라는 부가적인 기능을 부여하는 데코레이터 패턴을 적용한 것으로 볼 수 있다.
프록시는 기존 코드에 영향을 주지 않으면서 타깃의 기능을 확장하거나 접근 방법을 제어할 수 있는 유용한 방법이다.
하지만 매번 인터페이스를 구현한 프록시를 만드는건 쉽지 않은 일이다.
프록시를 만드는 것이 번거로운 이유는 두 가지가 존재한다.
프록시를 만들라면 매번 부가기능이 필요 없는 메소드도 구현해서 타깃으로 위임하는 코드를 일일이 만드는 번거로움이 존재한다.
또한, 위의 HelloUppercase에서 볼 수 있듯이 여전히 모든 메서드에 toUppercase()를 부여해야 한다는 중복이 발생한다.
다이내믹 프록시를 통해 이러한 번거로움을 줄일 수 있다.
다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트다.
다이내믹 프록시는 자바의 리플랙션 기능을 이용하여 프록시를 만들어준다.
UppercaseHandler
@RequiredArgsConstructor
public class UppercaseHandler implements InvocationHandler {
private final Object target;
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object ret = method.invoke(target, args);
if (ret instanceof String) {
return ((String)ret).toUpperCase();
}
return ret;
}
}
위와 같이 다이내믹 프록시로부터 요청을 전달받으려면 InvocationHandler
를 구현해야 한다.
다이내믹 프록시가 클라이언트로부터 받는 모든 요청은 invoke() 메서드로 전달된다.
invoke()에서 부가기능을 수행하고 리턴된 값은 다이내믹 프록시가 받아서 최종적으로 클라이언트에게 전달된다.
아래와 같이 다이내믹 프록시를 생성할 수 있다.
다이내믹 프록시 생성
Hello proxiedHello = (Hello)Proxy.newProxyInstance(
getClass().getClassLoader(), // 동적으로 생성되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더
new Class[] { Hello.class }, // 구현할 인터페이스
new UppercaseHandler(new HelloImpl())); // 부가기능과 위임 코드를 담은 InvocationHandler
)
Proxy
의 newProxyInstance()
메서드를 통해서만 생성이 가능한 다이내믹 프록시 오브젝트를 빈으로 등록하기 위해서는 팩토리 빈을 사용해야 한다.
UpperProxyFactoryBean.java
@RequiredArgsConstructor
public class UpperProxyFactoryBean implements FactoryBean<Object> {
private final Object target;
private final Class<?> serviceInterface;
public Object getObject() throws Exception {
UpperClassHandler upperClassHandler = new UpperClassHandler(target);
return Proxy.newProxyInstance(
getClass().getClassLoader(), new Class[]{ serviceInterface }, upperClassHandler
);
}
public Class<?> getObjectType() {
return serviceInterface;
}
public boolean isSingleton() {
return false;
}
}
Config.java
@Bean
public Hello hello(HelloImpl helloImpl) throws Exception {
UpperProxyFactoryBean upperProxyFactoryBean = new UpperProxyFactoryBean(helloImpl, Hello.class);
return (Hello) upperProxyFactoryBean.getObject();
}
FactoryBean
을 구현하여 위와 같이 Bean으로 등록하여 다이내믹 프록시를 사용할 수 있다.
프록시 팩토리 빈을 사용하여 앞서 데코레이터 패턴에서 발생한 두 가지 문제점을 해결하였다. 프록시 클래스를 일일이 만들어야 하는 번거로움이 줄어들었고, 부가적인 기능이 여러 메서드에 반복적으로 나타나는 코드 중복 문제를 해결하였다.
하지만 프록시 팩토리 빈에는 여전히 몇 가지 문제점이 존재한다.
UppercaseHandler
가 팩토리 빈 개수만큼 만들어진다.스프링은 이러한 문제를 스프링의 ProxyFactoryBean
를 통해 해결한다.
스프링의 ProxyFactoryBean
은 앞서 만든 UpperProxyFactoryBean
과는 다르게 순수하게 프록시를 생성하는 작업만을 담당하고 프록시를 통해 제공해줄 부가기능은 별도에 빈에 둘 수 있다.
UppercaseAdvice.java
@Component
public class UppercaseAdvice implements MethodInterceptor {
public Object invoke(MethodInvocation invocation) throws Throwable {
String ret = (String)invocation.proceed();
return ret.toUpperCase();
}
}
Config.java
@Bean
public NameMatchMethodPointcut uppercasePointcut() {
NameMatchMethodPointcut uppercasePointcut = new NameMatchMethodPointcut();
uppercasePointcut.setMappedName("sayH*");
return uppercasePointcut;
}
@Bean
public DefaultPointcutAdvisor uppercaseAdvisor() {
return new DefaultPointcutAdvisor(uppercasePointcut(), uppercaseAdvice);
}
@Bean
public Hello hello(HelloImpl helloImpl) {
ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
proxyFactoryBean.setBeanFactory(beanFactory);
proxyFactoryBean.setTarget(helloImpl);
proxyFactoryBean.setInterceptorNames("uppercaseAdvisor");
return (Hello) proxyFactoryBean.getObject();
}
Advice: 타깃이 필요 없는 순수한 부가기능
Pointcut: 부가기능 적용 대상 메서드 선정 방법
Advisor: Pointcut + Advice
스프링의 ProxyFactoryBean
은 DI와 템플릿/콜백 패턴, 서비스 추상화 등의 기법이 모두 적용되어 독립적이며, 여러 프록시가 공유할 수 있는 Advice와 Pointcut으로 확장 기능을 분리할 수 있다.
이제 대문자변환 기능이 필요한 새로운 클래스가 생기더라도 이미 만들어둔 Advice와 Pointcut을 재활용하여 쉽게 구현할 수 있게 되었다.
스프링의 ProxyFactoryBean
을 도입하여 대부분의 중복을 줄일 수 있었지만 아직 한 가지 문제점이 남아있다.
새로운 타깃이 등장하게 되면 매번 ProxyFactoryBean
빈 설정정보를 추가해줘야 한다.
이는 빈 후처리기를 이용한 자동 프록시 생성기를 통해 해결할 수 있다.
빈 후처리기는 스프링 빈 오브젝트로 만들어지고 난 후에, 빈 오브젝트를 다시 가공할 수 있게 해준다.
빈 후처리기 중 하나인 DefaultAdvisorAutoProxyCreator
는 Advisor를 이용한 자동 프록시 생성기이다.
DefaultAdvisorAutoProxyCreator
빈 후처리기가 등록되어 있으면 스프링은 빈 오브젝트를 만들 때마다 후처리기에 빈을 보낸다. DefaultAdvisorAutoProxyCreator
는 등록된 모든 Advisor 내의 Pointcut을 이용해 전달받은 빈이 프록시 적용 대상인지 확인하고, 내장된 프록시 생성기를 통해 해당 빈에 대한 프록시를 생성한다. 컨테이너는 최종적으로 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용한다.
아래의 예시를 통해 자동 프록시 생성을 구현하여 보자.
NameMatchClassMethodPointcut.java
public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {
public void setMappedClassName(String mappedClassName) {
this.setClassFilter(new SimpleClassFilter(mappedClassName));
}
@RequiredArgsConstructor
static class SimpleClassFilter implements ClassFilter {
private final String mappedName;
public boolean matches(Class<?> clazz) {
return PatternMatchUtils.simpleMatch(mappedName, clazz.getSimpleName());
}
}
}
앞서 사용한 NameMatchClassMethodPointcut
은 클래스 필터 기능이 존재하지 않는다. (모든 클래스를 하용하는 기본 필터만 존재) NameMatchClassMethodPointcut
확장하여 클래스 필터 설정 메서드를 추가할 수 있다.
Config.java
@Bean
public NameMatchClassMethodPointcut uppercasePointcut() {
NameMatchClassMethodPointcut nameMatchClassMethodPointcut = new NameMatchClassMethodPointcut();
nameMatchClassMethodPointcut.setMappedClassName("*Impl");
nameMatchClassMethodPointcut.setMappedName("sayH*");
return nameMatchClassMethodPointcut;
}
@Bean
public DefaultPointcutAdvisor uppercaseAdvisor() {
return new DefaultPointcutAdvisor(uppercasePointcut(), uppercaseAdvice);
}
@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
return new DefaultAdvisorAutoProxyCreator();
}
Advisor와 Advice는 수정할 것이 없다.
이제 DefaultAdvisorAutoProxyCreator
에 의해 자동으로 Advisor가 수집되고 프록시가 만들어져 빈으로 등록된다.
관심사가 같은 코드를 분리하여 모으는 것은 소프트웨어 개발의 가장 기본이 되는 원칙이다.
코드를 분리하고, 모으고, 인터페이스를 도입하고, DI를 통해 런타임 시에 의존관계를 만들어줌으로써 대부분의 문제를 해결할 수 있다.
하지만 트랜잭션이나 분산락, 로깅 등과 같은 부가기능은 핵심기능과는 같은 방식으로 모듈화하기가 매우 힘들다. 부가기능은 말 그대로 부가기능이기에 각 기능을 부가할 대상인 타깃의 코드 안에 침투하거나 긴밀하게 연결되어 있다.
이러한 부가기능을 독립적인 모듈로 만들기 위해 AOP라는 개념이 등장하였다. 앞서 살펴본 DI, 데코레이터 패턴, 다이내믹 프록시, 오브젝트 생성 후처리, 자동 프록시 생성과 같은 기법이 이러한 문제를 해결하기 위한 대표적인 방법이다. 덕분에 부가기능은 Advice로 모듈화할 수 있었다.
Advice는 독립적으로 모듈화되어 있기에 중복되지 않으며, 변경이 필요한 부분만 수정하면 된다. 또한 Pointcut을 통해 부가기능을 부여할 대상을 선정할 수 있었다.
앞서 만든 Advice와 Pointcut을 결합한 Advisor를 통해 부가기능을 분리한 원시적인 형태의 모듈을 만들 수 있었다.
이렇게 만든 모듈을 객체지향 기슬에서 사용하는 오브젝트와는 다르게 특별한 이름인 Aspect라고 부른다.
앞서 진행한 작업처럼 애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 Aspect라는 모듈로 만들어 설계하고 개발하는 방법을 Aspect Oriented Programming 즉, AOP라고 부른다.
Spring AOP에서는 두 가지 방식으로 proxy 객체를 생성한다.
JDK Porxy는 앞서 설명한 InvocationHandler를 사용한 방식으로 타깃의 인터페이스를 기반으로 리플랙션을 활용하여 Proxy 객체를 생성한다.
JDK Dynamic Proxy가 Proxy 객체를 생성해주는 과정은 다음과 같다.
JDK Dynamic Proxy 방식은 다음과 같은 단점이 있다.
CGLib은 Code Generator Library의 약자로, 클래스의 바이트코드를 조작하여 Proxy 객체를 생성해주는 라이브러리다.
Spring은 CGLib를 통해 인터페이스 없이도 Proxy를 생성할 수 있는 방식을 제공한다.
CGLib는 다음과 같이 타깃의 클래스를 상속받아 Proxy를 생성한다.
CGlib은 바이트 코드로 조작하여 Proxy를 생성해주기 때문에 성능에 대한 부분이 JDK Dynamic Proxy보다 좋다.
CGLib 방식에는 다음과 같은 한계가 존재한다.
하지만 Spring 3.2 버전 부터는 Objensis 라이브러리의 도움을 받아 default 생성자 없이도 Proxy를 생성할 수 있게 되었고, 생성자가 2 번 호출되던 상황도 같이 개선이 되어 Spring boot에는 CGLib 방식을 기본 방식으로 채택하여 사용하고 있다.
실제 스프링에서 AOP를 활용하여 개발하는 방식은 어노테이션을 활용하여 앞서 진행한 방식보다 훨씬 간단하다.
대문자변환 기능을 AOP를 통해 구현하여 보자.
UppercaseAspect
@Aspect
@Component
public class UppercaseAspect {
@Around("execution(* *..*Impl.sayH*(..))")
public Object convertToUppercase(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
if (result instanceof String strResult) {
return strResult.toUpperCase();
}
return result;
}
}
@Around를 메서드에 붙여 통해 Target의 앞, 뒤에 공통관심부분의 코드 (Advice)를 작성할 수 있다.
메서드에서 Target 메서드가 실행될 지점에 joinPoint.proceed()를 호출해주면 된다.
즉, @Aspect가 달린 클래스를 하나의 Advisor로 볼 수 있다.
@Around만 사용해도 모든게 가능하지만, 필요에 따라 범위를 제한해둘 수 있다.
토비의 스프링 3.1