Proxy 는 사전적인 의미로 “대리인"이라는 뜻입니다. java 에서 프록시란 대리를 수행하는 클래스를 의미합니다. Proxy 는 Client 가 사용하려고 하는 실제 대상인 것 처럼 위장을 해서 클라이언트의 요청을 받아줍니다. 여기서 위장이란 "다형성"을 의미합니다.
Proxy 를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 target 또는 real subject(실체)라 부릅니다.
Proxy 는 실제로 타겟이 담당하는 역할 요청을 대신받아서 요청 이전, 이후에 대한 로직을 추가할 수 있는 객체입니다. 이렇게 하면 실제 타겟의 코드는 수정하지 않으면서 기능적인 추가를 할 수 있다는 장점이 있습니다.
proxy 를 구현하기 위해서는 인터페이스를 이용한 방법과 상속을 이용한 방법이 있습니다.
(2번이 말이 조금 어려운데, 저는 "접근 가능한 package 위치에 있다는 의미"로 이해를 했습니다. 예를 들어 접근제어자가 default 인 메소드를 제어하고 싶으면 같은 패키지에 존재해야 한다의 의미정도로 생각합니다. 제 이해가 틀렸다면 지적해주시면 감사하겠습니다 😃 )
서비스를 실행하는 시간을 구하는 로깅 Proxy 를 구현해보려고 합니다.
public interface OrderService {
void orderGoods(User buyer, List<Goods> goods);
}
Proxy 객체와 Target 이 공유할 수 있는 인터페이스 입니다.
public class OrderServiceImpl implements OrderService {
private List<Order> orderRepository = new ArrayList<>();
@Override
public void orderGoods(User buyer, List<Goods> goods) {
...
}
}
OrderServiceImpl 는 OrderService 인터페이스를 구현합니다. Proxy 의 Target 에 해당하는 클래스입니다.
public class LoggingProxy implements OrderService {
private OrderService orderService;
public LoggingProxy(OrderService orderService) {
this.orderService = orderService;
}
@Override
public void orderGoods(User buyer, List<Goods> goods) {
long startTime = System.nanoTime(); // 시간측정 start
orderService.orderGoods(buyer, goods); // 본래 클라이언트가 요청했던 동작
long endTime = System.nanoTime(); // 시간측정 end
System.out.println((endTime - startTime) + " nanoseconds");
}
}
LoggingProxy 클래스가 Target 클래스인 OrderServiceImpl 과 동일한 OrderService 인터페이스를 implements 했기 때문에 클라이언트 입장에서 Proxy 객체를 OrderService 변수로 참조할 수 있게 되었습니다. 예를 들면 아래와 같습니다.
private OrderService orderService = new LoggingProxy();
(LoggingProxy 객체는 클라이언트가 직접 생성하지 않습니다.)
이렇게 함으로써 OrderService 가 제공하는 메서드를 호출할 수 있습니다. 마치 OrderService 가 동작하는 것 처럼 보입니다.
이제 이 코드를 실행시켜보려고 합니다.
public static void main(String[] args) {
OrderService orderService = new OrderServiceImpl();
LoggingProxy proxy = new LoggingProxy(orderService);
User buyer = new User();
buyer.setUsername("주문지");
buyer.setUserId(1);
buyer.setAddress("구로");
Goods goods = new Goods();
goods.setName("치킨");
goods.setPrice(10000d);
goods.setGoodsId(1);
proxy.orderGoods(buyer, Arrays.asList(goods));
}
Runner 는 클라이언트 코드를 의미합니다. 스프링을 사용해보신 분들은 아시겠지만 우리가 직접 orderService 생성하지 않습니다.
스프링 프레임워크에서 클라이언트는 아래과 같이 코드를 작성했을 것입니다.
@Controller
public class SpringController {
@Autowired
private OrderService orderService;
@RequestMapping(value = "order", method = RequestMethod.POST)
public void order(int userId, List<Integer> goodsId) {
// ... id 로 객체를 find 한다
orderService.orderGoods(buyer, goods);
}
}
OrderService 인스턴스는 스프링을 프레임 워크를 통해서 주입받았을 것입니다. 따라서 스프링이 언제든 OrderService 인스턴스 대신에 LoggingProxy 인스턴스를 넘겨줄 수 있다는 것이 핵심입니다.
그리고 클라이언트는 프록시가 아닌 OrderService 인스턴스가 orderService 변수에 DI 되었을 것이라고 가정하고 코드를 작성합니다.
AOP 는 Aspect Oriented Programming 의 약자로 관점 지향 프로그래밍이라고 불립니다. 즉, 로직을 기준으로 관점 별로 로직을 묶어서 모듈화 할 수 있도록 돕겠다는 취지에서 나온 프로그래밍 기법이라고 할 수 있습니다.
예를 들어서 보안, 로깅, 데이터베이스 연결, 캐싱, 트랜잭션, 비즈니스 로직 등등 애플리케이션이 동작하기 위해서 해주어야할 처리가 있는데 이를 하나의 Service 클래스에서 관리한다면 어떻게 될까요?
애플리케이션 여러 목적을 하는 코드가 하나의 Service 클래스에 있기 때문에 코드를 파악하기 어려워질 것입니다.
반면 특정 위치에 있는 코드의 목적이 “보안"이라는 주제로 명확하게 정해져 있다면 코드를 파악하기가 쉬워집니다. “보안"이라는 카테고리 안에서 코드의 의도를 파악하기 시작할 수 있기 때문입니다.
이런 주제를 “관심사”라고 부릅니다. AOP 라는 기술을 통해서 클라이언트가, 관심사별로 코드를 모듈화 할 수 있도록 해줍니다.
객체지향 기술은 분명 성공적인 프로그래밍 방식이었지만, 복잡해져가는 애플리케이션의 요구조건과 기술적인 부분을 모두 해결하기에는 난해함을 가지고 있었습니다.
예를 들어 객체 지향 원칙을 지키면서 기술적인 트랜잭션 & 로깅 등등을 객체지향 기술만으로 처리하기에는 문제가 있었습니다.
출처 : https://gmoon92.github.io/spring/aop/2019/02/09/why-used-aop.html
위 그림을 보면 서비스 비즈니스 로직을 담은 this.userRepo.transferUserAmt(user); 코드 이외에 위아래로 트랜잭션을 처리하기 위한 코드가 존재합니다. 이는 비즈니스 로직과는 관련이 없지만 필수적인 부가 기능입니다. 비즈니스를 처리하기 위한 목적을 가지고 AccountService 클래스를 열었다면 읽기가 난해함을 느낄 것입니다.
AOP 는 이런 객체지향 기술의 한계와 단점을 극복하기 위해 나왔습니다. 한편 AOP 를 사용하면 그 결과로 OOP 를 더더욱 OOP 답게 만들 수 있습니다.
AOP 를 이해하려면 위빙이라는 단어를 이해해야 합니다. 위빙은 원본 로직에 부가 기능을 추가하는 것을 의미합니다. 위빙을 하는 방법은 다양하게 있을 것입니다. 코드를 위빙한다고 가정하면 어떻게 사용자 코드 사이에 위빙을 할 수 있을까요??
예를 들면 컴파일 하기 전에 .java 파일에 필요한 코드를 앞뒤로 삽입한 후 컴파일하는 방법이 있을 수 있겠네요.
이렇게 위빙을 하는 시점이 언제냐에 따라서 종류가 크게 3가지로 나누어 집니다.
출처 : https://bepoz-study-diary.tistory.com/408
java 코드를 추가하는 방법입니다. 컴파일 시점에 .java
파일을 .class
파일로 변환하는 과정에서 AspectJ 컴파일러가 부가 기능 로직을 붙이는 방식입니다. 컴파일된 .class
를 디컴파일 해보면 AspectJ 관련 호출 코드가 들어감을 알 수 있습니다.
컴파일 시점 위빙의 단점은 특별한 컴파일러가 필요하고 복잡하다는 점입니다. 기본 컴파일러는 java 코드를 변경할 수 없기 때문에 특별한 컴파일러를 사용해야 합니다.
애플리케이션 구동을 위해서는 클래스로더라는 것을 통해서 JVM 에 클래스 정보를 올리는 과정을 거쳐야 합니다. 이 때 JVM 에 올라간 .class
정보를 토대로 코드가 실행됩니다.
클래스 로딩 시점 위빙은 .class
파일을 JVM 에 저장하기 전(클래스로드 타임 전)에 코드를 조작합니다. 그리고 많은 JVM 모니터링 툴들이 사용하는 방법이다.
로드타임 위빙의 단점은 클래스 로더 조작기를 직접 조작해야 한다는 점입니다. 자바 실행 시 특별한 옵션 (java -javaagent)을 통해 클래스 로더 조작기를 지정해야하는데 이 부분이 번거롭고 운영하기 어렵게 만드는 요인입니다.
위에서 배웠던, 프록시 방식의 AOP 를 의미합니다. 이 방법을 사용하면 “컴파일타임 위빙”이나 “로드타임 위빙"의 불편한 사항들이 개선됩니다. 컴파일 시점 처럼 특별한 컴파일러를 사용한다던가 클래스 로더 조작기를 설정하지 않아도 됩니다.
런타임에 스프링으로 부터 필요한 객체를 전달 받아서 “빈포스트프로세서"라는 곳에서 객체를 프록시 객체로 변경 해줍니다.
AOP 는 위에서 언급했던 프록시라는 기술을 사용해서 구현됩니다. Spring AOP 는 Proxy 메커니즘을 기반으로 AOP Proxy 를 제공하고 있다. 그리고 IoC 즉, 스프링이 관리하는 컨테이너에서 관리하는 빈 객체만 Proxy 를 적용할 수 있다는 말이기도 합니다.
스프링에서 프록시를 적용하는 방법에는 2가지가 있습니다.
jdk runtime proxy 라고도 부르는 이 기술은 다음과 같이 사용합니다.
Object proxy = Proxy.newProxyInstance(ClassLoader // 클래스로더
, Class<?>[] // 타깃의 인터페이스
, InvocationHandler // 타깃의 정보가 포함된 Handler
);
리플랙션을 지원하는 Proxy 클래스의 newProxyInstance 메소드를 사용하면 됩니다. JDK Dynamic Proxy 의 newProxyInstance 메소드가 Proxy 객체를 생성해주는 과정은 다음과 같습니다.
출처 : https://gmoon92.github.io/spring/aop/2019/04/20/jdk-dynamic-proxy-and-cglib.html
JDK Runtime Proxy 는 인터페이스를 기준으로 Proxy 객체를 생성해줍니다. 그렇기 때문에 인터페이스가 아닌 타입의 변수에 JDK Runtime Proxy 를 DI 하려고 하면 예외를 발생시킬 수 있습니다.
인터페이스 타입이 아닌 클래스 타입으로 DI
@RequiredArgsConstructor
@Service
public class CardServiceImpl implements CardService {
...
}
@RestController
public class CardController {
private CardServiceImpl cardService;
public CardController(CardServiceImpl cardService) {
this.cardService = cardService;
}
}
예외 로그
The bean is of type 'com.sun.proxy.$Proxy169' and implements:
com.slack.slack.domain.service.CardService
org.springframework.aop.SpringProxy
org.springframework.aop.framework.Advised
org.springframework.core.DecoratingProxy
Expected a bean of type 'com.slack.slack.domain.service.impl.CardServiceImpl' which implements:
com.slack.slack.domain.service.CardService
Action:
Consider injecting the bean as one of its interfaces or forcing the use of CGLib-based proxies by setting proxyTargetClass=true on @EnableAsync and/or @EnableCaching.
테스트를 위해서 Spring 에서 인터페이스가 아닌 타입으로 DI 를 받으려고 시도했지만 예외가 발생하지 않았습니다. 이유를 찾기 위해 디버깅 해보니 Jdk Dynamic 프록시가 아닌 GCLIB 을 기반으로 동작하고 있었습니다.
왜 그럴까 검색하다가 다음과 같은 글을 발견했습니다.
spring boot aop 에서 JDK dynamic proxy 이용하는 법
스프링을 Proxy 기반으로 AOP 를 동작시키려면 @EnableAspectJAutoProxy 어노테이션을 설정해야 하는데 이를 아래와 같은 내용을 설정파일에 추가해야 JDK Runtime Proxy 기반으로 동작시킬 수 있다고 합니다.
spring:
aop:
auto: false
proxy-target-class: false
CGLib 은 Code Generator Library 의 약자로 클래스의 바이트코드를 조작하여 Proxy 객체를 생성해주는 라이브러리입니다.
Spring CGLib 을 사용하여 인터페이스가 아닌 타깃의 클래스에 대해서도 Proxy 를 생성해줄 수 있습니다.
CGLib 은 Enhancer 라는 클래스를 통해 Proxy 를 생성할 수 있습니다.
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MemberService.class); // 타깃 클래스
enhancer.setCallback(MethodInterceptor); // Handler
Object proxy = enhancer.create(); // Proxy 생성
CGLib 은 타깃의 클래스를 상속받아 다음 그림과 같이 프록시를 생성해줍니다.
이런 특성 때문에 Final 메소드 또는 클래스에 대해서 재정의를 할 수 없기 때문에 Proxy 를 생성할 수 없다는 단점이 있지만 GCLib 은 바이트코드를 조작해서 Proxy 를 생성해주기 때문에 JDK Dynamic Proxy 보다 성능이 좋습니다.
예제
public static OrderServiceImpl getOrderServiceProxy(OrderServiceImpl target) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OrderServiceImpl.class); // 타깃 클래스
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
long startTime = System.nanoTime();
method.invoke(target, args);
long endTime = System.nanoTime();
System.out.println((endTime - startTime) + " nanoseconds");
return null;
}
}); // Handler
Object proxy = enhancer.create(); // Proxy 생성
return (OrderServiceImpl)proxy;
}
Jdk runtime Proxy 와 CGLib 성능비교
다음과 같은 3가지 한계가 존재했기 때문입니다.
⇒ Spring 은 JDK Dynamic Proxy 를 사용해서 인터페이스 방식의 DI 를 지원해주고 인터페이스가 아닌 타입에 대해서 CGlib 으로 Proxy 를 DI 해줍니다.
그러나 상속을 기반으로 하여, 인터페이스가 아닌 타입의 DI가 가능하다는 점, CGlib 이 가지고 있던 한계를 극복했습니다.
그래서 현재 Spring 은 Proxy 를 생성하는 방법으로 CGlib 을 사용합니다. 기본 설정이 CGlib 를 이용한 Proxy 생성입니다.
스프링이 제공하는 aop 기능을 구현해보고 싶었습니다. AspectJ 가 아닌, 어노테이션을 이용해서 Aspect 코드를 삽입하는 방법으로 AOP 를 구현하려고 합니다.
즉, 스프링 프레임워크의 일부 기능을 구현해보려고 합니다. 이 프레임워크의 이름을 임시로 Look 이라고 부르겠습니다.
코드는 아래 저장소에 있습니다.
https://github.com/donghyeon0725/study/tree/master/src/main/java/com/studyall/study/proxy/aop
아무런 스토리도 없이 구현하면 왜 이런 코드가 나왔는지 이해가 안될 수 있기 때문에 스토리를 추가하려고 합니다. 클라이언트 입장입니다. 현재 클라이언트는 Look 프레임워크를 이용해서 다음과 같은 코드를 작성하고 싶은 상황입니다.
client 패키지는 사용자 입장에서 사용자가 작성한 코드를 모아놓은 패키지이고 Look 패키지는 프레임워크가 제공하는 클래스를 모아놓은 패키지입니다.
클라이언트가 사용하기 어려운 프레임워크는 대체적으로 인기가 없습니다. 제가 만든 프레임워크는 세계정복(?)을 목표로 하고 있기 때문에 쉬워야 합니다. 그렇기 때문에 사용자가 AOP 사용을 위해서 알아야 할 클래스가 적으면 적을 수록 좋습니다.
클라이언트 입장에서 알아야 할 클래스와 어노테이션은 다음과 같습니다.
AOP 의 목적은 코드의 분리입니다.
나(클라이언트)는 코드를 분리해서 로직을 작성할 테니, 이 코드를 프레임워크가 알아서 다른 코드 사이에 끼워 넣어주기를 바래 ~
이점을 고려해서 AOP 코드를 삽입해줄 수 있도록 설계했습니다. 여기서 클라이언트가 위빙
하려는 로직을 Aspect 라고 부릅니다. 마찬가지로 Look 프레임워크도 이를 Aspect 라고 부르겠습니다.
@Aspect 는 Aspect 클래스를 만들기 위해서 사용하는 어노테이션이고, AspectI 는 Aspect 클래스가 따라야할 표준을 정의한 인터페이스입니다.
어노테이션
어노테이션 | 설명 |
---|---|
@Service | 서비스 빈 생성을 위한 어노테이션 |
@Aspect | Aspect 클래스를 만들 때 사용하는 어노테이션. 이 어노테이션과 type 타입만 명시해주면 ApplicationContext 에 저장된 해당 Bean 을 찾아 Aspect 로직이 들어있는 Proxy 로 만들어준다. |
스프링 프레임워크에서 @Service 와 @Aspect 어노테이션이 붙은 경우 모두 Bean 으로 관리를 하고 있습니다. 마찬가지로 Look을 구현할 때 두 객체 모두 빈으로 관리할 예정입니다.
어플리케이션 구동을 위한 주요 클래스 & 인터페이스
인터페이스 | 클래스 (or 구현체) | 설명 |
---|---|---|
ApplicationContext | LookApplicationContext | 생성한 빈을 관리하는 Context 객체입니다. |
ApplicationRunner | 프레임워크의 컨테이너에 해당하는 ApplicationContainer 클래스를 초기화 하고 애플리케이션을 로드합니다 | |
ApplicationContainer | 프레임워크를 구동시키는 컨테이너 입니다. 필요한 모듈을 new 생성자를 통해서 생성합니다. 빈 프로세서가 빈을 ApplicationContext 에 주입하고 어노테이션 핸들러를 체인에 추가(add) 합니다. 이 후 체인에 들어있는 핸들러들을 꺼내 어노테이션을 핸들링합니다. | |
BeanProcessor | DefaultBeanProcessor | 빈을 인스턴스화 합니다. BeanDefinitionFinder 를 통해서 로드한 package 의 클래스정보를 로드한 후 Bean 대상이되는 클래스를 찾아 인스턴스화 합니다. 이 후 ApplicationContext 에 추가(add)합니다. |
AnnotationHandlerChain | AnnotationHandler 구현체를 담아두는 chain 입니다. | |
chain 에서 AnnotationHandler 를 꺼내어 어노테이션을 핸들링힙니다. 커스텀으로 추가가 가능합니다. | AnnotationHandler | |
ProxyCreator | CGlib 을 기반으로 사용자가 작성한 Aspect 클래스를 빈에 위빙 해준다. |
어플리케이션을 구동하기 위해서 Look은 빈 객체를 생성하고 이를 프록시로 만들어줄 것 입니다. 구동 흐름은 다음과 같습니다.
애플리케이션 구동흐름
ApplicationRunner → ApplicationContainer → BeanDefinitionFinder → BeanProcessor → AnnotationHandlerChain → AspectHandler → ProxyCreator
public static void main(String[] args) {
ApplicationContainer applicationContainer = new ApplicationContainer();
applicationContainer.init(args);
ApplicationContext context = applicationContainer.getContext();
MemberService service = context.getBean(MemberServiceImpl.class.getName(), MemberService.class);
service.createMember("유저1");
}
애플리케이션의 시작점입니다. 마치 SpringBoot 에서 @SpringBootApplication 어노테이션이 붙은 클래스와 유사합니다.
ApplicationContainer 의 init 메소드를 호출함으로서 애플리케이션을 시작합니다. 그리고, 아직은 구현하지 않았지만 applicationContainer 에서 ApplicationContext 를 꺼내어 MemberService 의 createMember 메소드를 호출하는 과정은 나중에 스프링에서 @Autowired 어노테이션을 통해서 빈을 주입받는 DI 로 구현할 예정입니다.
public class ApplicationContainer {
private String path;
private ApplicationContext context;
private BeanDefinitionFinder finder;
private BeanProcessor processor;
private AnnotationHandlerChain annotationHandlerChain;
public void init(String[] args) {
// 필요한 인스턴스 로드 => 구현체 로드
createInstance();
// bean processing
processor.process(finder, context);
// add annotation handlers
addAllAnnotationHandler();
// annotation handling
annotationHandlerChain.handle(context);
}
...
}
ApplicationContainer 애플리케이션이 구동되기 위해서 필요한 의존성을 정의합니다. 느슨한 결합으로 구현된 클래스들에 구체적인 객체를 주입해줄 수 있도록 의존성을 정의하고 해당 객체를 생성하는 과정을 createInstance 메소드가 담당합니다.
createInstance 메소드 호출이 끝나면 빈을 로드하는 processor 가 동작합니다. 별도의 메소드로 만들어 loadBean 이라는 이름을 붙여주면 더 좋았겠지만, 시간상 그렇게 하지 못했습니다.
addAllAnnotationHandler 에서는 사용자가 커스텀으로 추가한 어노테이션 핸들러와 Look 프레임워크에서 제공하는 어노테이션 핸들러를 annotationHandlerChain 에 추가합니다.
왜 굳이 annotationHandlerChain 를 사용했으냐면 새로운 어노테이션이 추가되었을 때 기존의 코드를 건드리지 않고, 어노테이션 핸들러를 체인에 추가하고 동작시킬 수 있는 구조로 설계하고 싶었습니다.
addAllAnnotationHandler 메소드 코드를 보면 아래와 같습니다.
// 모든 어노테이션 핸들러 추가
private void addAllAnnotationHandler() {
AnnotationHandler aopHandler = new AspectHandler(new ProxyCreator());
this.annotationHandlerChain.add(aopHandler);
this.addAllCustomAnnotationHandler();
}
지금은 new 키워드를 통해서 AspectHandler 객체를 생성했기 때문에, Look 프레임웤이 제공하는 새로운 어노테이션 핸들러를 추가하기 위해서는 이 코드를 수정해야합니다.
하지만, 추후 스프링이 제공하는 @Scan 어노테이션과 유사하게 어노테이션 스캔을 이용해서 핸들러를 추가하는 코드로 변경을 한다면 새로운 핸들러를 추가하더라도 이 메소드를 변경할 일이 없습니다. 즉 변경에는 닫혀 있고 확장에는 열려 있어야 한다는 OCP 원칙
을 지킬 수 있습니다.
이를 염두해두고 Chain 역할을 할 클래스를 만들었습니다.
target 이 되는 빈을 proxy 객체로 만듭니다. 이 후 Aspect 클래스 빈을 찾아서 target 의 proxy 빈에 위빙
(사용자 코드를 끼워 넣음)합니다.
현재는 하나의 Aspect 클래스만 지원합니다. 추 후 기회가 되면 여러 Aspect 클래스를 지원할 수 있도록 확장하려고 합니다.
@Override
public void handle(ApplicationContext applicationContext) {
// aop 빈을 찾아서 service 빈에 넣어주기
AspectI aspectI = findAspect(applicationContext);
Object bean = findTargetBean(applicationContext, aspectI);
Object proxy = proxyCreator.getProxy(bean, aspectI);
applicationContext.setBean(bean.getClass().getName(), proxy);
}
public class ProxyCreator {
public <T> T getProxy(T target, AspectI aspectI) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(target.getClass()); // 타깃 클래스
enhancer.setCallback((MethodInterceptor) (o, method, args, methodProxy) -> {
aspectI.handle(target, method, args);
return null;
}); // Handler
Object proxy = enhancer.create(); // Proxy 생성
return (T)proxy;
}
}
마지막으로 프록시 생성기 클래스입니다.
CGlib 라이브러리를 사용해서 이를 구현 하였는데, JDK Runtime Proxy 대신 이를 사용한 주요 이유는 인터페이스 기반이 아닌 객체를 프록시로 만들 수 있기 때문입니다.
AOP 구현을 위해 Client 가 알아야 할 클래스 or 인터페이스
대상 | 타입 | 설명 |
---|---|---|
AspectI | 인터페이스 | aop class 가 지켜야할 표준을 정의한 인터페이스 입니다. |
사용자는 @Aspect 어노테이션을 제외하고 오직 AspectI 인터페이스만 알고 있으면 Aspect 를 만들 수 있습니다.