본문은 인프런의 [스프링 핵심 원리 - 고급편]를 수강하고 정리한 글입니다. 필요에 따라 생략/수정된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
스프링 핵심 원리 - 고급편
1. 쓰레드 로컬
2. 템플릿 메서드 패턴과 콜백 패턴
3. 프록시 패턴과 데코레이터 패턴
4. 동적 프록시 기술
5. 스프링이 지원하는 프록시
6. 빈 후처리기
7. @Aspect AOP
8. 스프링 AOP 개념
9. 스프링 AOP 구현
10. 포인트컷
11. 실무 주의사항
전 챕터에서는 프록시 패턴과 데코레이터 패턴을 학습했고, 프록시를 사용해서 기존 코드를 변경하지 않고 부가 기능(로그 추적)을 적용했다. 그러나 프록시를 이용하기 위해 부가 기능을 위한 프록시 클래스를 계속해서 만들어내야 한다는 점은 여전히 문제로 남아있다.
@RequiredArgsConstructor
public class OrderControllerInterfaceProxy implements OrderControllerV1 {
private final OrderControllerV1 target;
private final LogTrace logTrace;
// ...
}
@RequiredArgsConstructor
public class OrderServiceInterfaceProxy implements OrderServiceV1 {
private final OrderServiceV1 target;
private final LogTrace logTrace;
// ...
}
@RequiredArgsConstructor
public class OrderRepositoryInterfaceProxy implements OrderRepositoryV1 {
private final OrderRepositoryV1 target;
private final LogTrace logTrace;
// ...
}
본 챕터를 통해 동적 프록시 기술을 사용해서 이러한 문제를 해결하는 것을 살펴보자.
1. 리플렉션
JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프록시 객체를 동적으로 만들어낼 수 있다. 먼저 JDK 동적 프록시를 이해하기 위해서는 자바의 리플렉션에 대해 알아야 한다.
리플렉션이란 런타임에 임의의 클래스의 정보에 접근할 수 있게 해주는 기능이다. 리플렉션을 사용하면 클래스나 메서드의 메타정보를 동적으로 획득하거나 코드를 동적 호출할 수 있다.
다음의 코드를 보자. 공통 로직1과 공통 로직2는 호출하는 메서드만 다를 뿐, 전체 코드 흐름은 완전히 동일하다.
예제1 - 리플렉션 미적용
@Test
void reflection0() {
Hello target = new Hello();
//공통 로직1 시작
log.info("start");
String result1 = target.callA(); //호출하는 메서드가 다음
log.info("result={}", result1);
//공통 로직1 종료
//공통 로직2 시작
log.info("start");
String result2 = target.callB(); //호출하는 메서드가 다음
log.info("result={}", result2);
//공통 로직2 종료
}
여기에 리플렉션을 적용하면 기존에 callA(), callB() 메서드를 직접 호출하는 부분을 Method라는 메타정보로 추상화할 수 있다. 이렇게 추상화한 Method를 별도 공통 메서드의 인수로 전달하면 공통 로직을 합칠 수 있게 된다.
예제2- 리플렉션 적용
@Test
void reflection2() throws Exception {
//클래스 메타정보 (내부 클래스는 $를 통해 구분)
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
Hello target = new Hello();
Method methodCallA = classHello.getMethod("callA"); //클래스 메타정보를 통해 메서드 정보를 획득
dynamicCall(methodCallA, target);
Method methodCallB = classHello.getMethod("callB"); //클래스 메타정보를 통해 메서드 정보를 획득
dynamicCall(methodCallB, target);
}
private void dynamicCall(Method method, Object target) throws Exception {
log.info("start");
//target: 실제 실행할 인스턴스 정보, Object 타입이므로 어떠한 인스턴스도 받을 수 있다
Object result = method.invoke(target); //target의 메서드를 실행하고 그 결과를 받음
log.info("result={}", result);
}
- target의 메서드를 실행하는 method.invoke(target)를 주목하자
- 동적프록시에서 비슷한 형태를 취하는 코드를 계속 보게 될 것이다
리플렉션은 이처럼 편한 기능이지만, 그 사용에 있어서는 굉장히 주의해야 한다. 런타임에 동작하므로 컴파일 시점에 오류를 잡을 수 없다는 치명적인 단점이 있기 때문이다. 따라서 리플렉션은 프레임워크 개발이나 매우 일반적인 공통 처리가 필요할 때만 제한적으로 주의해서 사용해야 한다
2. JDK 동적 프록시
1) JDK 동적 프록시 예제
지금 살펴봤던 프록시 클래스의 특징은 로직은 거의 같은데, 적용 대상에 차이만 있다는 점이다.
@RequiredArgsConstructor
public class OrderControllerInterfaceProxy implements OrderControllerV1 {
private final OrderControllerV1 target;
private final LogTrace logTrace;
// ...
}
@RequiredArgsConstructor
public class OrderServiceInterfaceProxy implements OrderServiceV1 {
private final OrderServiceV1 target;
private final LogTrace logTrace;
// ...
}
동적 프록시 기술을 사용하면 이를 효과적으로 해결할 수 있다. 동적 프록시를 사용하면 프록시 객체를 런타임에 동적으로 생성할 수 있고, 이렇게 생성된 프록시 객체에 원하는 실행 로직을 지정할 수 있다.
먼저 자바에서 제공하는 동적 프록시 기술인 JDK 동적 프록시를 살펴보자. JDK 동적 프록시는 인터페이스를 기반으로 프록시를 동적으로 만들어주기 때문에 인터페이스가 필수이다.
인터페이스
public interface AInterface {
String call();
}
@Slf4j
public class AImpl implements AInterface {
@Override
public String call() {
log.info("A 호출");
return "a";
}
}
JDK 동적 프록시가 제공하는 InvocationHandler - 동적 프록시가 수행할 로직을 정의
package java.lang.reflect;
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
- Object proxy: 프록시 자기 자신
- Method method: 호출한 메서드
- Object[] args: 메서드를 호출할 때 전달한 인수
TimeInvocationHandler - InvocationHandler를 구현
@Slf4j
public class TimeInvocationHandler implements InvocationHandler {
private final Object target; //동적 프록시가 호출할 대상
public TimeInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
//리플렉션을 사용해서 target 인스턴스의 메서드를 실행한다 (args는 호출시 넘겨줄 인수이다)
Object result = method.invoke(target, args);
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
- InvocationHandler 인터페이스를 구현함으로써 JDK 동적 프록시가 수행할 로직을 정의할 수 있다
- 프록시의 어떤 메서드가 호출됐는지 등이 파라미터로 넘어옴
테스트 코드
@Slf4j
public class JdkDynamicProxyTest {
@Test
void dynamicA() {
AInterface target = new AImpl();
TimeInvocationHandler handler = new TimeInvocationHandler(target);
//클래스로더 정보, 인터페이스, 핸들러 로직을 넣어주면 해당 인터페이스를 기반으로 동적 프록시를 생성한다
AInterface proxy = (AInterface) Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[]{AInterface.class}, handler);
proxy.call();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
}
@Test
void dynamicB() {
BInterface target = new BImpl();
TimeInvocationHandler handler = new TimeInvocationHandler(target);
//클래스로더 정보, 인터페이스, 핸들러 로직을 넣어주면 해당 인터페이스를 기반으로 동적 프록시를 생성한다
BInterface proxy = (BInterface) Proxy.newProxyInstance(BInterface.class.getClassLoader(), new Class[]{BInterface.class}, handler);
proxy.call();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
}
}
- 동적프록시는 java.lang.reflect.Proxy를 통해서 생성된다
동적 프록시 도입 전과 후의 런타임 객체 의존 관계
2) JDK 동적 프록시 적용
LogTraceBasicHandler
public class LogTraceBasicHandler implements InvocationHandler {
private final Object target; //프록시가 호출할 대상
private final LogTrace logTrace;
public LogTraceBasicHandler(Object target, LogTrace logTrace) {
this.target = target;
this.logTrace = logTrace;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
TraceStatus status = null;
try {
//기존엔 "OrderController.request()"와 같이 직접 남겼었다
//그러나 이제는 메서드 정보와 클래스 정보에 동적으로 접근할 수 있다
String message = method.getDeclaringClass().getSimpleName() + "." +
method.getName() + "()";
status = logTrace.begin(message);
//로직 호출
Object result = method.invoke(target, args);
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
DynamicProxyBasicConfig
@Configuration
public class DynamicProxyBasicConfig {
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1 orderControllerV1 = new OrderControllerV1Impl(orderServiceV1(logTrace));
OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(),
new Class[]{OrderControllerV1.class},
new LogTraceBasicHandler(orderControllerV1, logTrace));
return proxy; //프록시를 리턴한다!!
}
// ...
동적 프록시 도입 후 런타임 객체 의존 관계
그런데 InvocationHandler를 구현한 현재의 LogTraceBasicHandler엔 중요한 문제가 남아있다. 바로 모든 메서드에 동적 프록시가 적용되는 것이다. 즉, 로그를 남기고 싶지 않은 경우에도 로그 관련 로직을 타게 된다. 따라서 메서드 이름을 기준으로 특정 조건을 만족할 때만 로그를 남기게끔 하면 이 문제를 해결할 수 있다.
LogTraceFilterHandler
public class LogTraceFilterHandler implements InvocationHandler {
private final Object target;
private final LogTrace logTrace;
private final String[] patterns; //패턴을 추가!!
public LogTraceFilterHandler(Object target, LogTrace logTrace, String[] patterns) {
this.target = target;
this.logTrace = logTrace;
this.patterns = patterns;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//메서드 이름으로 패턴을 매칭하여 일치하지 않는 경우에는 pass
String methodName = method.getName();
if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
return method.invoke(target, args); // 부가기능을 수행하지 않고 원래 동작만 수행
}
//...
}
}
- 필드에 patterns 추가하여 패턴 매칭을 사용하면 됨
DynamicProxyFilterConfig
@Configuration
public class DynamicProxyFilterConfig {
// 패턴을 미리 정의
private static final String[] PATTERNS = {"request*", "order*", "save*"};
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1 orderControllerV1 = new OrderControllerV1Impl(orderServiceV1(logTrace));
OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(),
new Class[]{OrderControllerV1.class},
new LogTraceFilterHandler(orderControllerV1, logTrace, PATTERNS)); // 패턴을 주입
return proxy;
}
// ...
3. CGLIB
1) CGLIB란?
CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이며, 이를 이용하면 인터페이스가 없더라도 구체 클래스만을 이용해 동적 프록시를 만들어낼 수 있다. CGLIB는 외부 라이브러리였는데 스프링 프레임워크가 스프링 내부 소스에 포함하게 되면서 별도 의존성 추가 없이 사용할 수 있게 되었다.
우리가 직접 CGLIB를 직접 사용하는 경우는 거의 없기 때문에 CGLIB가 무엇인지 대략적인 개념만 잡으면 된다. 다음 챕터에서 나올 ProxyFactory라는 것이 CGLIB를 편리하게 사용하게 도와줄 것이다.
2) CGLIB 예제
인터페이스와 구현이 있는 서비스 클래스 - ServiceInteface, ServiceImpl
public interface ServiceInterface {
void save();
void find();
}
@Slf4j
public class ServiceImpl implements ServiceInterface {
@Override
public void save() {
log.info("save 호출");
}
@Override
public void find() {
log.info("find 호출");
}
}
구체 클래스만 있는 서비스 클래스 - ConcreteService
@Slf4j
public class ConcreteService {
public void call() {
log.info("ConcreteService 호출");
}
}
CGLIB가 제공하는 MethodInterceptor - 동적 프록시가 수행할 로직을 정의
package org.springframework.cglib.proxy;
import java.lang.reflect.Method;
// https://github.com/cglib/cglib/blob/975e481faf39c91b8ac5b9b3d62822b7c52c5f47/cglib/src/main/java/net/sf/cglib/proxy/MethodInterceptor.java
// Spring에 내장된 MethodInterceptor와 CGLIB 내 MethodInteceptor의 인자명이 다름.. 여기서는 CGLIB에 맞춰 수정함
public interface MethodInterceptor extends Callback {
Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}
- CGLIB는 실행 로직을 위해 MethodInterceptor를 제공하는데, 이는 JDK 동적 프록시의 InvocationHandler와 동일한 역할을 한다
- Object obj: CGLIB가 적용된 객체
- Method method: 호출된 메서드
- Object[] args: 메서드를 호출하면서 전달된 인수
- MethodProxy proxy: 메서드 호출에 이용
TimeMethodInterceptor - MethodInteceptor를 구현
@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {
private final Object target; //프록시가 호출할 대상
public TimeMethodInterceptor(Object target) {
this.target = target;
}
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = methodProxy.invoke(target, args); //실제 대상을 동적으로 호출
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
테스트 코드
@Test
void cglib() {
ConcreteService target = new ConcreteService();
Enhancer enhancer = new Enhancer(); //CGLIB에서 프록시를 만드는 역할
//인터페이스가 아닌 구체클래스를 기반으로 프록시 생성하므로 어떤 구체클래스를 상속받을지 지정한다
enhancer.setSuperclass(ConcreteService.class);
//프록시에 적용할 실행 로직을 할당한다
enhancer.setCallback(new TimeMethodInterceptor(target));
ConcreteService proxy = (ConcreteService) enhancer.create(); //프록시 생성
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.call();
}
- CGLIB는 Enhancer를 사용해서 프록시를 생성한다
실행결과
CglibTest - targetClass=class hello.proxy.common.service.ConcreteService
CglibTest - proxyClass=class hello.proxy.common.service.ConcreteService$$EnhancerByCGLIB$$25d6b0e3
TimeMethodInterceptor - TimeProxy 실행
ConcreteService - ConcreteService 호출
TimeMethodInterceptor - TimeProxy 종료 resultTime=9
- CGLIB를 통해 생성된 프록시 클래스의 이름은 원본 클래스와 다르다
- CGLIB가 동적으로 생성하는 클래스 이름은 다음과 같은 규칙으로 생성된다
- 대상클래스$$EnhancedByCGLIB$$임의코드
CGLIB 동적 프록시 사용 후 의존 관계
3) CGLIB의 제약
JDK 동적 프록시는 인터페이스를 구현(implement)해서 프록시를 만드는 반면, CGLIB는 구체 클래스를 상속(extends)해서 프록시를 만든다. 따라서 몇가지 제약이 있다.
- 부모 클래스의 생성자를 체크해야 한다 → CGLIB는 자식 클래스를 동적으로 생성하므로 기본 생성자가 필요하다 (기본 생성자를 통해 인스턴스를 생성한 뒤, 필드값을 채우는 방식?)
- 클래스에 final 키워드가 붙으면 상속이 불가능하다 → CGLIB에서는 예외가 발생한다
- 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다 → CGLIB에서는 프록시 로직이 동작하지 않는다
CGLIB를 사용하면 인터페이스가 없는 클래스에 동적 프록시를 적용할 수 있다는 장점이 있지만, 실제 애플리케이션에 적용할 땐 위의 제약사항들 때문에 추가적인 수고가 들어가게 된다. 예를 들어 아래와 같이 생성자 주입을 통해 의존성을 주입받던 클래스에 CGLIB를 적용하기 위해 기본 생성자를 추가하고 의존성을 setter를 통해 주입받는 방식으로 변경해야 할 수도 있다. ProxyFactory를 사용해서 CGLIB를 적용하면 편리하게 이러한 단점을 극복할 수 있다.
// AS-IS (CGLIB 적용 전)
public class OrderControllerV2 {
private final OrderServiceV2 orderService;
public OrderControllerV2(OrderServiceV2 orderService) {
this.orderService = orderService;
}
// ...
}
// TO-BE (CGLIB 적용 후)
public class OrderControllerV2 {
private OrderServiceV2 orderService;
// 1. 기본 생성자를 만들어야 함 -> 생성자 주입 사용 불가
// 2. 생성자 주입 대신 setter 주입을 사용해야 함
public OrderControllerV2() {}
@Autowired
public void setOrderService(OrderServiceV2 orderService) {
this.orderService = orderService;
}
// ...
}
'Spring > 스프링 핵심 원리 - 고급편' 카테고리의 다른 글
[스프링 핵심 원리 - 고급편] 빈 후처리기 (0) | 2023.03.06 |
---|---|
[스프링 핵심 원리 - 고급편] 스프링이 지원하는 프록시 (0) | 2023.03.06 |
[스프링 핵심 원리 - 고급편] 프록시 패턴과 데코레이터 패턴 (0) | 2023.02.17 |
[스프링 핵심 원리 - 고급편] 템플릿 메서드 패턴과 콜백 패턴 (0) | 2023.02.09 |
[스프링 핵심 원리 - 고급편] 쓰레드 로컬 (ThreadLocal) (1) | 2023.02.03 |