@Slf4j
@Component
public class InternalCallSevice {
@MyLogging
public void external() {
log.info("external call");
internal(); // 내부 메서드 호출
}
@MyLogging
public void internal() {
log.info("internal call");
}
}
메서드 단위로 로그를 찍는 애노테이션 방식의 AOP(@MyLogging)를 설계했다고 가정하자. 본래의 의도대로라면 external()에서 한번 찍히고 external() 내부에 존재하는 internal()을 호출할 때 다시 AOP가 동작되어야 한다.
이 예시에서 프록시 패턴의 단점이 드러난다.
테스트로 단순히 위 빈 객체를 주입받아 external()를 실행하면, 아래의 콘솔화면과 같은 결과를 얻는다.

external() 내부에서 실행된 internal()에 대해 AOP가 동작하지 않고있다. 자세히 따져보자면 internal()은 빈으로 등록된 프록시.internal()가 동작한 것이 아니라 타겟.internal()이 동작한 것이다.

대안은 여러가지가 존재한다.
자기 자신 주입 : (자신의 프록시에 해당하는 빈을 자신에게 주입 받으므로 생성자로 주입받는 순간 순환 사이클이 만들어진다. 부트 2.6부터는 이러한 순환 참조 코드가 금지된다. 즉 대안으로 사용하지 말란 소리)지연 조회 : ObjectProvider<프록시 빈>으로 하여금 호출마다 ObjectProvider의 허락을 받는 구조로 만들 수 있다. 물론 ObjectProvider는 등록된 빈을 기준으로 메서드를 호출해준다. 구조 변경 : 그냥 internal() 로직을 외부 클래스 아래에 따로 만든다. 구조가 변경되어 내부호출이라고도 볼 수 없게 된다. 내부호출 구조를 외부호출로 바꾸라는 것이다.대안으로, 자신을 주입하는 방식은 적절치않고 지연조회 또한 애매하다.
public void external() {
log.info("external call");
InternalCallSevice internalCall = callServiceProvider.getObject();
internalCall.internal();
}
ObjectProvider로 한번 감싸다보니 사용할 때마다 ObjectProvider를 거친 코드를 만들어야줘야 한다. 스프링은 이러한 상황에서 구조 변경으로의 해결을 권장한다.
생각해볼 점은 로그AOP를 적용한다는 가정하에, 그렇다면 우리가 이때동안 리펙토링의 관점에서 주요 기능 메서드 내부 로직에서 private 메서드로 코드뭉치를 빼내었던 것을 모두 클래스화 시켜야할까?
작은 단위의 메서드, 즉 하나의 메서드에서 가독성과 유지보수를 위해 떼어놓은 private 메서드에 AOP를 적용할 상황이 많지 않다는 것이다. Save로직에서 적절하지 않은 item이 save되는 것을 방지하고자 간단한 if문으로 필터링하는 로직을 private 메서드로 분리했을 때 이런 작은 부분까지 로깅되는 것은 매우 과하기 때문에 AOP를 적용하지 않을 것이다.
다만, AOP가 우리의 예상처럼 적용되지 않을 때 이러한 지식을 알고있다면 빠르게 의심하여 문제를 수정할 수 있을 것이다.
이번 문제는 Cglib과Jdk Proxy에 관련한 문제이다. 기본적인 개념은 인터페이스가 없고 콘크리트 클래스만 존재할 경우 CGLIB 프록시 방식을 선택하고, 인터페이스가 존재할 경우에는 JDK 프록시를 선택한다.
JDK Proxy와 Cglib 프록시는 다음과 같은 구조를 가진다.

