AOP를 적용하려면 항상 프록시를 통해 객체를 호출해야 한다. 또한 AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록하기 때문에, 스프링은 의존관계 주입시 항상 프록시 객체를 주입한다. 이유는 스프링 컨테이너에 프록시가 올라가있고 빈을 가져오면 프록시가 주입되기 때문이다. 하지만 이 생각은 맞긴하지만 예외가 발생할 수 있다. 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생하는 예외 사항이 있다.
예시를 봐보자
@Slf4j
@Component
public class CallServiceV0 {
public void external() {
log.info("call external");;
// java에서 대상을 지정하지 않으면 내부 메서드가 호출되기 때문에 this가 생략되더라도 문제가 없다.
internal();;
}
public void internal() {
log.info("call internal");
}
}
/**
* aop 설정을 해준다.
*/
@Slf4j
@Aspect
public class CallLogAspect {
@Before("execution(* hello.aop.internalcall..*.*(..))")
public void doLog(JoinPoint joinPoint){
log.info("aop = {}", joinPoint.getSignature());
}
}
@Import(CallLogAspect.class)
@SpringBootTest
@Slf4j
class CallServiceV0Test {
@Autowired
CallServiceV0 callServiceV0;
@Test
void external() {
log.info("target = {}", callServiceV0.getClass());
callServiceV0.external();
}
@Test
void internal() {
callServiceV0.internal();
}
}
2023-10-16 22:41:03.828 INFO 18064 --- [ main] h.aop.internalcall.aop.CallLogAspect : aop = void hello.aop.internalcall.CallServiceV0.external()
2023-10-16 22:41:03.839 INFO 18064 --- [ main] hello.aop.internalcall.CallServiceV0 : call external
2023-10-16 22:41:03.839 INFO 18064 --- [ main] hello.aop.internalcall.CallServiceV0 : call internal
그렇다면 내부 호출할 경우 프록시를 적용하기 위해서는 어떻게 해야할까?
callServiceV1를 setter로 주입븓는다. 주입 받은 대상은 실제 자신이 아니라 프록시 객체이다.
@Slf4j
@Component
public class CallServiceV1 {
private CallServiceV1 callServiceV1;
@Autowired
public void setCallServiceV1(CallServiceV1 callServiceV1) {
this.callServiceV1 = callServiceV1;
}
public void external() {
log.info("call external");;
// setter로 주입을 해주기 떄문에 외부 메서드 호출로 바뀐다.
callServiceV1.internal();;
}
public void internal() {
log.info("call internal");
}
}
2023-10-16 23:07:32.685 INFO 11920 --- [ main] h.aop.internalcall.aop.CallLogAspect : aop = void hello.aop.internalcall.CallServiceV1.external()
2023-10-16 23:07:32.696 INFO 11920 --- [ main] hello.aop.internalcall.CallServiceV1 : call external
2023-10-16 23:07:32.696 INFO 11920 --- [ main] h.aop.internalcall.aop.CallLogAspect : aop = void hello.aop.internalcall.CallServiceV1.internal()
2023-10-16 23:07:32.696 INFO 11920 --- [ main] hello.aop.internalcall.CallServiceV1 : call internal
주의
스프링부트 2.6이상에서는 순환 참조를 기본적으로 금지했다. 그렇기 때문에 2.6이상이라면 spring.main.allow-circular-references=true를 추가해주도록 하자!
방법 1 : ApplicationContext를 사용하여 지연로딩하는 방법이다. 하지만 ApplicationContext는 너무 방대하기 때문에 사용을 안하는 것이 좋다.
방법 2 : ObjectProvider를 이용하는 방법으로, 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연하는 것이다.
@Slf4j
@Component
public class CallServiceV2 {
// 방법1
private final ApplicationContext applicationContext;
public CallServiceV2(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
// 방법 2
private final ObjectProvider<CallServiceV2> callServiceV2ObjectProvider;
public CallServiceV2(ObjectProvider<CallServiceV2> callServiceV2ObjectProvider) {
this.callServiceV2ObjectProvider = callServiceV2ObjectProvider;
}
// 방법1
public void external() {
log.info("call external");
CallServiceV2 bean = applicationContext.getBean(CallServiceV2.class);
bean.internal(); //외부 메서드 호출
}
// 방법 2
public void external() {
log.info("call external");
CallServiceV2 bean = callServiceV2ObjectProvider.getObject();
bean.internal(); //외부 메서드 호출
}
public void internal() {
log.info("call internal");
}
}
CallService3에서는 내부 호출이 발생하지 않도록 구조를 변경해보도록 하겠다.
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {
private final InternalService internalService;
public void external() {
log.info("call external");
internalService.internal(); //외부 메서드 호출
}
}
@Slf4j
@Component
public class InternalService {
// 구조를 변경
public void internal() {
log.info("call internal");
}
}
내부 호출을 없애고, CallServiceV3에서 InternalService를 호출하는 구조로 변경해서 AOP가 적용된다.
JDK는 인터페이스를 기반으로 프록시를 생성하고, CGLIB는 구체 클래스를 기반으로 프록시를 생성한다.
프록시를 만들떄 제공하는 ProxyFactory에 proxyTargetClass에 옵션을 true, false 값에 따라 프록시를 선택해 쓸 수 있다.
@Slf4j
public class ProxyCastingTest {
@Test
void jdkProxy() {
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(false); // JDK 프록시
// 프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
// cannot be cast to class hello.aop.member.MemberServiceImpl -> 실패한다. ( 구체타입으로는 실패 ) ( ClassCastException )
Assertions.assertThrows(ClassCastException.class, () -> {
MemberServiceImpl castMemberService = (MemberServiceImpl) memberServiceProxy;
});
}
@Test
void cglProxy() {
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(true); // CGLIB 프록시
// 프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
// CGLIB 프록시를 캐스팅 구현 성공
// CGLIB는 구체클래스를 기반으로 프록시 생성
MemberServiceImpl castMemberService = (MemberServiceImpl) memberServiceProxy;
}
}
MemberService로는 캐스팅을 성공하지만 MemberServiceImpl로 캐스팅을 할 경우 ClassCastException이 나오는 것을 확인할 수 있다.
-cglProxy()
JDK 프록시와 다르게 구레클래스를 기반으로 프록시를 생성할 수 있기 떄문에 MemberServiceImpl로 캐스팅을 하더라도 성공한다.
그렇다면 Test는 통해 직접 주입을 해보자!
@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"}) // 기본적으로 CGLIB를 사용한다. 그래서 false줘서 JDK로 바꾼다.
@Import(ProxyDIAspect.class)
public class ProxyDITest {
@Autowired
MemberService memberService;
@Autowired
MemberServiceImpl memberServiceImpl;
@Test
void go() {
log.info("memberService class = {}", memberService.getClass());
log.info("memberServiceImpl class = {}", memberServiceImpl.getClass());
memberService.hello("hello");
}
}
여기서 보면 현재 에러가 나오는 것을 확인할 수 있습니다.
에러 내용 : BeanNotOfRequiredTypeException: Bean named 'memberServiceImpl' is expected to be of type 'hello.aop.member.MemberServiceImpl' but was actually of type 'com.sun.proxy.$Proxy55'
MemberServiceImpl 빈이 'hello.aop.member.MemberServiceImpl' 타입이어야 하는데 현재 memberServiceImpl' 빈이 실제로 'com.sun.proxy.$Proxy55'라는 다른 타입의 프록시 객체로 등록되었음을 의미하기 떄문에 적용이 되지 않습니다. 이 문제는 AOP 환경에서 자주 나오는 오류입니다. ( JDK는 구체클래스를 주입하면 에러가 나온다. )
@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=true"}) // 기본적으로 CGLIB를 사용한다. 그래서 false줘서 JDK로 바꾼다.
@Import(ProxyDIAspect.class)
public class ProxyDITest {
@Autowired
MemberService memberService;
@Autowired
MemberServiceImpl memberServiceImpl;
@Test
void go() {
log.info("memberService class = {}", memberService.getClass());
log.info("memberServiceImpl class = {}", memberServiceImpl.getClass());
memberService.hello("hello");
}
}
JDK와 다르게 CGLIB는 성공하는 것을 볼 수 있다. 이유는 CGLIB는 구체 클래스를 기반으로 프록시를 생성하기 때문에 MeberServiceImpl을 기반으로 프록시를 생성한다. 따라서 MemberServiceImpl은 물론 MemberServiceImpl이 구현한 인터페이스인 MemberService도 캐스팅이 가능하다.
- CGLIB의 한계
대상 클래스에 기본 생성자가 필수
생성자가 2번 호출되는 문제
final 키워드 클래스, 메서드 사용 불가 (final이면 상속이 되지 않는다.)
- 스프링의 해결책
3.2 버전 이후부터는 스프링 내부에 함께 CGLIB가 패키징되었다. 그래서 라이브러리 추가 없이 CGLIB를 사용가능하다.
스프링 4.0에서는 CGLIB의 기본 생성자 필수인 문제가 해결되었다. Objenesis라는 특별한 라이브러리를 사용해 기본 생성자 없이 객체가 생성 가능하다. Objenesis떄문에 두번 호출도 해결이 되었다.