스프링은 프록시 방식의 AOP를 사용하기 때문에 AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다.
스프링은 의존관계 주입 시에 프록시 객체를 주입하여 대상 객체 대신에 프록시를 스프링 빈으로 등록하여 적용할 수 있다.
여기서 문제가 발생하는데 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다. 즉, 부가 기능이 적용되지 않게 된다.
문제 발생
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class CallServiceV0 {
public void external() {
log.info("call external");
internal(); //내부 메서드 호출(this.internal())
}
public void internal() {
log.info("call internal");
}
}
@Aspect
public class CallLogAspect {
@Before("execution(* hello.aop.internalcall..*.*(..))")
public void doLog(JoinPoint joinPoint) {
log.info("aop={}", joinPoint.getSignature());
}
}
@Import(CallLogAspect.class)
@SpringBootTest()
class CallServiceV0Test {
@Autowired
CallServiceV0 callServiceV0;
@Test
void external() {
callServiceV0.external();
}
}
스프링 AOP를 적용하여 CallService에 포인트컷을 매칭하여 프록시 객체가 빈으로 등록되게 하였고, 해당 프록시는 메소드를 실행하기 전 시그니처를 출력하도록 구현하였다.
기대하는 결과는 external()과 internal()호출 전 "[aop] 시그니처 정보" 가 하나씩 총 2개가 나와야 하지만 아래와 같이 internal에는 적용되지 않았음을 확인할 수 있다.
그 이유는 외부에서 호출을 할 때는 스프링 AOP의 프록시 기술을 통해 프록시.외부메소드()를 통해 실행되지만 내부 호출은 this.내부메소드()를 통해 실행되기 때문에 프록시에 등록해놓은 어드바이스가 실행되지 않는 것이다.
당연히 해결 방법은 있다.
해결 방법
대안1) 자기 자신 주입
Callservice에서 메소드르 실행할 때 내부 메소드를 통해 this.method()가 호출되어 문제였으므로 프록시.method()가 호출되도록 바꿔준다. 즉, internal()을 프록시.internal()로 호출되도록 변경해주면 된다. 이때 CallService1에서 CallService1을 가져오기 위해 set을 통해 주입받아야 한다. 생성자를 통해 주입받으면 오류가 발생한다.
@Slf4j
@Component
public class CallServiceV1 {
private CallServiceV1 callServiceV1;
@Autowired
public void setCallServiceV1(CallServiceV1 callServiceV1) {
this.callServiceV1 = callServiceV1;
}
public void external() {
log.info("call external");
callServiceV1.internal(); //외부 메서드 호출
}
public void internal() {
log.info("call internal");
}
}
대안2) 지연 조회
스프링 빈을 지연 조회하기 위해 ObjectProvider를 사용할 수 있다. 사용 시점에 빈을 조회하여 사용하게 해준다.
@Component
@RequiredArgsConstructor
public class CallServiceV2 {
private final ObjectProvider<CallServiceV2> callServiceProvider;
public void external() {
log.info("call external");
CallServiceV2 callServiceV2 = callServiceProvider.getObject(); // 프록시 빈 조회
callServiceV2.internal(); //외부 메서드 호출
}
public void internal() {
log.info("call internal");
}
}
대안3) 구조 변경
내부 호출이 일어나지 않도록 구조를 변경해주는 것이다.
Internal()이 내부에서 호출되어 어드바이스가 적용되지 않았다. 이 로직을 새로운 프록시 객체에서 실행될 수 있도록 따로 클래스를 생성해서 외부 호출로 변경하여 해결해보자.
@Slf4j
@Component
public class InternalService {
public void internal() {
log.info("call internal");
}
}
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {
private final InternalService internalService;
public void external() {
log.info("call external");
internalService.internal(); //외부 메서드 호출
}
}
프록시 기술의 한계
스프링 AOP는 프록시 기술을 활용을 하고, 인터페이스 기반의 JDK 동적 프록시 기술, 구체 클래스 기반의 CGLIB 프록시 기술이 존재한다. 여기서 한계점이 발생하게 되는데 바로 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능하다는 점이다.
다음 JDK 동적 프록시를 활용한 인터페이스를 살펴보자.
public interface MemberService {
String hello(String param);
}
@Component
public class MemberServiceImpl implements MemberService {
@Override
@MethodAop("test value")
public String hello(String param) {
return "ok";
}
public String internal(String param) {
return "ok";
}
}
다음 테스트 코드를 보면 구체 프록시 -> 인터페이스는 성공하지만 프록시 -> 구체 클래스로는 캐스팅에 실패한다.
@Test
void jdkProxy() {
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(false);//JDK 동적 프록시
//프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
log.info("proxy class={}", memberServiceProxy.getClass());
//JDK 동적 프록시를 구현 클래스로 캐스팅 시도 실패, ClassCastException 예외 발생
assertThrows(ClassCastException.class, () -> {
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
});
}
JDK 동적 프록시 작동 원리를 이해하면 되게 쉬운 이유이다. JDK 동적 프록시에 의해 생성된 프록시는 해당 target의 부모클래스인 인터페이스를 통해 구현되고 target 구현체를 가지고 있어 호출을 해줄 뿐이다.
당연히 프록시도 인터페이스를 통해 구현되고, 구현체인 target은 당연히 인터페이스를 구현했다.
더 쉽게 설명하기 위한 코드를 구현해봤다. 부모 클래스인 인터페이스 A를 구현한 ProxyA와 ConcreteA가 있다. 같은 부모 클래스 A를 구현하였지만 서로 다른 메소드들을 추가로 가질 수 있을 것이다. 그렇다면 당연히 ProxyA의 객체는 ConcreteA 참조에 담길 수 없다. 너무나 당연한 얘기다.
interface A {
void methodA();
}
class ProxyA implements A{
@Override
public void methodA() {}
public void methodA1() {}
}
class ConcreteA implements A{
@Override
public void methodA() {
}
public void methodA2() {}
}
class Main {
ConcreteA a1 = new ProxyA();
}
그럼 구체 클래스 기반으로 프록시를 생성하느 CGLIB은 어떨까?
CGLIB은 구체 클래스를 기반으로 프록시갓 생성된다. 즉, CGLIB 프록시는 구체 클래스를 부모 클래스로 삼기 때문에 전혀 문제가 되지 않는다. 다음 의존관계 주입을 살펴보자.
@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"}) //JDK 동적 프록시, DI 예외 발생
@Import(ProxyDIAspect.class)
public class ProxyDITest {
@Autowired
MemberService memberService; //JDK 동적 프록시 OK, CGLIB OK
@Autowired
MemberServiceImpl memberServiceImpl; //JDK 동적 프록시 X, CGLIB OK
...
}
위에서 살펴본 타입 캐스팅 예시에 의하면 JDK 동적 프록시가 적용되었을 때 MemberServiceImpl memberServiceImpl은 타입 캐스팅 오류가 발생한다. 그럼 인터페이스를 사용하면 되는 것 아니냐 할 수 있지만, 테스트 또는 여러 이유로 구체 클래스를 직접 의존관계 주입을 받아야 할 경우가 발생할 것이다.
또 다른 해결책은 CGLIB을 사용하는 것이다. 인터페이스, 구체 클래스에 상관없이 타입 캐스팅이 가능하다.
하지만 CGLIB에도 단점이 존재한다.
CGLIB 단점
- 대상 클래스에 기본 생성자 필수
- 생성자 2번 호출 문제
- final 키워드 클래스, 메서드 사용 불가
앞서 말했듯이 구체 클래스를 부모 클래스로 가지기 때문에 상속 관계의 생성자 규칙에 의해서 부모 클래스(대상 클래스, target)의 생성자가 필요하다.
또한 위 이유로 인해 프록시->부모 클래스(대상 클래스, target)를 생성하는데 1회 / 프록시 내부의 target(대상 클래스)를 생성하는데 1회 총 2회의 생성자가 호출된다.
마지막으로 클래스 상속을 이용한 기술이기에 상속이 불가능한 final 클래스, 오버라이딩이 불가능한 final 메소드에는 사용이 제한된다.
이쯤되면 뭐 어쩌라는 건가 싶다. 이래도 문제, 저래도 문제,,, 그래서 스프링은 CGLIB의 단점을 해결해 왔고 우리는 그냥 CGLIB을 사용하면 된다.
어떻게 해결했는지는 참고만하자.
objenesis 라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능하게 하여 생성자 문제를 해결하였다.
AOP를 적용할 대상은 final 키워드를 잘 사용하지 않아 큰 문제가 되지 않는다.
'Study > Spring' 카테고리의 다른 글
AOP (Aspect - Oriented Programming)와 @Aspect (0) | 2023.10.12 |
---|---|
빈 후처리기(Bean PostProcessor)와 어드바이저 (1) | 2023.10.11 |
리플렉션과 프록시 팩토리 (0) | 2023.10.11 |
PRG Post/Redirect/Get (0) | 2023.08.22 |
HTTP 헤더, 파라미터, 바디 조회하는 방법 (0) | 2023.08.21 |