이때 Proxy는 타겟으로의 타입 변환이 불가능하다.
//RealSubject는 Subject의 구현
//proxy또한 Subject의 구현
RealSubject proxyTypeChange = (RealSubject) proxy -> 예외 발생(ClassCastException 예외)
Cglib 프록시는 Target을 직접 상속받는다. 그렇기에 타입 캐스팅이 가능하다 만약 위의 코드를 연결해서 설명해보자면 proxy가 RealSubject의 프록시라면 proxy는 RealSubject로의 타입 캐스팅도 가능하고 Subject로의 타입 캐스팅도 가능해진다.
즉 JDK 프록시는 타겟 객체로의 캐스팅이 제한되며 Cglib은 자유롭다.
AOP가 걸려있는 빈을 생각해보자 빈으로 등록될 객체는 프록시 객체가 될 것이다. JDK 프록시는 타겟 객체로의 캐스팅이 제한된다고 했다.
public class ProxyDITest {
@Autowired MemberService memberService; //JDK 동적 프록시 OK, CGLIB OK
@Autowired MemberServiceImpl memberServiceImpl; //JDK 동적 프록시 X, CGLIB OK
MemberService가 인터페이스이고, MemberServiceImpl은 MemberService의 구현이다.
이때 MemberServiceImpl에 대해 프록시를 생성한다면 JDK 방식은 MemberService를 구현한 프록시가 탄생할 것이고 Cglib의 경우에는 MemberServiceImpl의 자식을 만들어낼 것이다.
우리가 만약 jdk로 빈 등록된 프록시를 MemberServiceImpl 타입으로 주입받길 원한다면 에러가 나는 것이다. 이 경우 우리는 MemberService 타입으로 받아야만 한다.
보통적으로 활용할 때 인터페이스 타입으로 구현체를 받는 것이 바람직하다. 그러므로 MemberServiceImpl 타입으로 주입받는 것 자체가 초보적인 형태라고 말할 수 있다. 일반적으로 본다면 정확한 지적이지만 테스트, 또는 여러가지 이유로 구체 클래스를 직접 주입받아야할 상황이 생길 수 있다.
위의 서술을 본다면 "그럼 Cglib 사용하면 되겠네!" 로 결론에 이를 수 있지만 과거에는 Cglib또한 과거에는 단점이 존재했다. 결론적으로는 설명할 단점들을 spring이 해결하면서 스프링 AOP의 기본 proxy방식이 Cglib으로 굳어졌다.
자바 언어 규칙에서 상속을 받을 경우 자식 클래스의 생성자를 호출할 때 부모 클래스의 생성자도 호출해야한다.
Enhancer를 통해 CGLIB 프록시 클래스를 동적으로 생성한다. 이 과정에서 CGLIB은 타겟 클래스의 생성자를 호출한다.
빈 후처리기 동작을 통해 타겟 객체를 위시한 프록시 객체가 빈으로 등록될 때 다시 타겟의 생성자를 호출한다.
그렇기에 두번의 타겟 생성자 호출이 이루어진다.
두번 호출 되는 것이 왜 문제일까? 어디에 피해를 주는 것일까?
예로, 만약 생성자에 다른 작업이 들어가는 경우, 만약 외부 리소스와의 연결에 관련된 코드가 들어간다면 외부 리소스 연결 코드가 두번 진행되는 것이다. 한번 호출에 두번 연결될 수 있는 상황이 생기는 것이다.
이러한 상황에 놓이기때문에 생성자에 우리가 해줄 작업에 제한이 생기게 되는 것이다.
스프링은 4.0버전부터 objenesis라이브러리를 통해 기본 생정자 없이 프록시 객체 생성이 가능하도록 이를 극복하였다.
현재는 Spring은 기본적으로 프록시를 생성하는 표준을 Cglib로 잡았다. 내부 호출 문제를 잘 이해하여 프록시 객체의 행동이 적용되지 않을 때 의심포인트를 적절히 설정할 수 있어야 하기에 위의 내용들을 잘 파악해야한다. 이는 @Transactional 사용에 있어어도 동일한 개념의 주의사항으로 들어간다.
Cglib과 JDK프록시의 차이를 확실히 이해하고 스프링이 어떤 기술을 표준으로 설정했는지 이해하여 프록시가 생성될 때 Cglib과 jdk를 구분하여 jdk의 단점을 인지하고 문제가 될 수 있는 영역을 미리 파악할 수 있다.