스프링은 OCP의 중요한 요소인 유연한 확장 이라는 개념을 스프링 컨테이너 자신에게도 다양한 방법으로 적용한다. 컨테이너로서 제공하는 기능 중 변하지 않는 핵심적인 부분 외에는 대부분 확장할 수 있도록 확장 포인트를 제공한다.
BeanPostProcessor 인터페이스를 활용하면, 이를 구현한 빈 후처리기는 이름 그대로 스프링 빈 객체가 만들어지고 난 후 해당 빈 객체를 다시 한번 가공할 수 있다. 빈 후처리기가 빈으로 등록되어 있으면, 빈 객체가 생성될 때마다 빈 후처리기에 보내어 후처리 작업을 요청한다.
자동으로 프록시를 생성하기 위해 이 빈 후처리기를 통해 구현할 수 있다. 빈 후처리기들 중에서 자동으로 프록시를 생성하기 위해 사용되는 클래스는 DefaultAdvisorAutoProxyCreator 이다. 이 클래스는 어드바이저를 이용한 자동 프록시 생성기 이다. 빈 객체를 프록시로 포장하고, 프록시를 빈으로 대신 등록시킨다.
DefaultAdvisorAutoProxyCreator 빈 후처리가 등록되어 있다면, 스프링은 빈 객체를 만들 때마다 후처리기에게 빈을 보낸다.
이 후처리기를 통해 일일이 ProxyFactoryBean을 빈으로 등록할 필요없이 여러 타깃 객체에 자동으로 프록시를 적용시킬 수 있다.
포인트컷은 어떤 메소드에 부가기능을 적용할지 결정해주는 역할 뿐만 아니라, 등록된 빈 중에서 어떤 빈에 프록시를 적용할지를 결정할 수도 있다.
public interface Pointcut {
ClassFilter getClassFilter(); // 프록시를 적용할 클래스인지 확인.
MethodMatcher getMethodMatcher(); // 어드바이스를 적용할 메소드인지 확인.
}
위와 같이 포인트컷을 통해 프록시를 적용할 클래스인지 판단하고 나서, 적용 대상일 경우 어드바이스를 적용할 메소드인지 확인할 수 있다. DefaultAdvisorAutoProxyCreator 빈 후처리기를 사용하기 위해 클래스와 메소드를 모두 선정하는 포인트컷이 필요하다.
Pointcut test with ClassFilter
포인트컷이 클래스 필터링을 통해 클래스를 걸러버리면, 부가기능이 전혀 적용되지 않는다.
DefaultAdvisorAutoProxyCreator 의 동작 방식
DefaultAdvisorAutoProxyCreator를 사용하기 위해 다음과 같이 애플리케이션 컨텍스트 xml에 추가한다.
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator" />
Transaction code using auto proxy
스프링에서는 클래스 필터나 메소드 매처를 사용하는 것이 아닌, 정규식과 비슷하게 포인트컷이 클래스와 메소드를 선정할 수 있도록 포인트컷 표현식이라는 방법을 사용할 수 있다.
포인트컷 표현식을 사용하기 위해서는 AspectJExpressionPointcut 클래스를 사용한다. 이 클래스를 통해 포인트컷 표현식을 사용해서 클래스와 메소드 선정 방식을 한 번에 지정할 수 있다.
스프링이 사용하는 포인트컷 표현식은 AspectJ 프레임워크에서 사용하는 문법을 확장해서 사용한다.
포인트컷 표현식 문법
AspectJ 포인트컷 표현식은 포인트컷 지시자를 이용해 작성한다. 포인트컷 지시자 중 execution() 에 대한 문법구조는 다음과 같다.
execution([접근제한자 패턴] 리턴값의 타입패턴 [클래스 타입패턴.]메소드 이름패턴 (파라미터 타입패턴 | "..", ...) throws 예외패턴 )
패턴 요소를 생략하면 모든 경우를 다 허용하도록 되어 있다.
Pointcut execution expression test
포인트컷 표현식은 execution() 말고도, bean() 이 있다. bean(*Service) 라고 지정하면 빈 아이디가 Service로 끝나는 모든 빈을 선택한다.
AspectJExpressionPointcut 직접 사용시, 포인트컷 표현식을 다음과 같이 메소드 시그니처를 execution() 안에 넣어 expression 프로퍼티에 설정한다.
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* *(..))");
스프링 빈으로 등록하여 사용할 때는 다음과 같이 작성한다.
<bean id="transactionPointcut" class="org.springframework.aop.aspectj.AspectJExpressionPointcut">
<property name="expression" value="execution(* *..*ServiceImpl.upgrade*(..))" />
</bean>
위의 xml과 같이 expression을 설정하면 패키지에 상관없이 ServiceImpl 로 끝나는 클래스의 upgrade로 시작하는 모든 메소드에 적용될 것이다.
포인트컷 표현식은 클래스 및 메소드를 선정하는 로직이 짧은 문자열에 담기기 때문에 코드와 설정이 단순해진다. 그러나 문자열로 된 표현식이므로 런타임 시점까지 문법의 검증이나 기능 확인이 되지 않는다는 단점이 있다.
포인트컷 표현식을 이용하는 포인트컷이 정확히 원하는 빈, 메소드만 선정했는지 확인하는 것은 어렵다. 스프링 개발팀에서 제공하는 스프링 지원 툴을 사용하면 아주 간단하게 포인트컷이 선정한 빈, 메소드가 무엇인지 한 눈에 알 수 있다.
포인트컷 타입 패턴
포인트컷 표현식의 클래스 이름에 적용되는 패턴은 클래스 이름에 대한 매칭이 아니라, 타입 패턴이다.
public interface UserService {
void add(User user);
void upgradeLevels();
}
public class UserServiceImpl implements UserService {
...
}
public class TestUserService implements userServiceImpl {
...
}
위와 같이 클래스가 정의되어 있을 때, 포인트컷 표현식을 execution(* *..*ServiceImpl.upgrade*(..)) 과 같이 설정하면 TestUserService 까지 매칭된다. 이는 포인트컷 표현식에서 클래스 패턴은 타입에 대한 패턴이기 때문이다.
마찬가지로 인터페이스를 지정하면 그 인터페이스를 구현하는 모든 클래스에 대해 매칭될 것이다. execution(* *..UserService.*(..)) 같이 지정하면 UserService 인터페이스를 구현하는 모든 클래스에 대해 적용된다.
트랜잭션 경계 설정 코드와 같은 부가기능을 다른 비즈니스 로직과는 다르게 일반적인 객체지향적인 설계 방법으로는 독립적인 모듈화가 불가능하였다. 다이내믹 프록시라든가 빈 후처리기와 같은 복잡한 기술까지 동원하였기 때문이다.
이런 부가기능에 대한 모듈화 작업을 진행할 때 객체지향 설계 패러다임과는 다른 새로운 특성을 가지고 있어, 모듈화된 부가기능을 오브젝트라고 부르지 않고 애스팩트(Aspect) 라고 부른다.
애스팩트는 그 자체로 애플리케이션의 핵심 기능을 담고 있지 않지만, 애플리케이션을 구성하는 중요 요소이고 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 말한다. 애스팩트는 부가기능을 정의한 어드바이스 와 어드바이스를 어디에 적용할지를 결정하는 포인트컷 을 함께 갖고 있다.
애스팩트를 통해 분리하기 전의 코드는 부가기능과 비즈니스 로직이 한 군데 모여 있어 설계와 코드가 모두 지저분하였다. 또한 부가기능은 여러 군데에 나타나 중복될 확률도 높고, 부가기능을 추가할 때마다 여러 곳에서 코드를 수정해야 한다.
그러나 애스팩트를 통한 부가기능의 모듈화로 부가기능에 대한 코드 중복을 피할 수가 있었고 핵심기능은 그 기능을 담은 코드로만 존재할 수 있었다. 설계와 개발시에는 부가기능을 담은 애스팩트와 핵심기능을 담은 비즈니스 로직을 분리된, 독립적인 관점으로 작성할 수 있게 된 것이다.
애플리케이션의 핵심기능에서 부가기능을 분리해서 애스팩트라는 독특한 모듈로 만들어서 설계하고 개발하는 방법을 애스팩트 지향 프로그래밍(Aspect Oriented Programming), 관점 지향 프로그래밍이라고 부른다.
AOP는 객체지향프로그래밍인 OOP를 대체하는 기술은 아니고 OOP를 돕는 보조적인 기술이다. AOP를 통해 애스팩트를 분리하고 애플리케이션 핵심기능에 대해서는 OOP의 가치를 보존할 수 있도록 하는 것이다.
또한 AOP는 애플리케이션을 다양한 측면에서 독립적으로 모델링하고, 설계하고, 개발할 수 있도록 만들어준다. 애스팩트를 분리하고 개발할 수 있게 됨으로써 개발자로 하여금, 애플리케이션을 핵심기능을 담당하는 프로그램이 아닌 부가기능을 설정하는 프로그램이라는 관점에서 바라보고 개발할 수 있다는 점에서 AOP를 관점 지향 프로그래밍 이라고도 하는 것이다.
스프링은 IoC/DI 컨테이너와 다이내믹 프록시, 데코레이터 패턴, 프록시 패턴, 자동 프록시 생성 기법, 빈 객체의 후처리 조작 등을 통해 AOP를 지원한다. 그중 가장 핵심적인 것은 프록시 이다.
프록시를 가지고 DI로 연결된 빈들 사이에 적용해 타깃의 메소드 호출 과정에 끼어들어 부가기능을 제공하도록 만들었다.
스프링의 AOP는 프록시 방식의 AOP 이다.
AOP 기술의 원조이자, 가장 강력한 AOP 프레임워크인 AspectJ는 프록시 방식을 사용하지 않고, 타깃 객체를 아예 뜯어고쳐 부가기능을 직접 넣어주는 방식을 사용한다. 컴파일된 타깃의 클래스 파일 자체를 수정하거나 클래스가 JVM 상에 로딩될 때 바이트코드를 조작하는 복잡한 방법을 사용한다.
이렇게 직접적인 방식을 사용함으로써 스프링 AOP와 같이 자동 프록시 생성 방식을 사용하지 않아도 AOP를 적용할 수 있다. 그리고 프록시 방식보다 강력하고 유연한 AOP가 가능해진다.
프록시 방식의 AOP는 부가기능을 부여할 대상이 타깃의 메소드만으로 한정되지만, 바이트코드 조작을 통한 AOP에서는 오브젝트의 생성, 필드 값 조회와 수정, static 초기화 등의 다양한 작업에 부가기능을 부여할 수 있다.
대부분의 부가기능은 프록시 방식을 통해 메소드 호출 시점에 적용하는 것만으로 충분하나, 특별한 AOP 요구사항이 있어 스프링의 프록시 AOP 수준을 넘어서는 기능이 필요하다면 그때 AspectJ를 사용하면 된다.
스프링 AOP를 적용하기 위해 추가했던 어드바이저, 포인트컷, 자동 프록시 생성기와 같은 빈들은 다른 애플리케이션 로직을 담은 빈들과는 성격이 다르다. 이런 빈들은 스프링 컨테이너에 의해 자동으로 인식되어 특별한 작업을 위해 사용된다.
스프링의 프록시 방식 AOP를 적용하려면 최소한 네 가지 빈을 등록해야 한다.
스프링에서는 AOP와 관련된 태그를 정의해둔 aop 스키마 를 통해 간편하게 빈으로 등록할 수 있다. aop 스키마에 정의된 태그를 사용하려면 설정파일에 다음과 같이 추가한다.
<beans ...
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="...
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop-4.3.xsd">
위와 같이 네임스페이스 선언을 추가해준 후, aop 네임스페이스를 통해 기존 AOP 관련 빈 설정을 다음과 같이 변경할 수 있다.
<!--
<bean id="transactionPointcut" class="org.springframework.aop.aspectj.AspectJExpressionPointcut">
<property name="expression" value="execution(* *..*ServiceImpl.upgrade*(..))" />
</bean>
<bean id="transactionAdvisor" class="org.springframework.aop.support.DefaultPointcutAdvisor">
<property name="advice" ref="transactionAdvice"/>
<property name="pointcut" ref="transactionPointcut"/>
</bean>
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator" />
-->
<!-- AOP 설정을 담는 부모태그로, 필요에 따라 AspectJAdvisorAutoProxyCreator를 빈으로 등록해준다. -->
<aop:config>
<!-- expression 표현식을 프로퍼티로 가진 AspectJExpressionPointcut을 빈으로 등록해준다. -->
<aop:pointcut id="transactionPointcut" expression="execution(* *..*ServiceImpl.upgrade*(..))" />
<!-- advice와 pointcut의 ref를 프로퍼티로 갖는 DefaultBeanFactoryPointcutAdvisor를 빈으로 등록해준다. -->
<aop:advisor advice-ref="transactionAdvice" pointcut-ref="transactionPointcut" />
</aop:config>
위의 주석처리된 포인트컷 및 어드바이저를 aop 태그를 통해 쉽게 AOP를 적용할 수 있다. 직접 구현한 클래스로 등록하는 어드바이스를 제외한 AOP 관련 빈들은 의미를 잘 드러내는 독립된 전용 태그를 사용하도록 권장된다.
애플리케이션을 구성하는 컴포넌트 빈과 컨테이너에 의해 사용되는 기반 기능을 지원하는 빈은 구분이 되는 것이 좋다.
AOP는 모듈화된 부가기능과 포인트 컷의 조합을 통해 여러 객체에 산재해서 나타나는 공통적인 기능을 손쉽게 개발하고 관리하는 기술이다.
스프링은 자바 JDK에서 지원하는 다이내믹 프록시 기술을 통해 프록시 기반의 AOP 개발 기능을 제공한다.
프록시 방식의 AOP는 데코레이터 / 프록시 패턴을 응용해서, 기존 코드에 영향을 주지 않은 채로 부가기능을 타킷 객체에 제공하는 객체지향 프로그래밍 모델로부터 출발한다.
스프링 AOP를 사용한다면 어떤 개발 방식을 사용하든 모두 프록시 방식의 AOP이다. 프록시 개념은 데코레이터 패턴에서 나온 것이고, 동작 원리는 JDK 다이내믹 프록시와 DI를 이용한다.
public class Client {
@Autowired
private Target target;
}
public class Target {
...
}
위와 같이 두 개의 클래스가 있고, Target 클래스에 AOP를 통해 부가기능을 제공할려고 할 때 문제가 있다. Client 클래스가 바로 Target 클래스에 의존하기 때문에, 이 의존관계를 DI 컨테이너와 설정을 통해 바꿀 수 없기 때문이다.
따라서 Client 클래스는 Target 클래스에 직접 의존하는 대신 인터페이스를 통하여 느슨하게 연결하도록 해야 한다.
public class Client {
@Autowired
private Interface intf;
}
interface Interface {
...
}
public class Target implements Interface {
...
}
위와 같이 코드를 정의하면 인터페이스를 통해 느슨하게 연결하였으므로, DI 설정을 조작하여 중간에 프록시 객체가 끼어들 수 있다. 하지만 여전히 문제가 있다.
Client 클래스는 @Autowired를 통한 자동 와이어링을 사용하는데, 프록시를 추가하면 같은 Interface를 구현한 빈이 2개가 된다. 따라서 이 때 @Autowired를 통한 자동 빈 선택이 불가능해진다.
스프링은 이를 위해 자동 프록시 생성기를 이용해 컨테이너 초기화 중에 만들어진 빈을 바꿔치기 해 프록시 객체를 빈으로 등록한다. 자동 프록시 생성기는 프록시 빈을 별도로 추가하는 것이 아니라, 타겟 객체를 아예 자신이 포장하여 마치 그 빈처럼 동작하는 프록시 객체로 대체시키는 것이다.
위의 그림과 같이 자동 프록시 생성기를 통해 프록시를 생성하면 타겟 객체가 빈으로 노출되지 않아 자동 와이어링을 진행할 수 있다.
하지만 다른 클라이언트인 Client2가 다음과 같이 코드를 정의해놓았었다면 또 다른 문제가 발생한다.
public class Client2 {
@Autowired
private Target target;
}
자동 프록시 생성기를 적용하기 전에는 문제가 없겠지만, AOP를 적용 후에는 타겟 객체가 프록시 빈 안으로 숨겨짐으로써 문제가 발생하는 것이다. Client2 입장에서는 Target 클래스 객체를 찾을 수 없다. 자동 프록시 생성기가 등록하는 프록시 빈은 Target 클래스가 구현하던 Interface를 구현하는 다른 타입일 뿐이다.
자동 프록시 생성기의 기본 옵션은 프록시를 적용할 타겟이 구현한 모든 인터페이스를 프록시 빈도 구현하게 해주는 것이다. 프록시 빈의 클래스는 타겟 클래스와 같은 인터페이스를 구현할 뿐 다른 타입이다.
JDK 다이내믹 프록시 방식을 활용한 AOP 적용시 주의사항이 있다. 애너테이션을 통해 AOP를 적용할 때, 클래스 뿐만 아니라 인터페이스에도 애너테이션을 붙여야 한다. 만약 인터페이스가 아닌 클래스에만 붙이면 다음과 같은 문제가 발생할 수 있다.
public interface AopService {
void childOnly();
@AopTest
void parentChildBoth();
}
@Service
public class AopServiceImpl implements AopService {
@Override
@AopTest
public void childOnly() {
}
@Override
@AopTest
public void parentChildBoth() {
}
}
@Getter
@Setter
@Aspect
@Component
public class AopObject {
@Pointcut("execution(public * ((@AopTest *)+).*(..)) && within(@AopTest *)")
private void executionOfAnyPublicMethodInAtAopTestType() {
}
@Pointcut("execution(@AopTest * *(..))")
private void executionOfAopTestMethod() {
}
@Before("executionOfAnyPublicMethodInAtAopTestType() || executionOfAopTestMethod()")
public void beforeWoved(JoinPoint joinPoint) {
Method method = ((MethodSignature)joinPoint.getSignature()).getMethod();
value.incrementAndGet();
// 클래스에만 애너테이션을 달았을 경우, AopTest 애너테이션 객체를 얻을 수 없다!
AopTest aopTestAnnotationOnMethod = method.getAnnotation(AopTest.class);
if (aopTestAnnotationOnMethod == null) {
foundAnnotationOnMethod = false;
}
}
private AtomicInteger value = new AtomicInteger();
private boolean foundAnnotationOnMethod = true;
public void reset() {
foundAnnotationOnMethod = true;
value.set(0);
}
}
// Spring Boot 2.0부터 디폴트 값이 true로, CGLib을 활용한 클래스 프록시 방식이 디폴트이다.
@SpringBootTest(properties = {
"spring.aop.proxy-target-class=false"
})
@RunWith(SpringRunner.class)
public class AopServiceWithJdkProxyTest {
@Autowired
private AopObject aopObject;
@Autowired
private AopService aopService;
// JDK 다이내믹 프록시 방식이므로, 해당 빈을 찾을 수 없다.
@Autowired(required = false)
private AopServiceImpl aopServiceImpl;
@Before
public void beforeTest() {
aopObject.reset();
}
@Test
public void springDiTest() {
assertThat(aopService).isNotNull();
assertThat(aopServiceImpl).isNull(); // JDK 다이내믹 프록시 방식이므로, 해당 빈을 찾을 수 없다.
assertThat(AopUtils.isJdkDynamicProxy(aopService)).isTrue();
}
@Test
public void childOnlyTest() {
given: {
}
when: {
aopService.childOnly();
}
then: {
assertThat(aopObject.getValue().get()).isEqualTo(1);
// AopTest 애너테이션을 얻을 수 없다.
assertThat(aopObject.isFoundAnnotationOnMethod()).isFalse();
}
}
@Test
public void parentChildBothTest() {
given: {
}
when: {
aopService.parentChildBoth();
}
then: {
assertThat(aopObject.getValue().get()).isEqualTo(1);
// 인터페이스, 클래스 모두 애너테이션을 달면 얻을 수 있다.
assertThat(aopObject.isFoundAnnotationOnMethod()).isTrue();
}
}
}
클래스에만 붙이더라도 @Before 어드바이스가 적용되기는 하지만, 어드바이스 내에서 @AopTest 애너테이션 정보를 얻을 수 없다. 이를 해결하기 위해서는 AOP를 적용할 클래스 뿐만 아니라 해당 클래스가 구현하는 인터페이스 쪽에도 애너테이션을 달아야 한다.
클래스 프록시 방식을 사용할 경우에는 해당 문제가 없다.
원래 스프링이 제공하는 AOP 프록시는 인터페이스를 구현한 프록시이다. JDK 다이내믹 프록시가 인터페이스를 사용하고, 프록시의 원리인 데코레이터 패턴과 DI도 인터페이스를 통한 의존관계를 사용하기 때문이다.
하지만 다음과 같이 인터페이스가 아닌 클래스를 직접 참조하는 경우에도 프록시를 적용시킬 수도 있다.
public class Client {
@Autowired
private Target target;
}
public class Target {
...
}
타겟 클래스 자체를 인터페이스처럼 사용하는 것이다. 클라이언트가 타겟 클래스에 의존하는 것은 변함이 없지만, 프록시를 구현할 때는 이 타겟 클래스를 상속받는 서브 클래스를 만드는 것이다. 서브 클래스를 만들고 상속받은 모든 public 메소드를 오버라이드한다. 서브 클래스이므로 클라이언트에 프록시 빈을 주입할 수 있다.
이 방식에는 다음과 같은 제약 사항이 있다.
스프링은 이 클래스를 이용한 프록시를 제공하는 것은 어디까지나 레거시 코드나 인터페이스가 없는 외부 라이브러리 클래스에 AOP를 적용할 수 있도록 만들어준 것일 뿐, 평소에 타겟 클래스를 직접 DI받아 사용하다가 AOP가 필요해지면 이를 이용하라는 것이 아니다.
Spring Framework 4 부터 위의 제약 사항이 해결되었다. 이제 CGLib 라이브러리를 활용한 클래스 프록시 방식을 사용하더라도 objenesis 라이브러리를 통해 프록시 객체를 생성하여, 생성자가 두 번 호출되지 않는다. 또한 public 메소드를 대상으로 모두 프록시 대상으로 삼는 것이 아닌, 메서드 하나 하나 따로 AOP 설정이 가능하다. (final 클래스 및 final 메서드는 여전히 AOP 적용이 되지 않는다.)
스프링은 타깃 객체가 인터페이스를 구현하고 있다면 인터페이스를 사용하는 JDK 다이내믹 프록시를 통해 프록시를 만들지만, 인터페이스가 없다면 CGLib을 통한 클래스 프록시를 만든다.
만약 인터페이스 유무에 상관없이 강제로 클래스 프록시를 사용하려면 다음과 같이 설정한다.
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass=true)
public class AppConfig {
// ...
}
Spring Boot 2.0부터 클래스 프록시 방식이 디폴트이다. (spring.aop.proxy-target-class 기본 값이 true이다.) CGLib을 활용한 이 클래스 프록시 방식은 바이트 코드 조작을 통한 일종의 캐싱 방식으로 JDK 다이내믹 프록시 방식에 비해 성능 상의 이점도 존재한다. What is the difference between JDK dynamic proxy and CGLib?
참고: Spring Manual 5.8 Proxying Mechanisms