백앤드(스프링)

AOP 2편[내부 호출과 프록시 기술의 한계, 마무리]

유승혁 2022. 10. 30. 17:45

 AOP 마지막 글이다! 진짜 이것까지 정리하면 완전정복!! AOP를 알기 전과 지금 알게 되면서 정말 많은 구현을 해보고 싶다. 너무 재미있게 배웠고 이것저것 떠오르는 아이디어가 많다. 기존에 적용했던 프로젝트에도 AOP를 이용하여 좀 더 깔끔하게 정리하고 싶은 욕구도 샘솟는다. 마지막 정리 하기 이전에 영한님께 정말 감사인사 드리고 싶다.. 꼭 영한님 같은 개발자가 될거다.

 

1. 프록시와 내부 호출 문제

 @Transactional 어노테이션을 공부하면서 얼핏 들었던 문제였다.

@Slf4j
@Component
public class CallServiceV0 {

    public void external(){
        log.info("call external");
        internal();//내부 메서드 호출(this.internal()) this는 자바에서 자동 생략.
    }
    public void internal(){
        log.info("call internal");
    }
}
============================================================================
@Slf4j
@Aspect
public class CallLogAspect {

    @Before("execution(* hello.aop.internalcall..*.*(..))")
    public void doLog(JoinPoint joinPoint){
        log.info("aop={}", joinPoint.getSignature());
    }
}

만약 위와 같은 경우 external을 호출했을때 external 내부에 internal을 호출시 CallLogAspect가 과연 돌아갈까? 정답은 아니다. 왜냐하면 아래 그림을 보면 잘 이해가 될 것이다.

 외부(클라이언트)에서 callService를 호출할 때는 스프링 컨테이너에 있는 callService를 불러낸다. 이때 callService는 빈 후처리기에 의해 callService가 아닌 callService@EnhacerByCGLIB와 같은 형식의 프록시가 들어있다. 그렇기 때문에 외부에서 부른 callService는 프록시가 동작하므로 어드바이스가 동작한다. 하지만, 내부에서 호출할 경우 프록시가 아닌 callService를 호출하므로 당연하게 어드바이스가 동적하지 않는다.

 이와 같은 경우 어떻게 해결할까? 물론 런타임이 아닌 다른 방식의 위빙을 적용할 수 있지만 쉽지 않다. 그냥 이 안에서 해결해 보자.

 

1.1 자기 자신 주입받기

private CallServiceV1 callServiceV1;

@Autowired
public void setCallServiceV1(CallServiceV1 callServiceV1) {
    this.callServiceV1 = callServiceV1;
}

callService 내부에 자기 자신을 주입받는다. 이때 생성자로 주입 받게 되면 안된다. 그 이유는? 스프링이 올라갈때의 순서상

[컨테이너 -> 빈 객체 생성 -> 의존관계 주입 -> 빈 초기화] 의 순서로 동작한다. 이때 생성 할때 자기 자신이 있어야 생성하는데 없는데 어떻게? 그래서 순환 생성 에러가 발생하면서 스프링이 시작되지 못한다. 이에 setter 주입으로 해야하는데 스프링 부트 2.6에서는 기본적으로 setter 자기 자신 주입도 막아두었다.

 

1.2 지연 조회

 이럴 경우 난 바로 생각났던 것이 DL이었다. 조회를 늦추면 되니깐. 두 가지 방식이 존재한다.

private final ApplicationContext applicationContext;//너무 큼..
private CallServiceV2 callServiceProvider;
public CallServiceV2(ApplicationContext applicationContext) {
    this.callServiceProvider = callServiceProvider;
}
...사용시..
this.callServiceProvider = applicationContext.getBean(CallServiceV2.class);
======================================================================
private final ObjectProvider<CallServiceV2> callServiceProvider;

public CallServiceV2(ObjectProvider<CallServiceV2> callServiceProvider) {
    this.callServiceProvider = callServiceProvider;
}

 스프링 컨테이너를 주입 받거나 ObjectProvider로 감싸는 방법이다. 이 방식일 경우 컨테이너를 통해 가져오기 때문에 문제가 해결된다. 하지만 컨테이너는 너무 두껍다는 문제가 있어서 ObjectProvider가 더 좋다. 내가 만약 문제를 해결한다면 후자를 선택할 것 같다.

 

1.3 구조 변경

 그냥 단순히 external과 internal을 두 개의 클래스로 쪼개는 것이다. 사실 이게 제일 맞다고 생각한다. private이 아닌 public. 즉 외부에서도 접근할 메서드이라는 점. 내부에서도 호출 된다는 점. 이럴거면 분리해내는게 구조적으로 맞지. 도저히 분리해낼 상황이 아니라면 ObjectProvider를 사용하겠지만.. 그럴 일은 잘 없을 것같다.

 

2. 프록시 기술의 한계

스프링은 두 가지 방식의 프록시를 제공했다. JDK 동적 프록시와 CGLIB. 각각에 장단점이 존재한다.

  JDK 동적 프록시 CGLIB
장점 인터페이스 기반.(이게 엄청난 장점) 캐스팅 문제 해결
단점 this와 target문제 처럼 타입 캐스팅에 문제가 생긴다. 기본 생성자 필요.
final이 있으면 프록시 생성 불가.
생성자 클래스 두 번 호출

이렇게 정리할 수 있고 하나씩 자세히 알아보자.

 

2.1 타입 캐스팅 - JDK 동적 프록시의 문제

@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();
        //구체 타입으로 캐스팅 불가능.. 프록시는 인터페이스 기반해서 만들었기 때문에 하나의 구현체일 뿐 MemberServiceImpl은 알지 못함.
        //JDK 동적 프록시를 구현 클래스로 캐스팅 시도 실패, ClassCastException 예외 발생
        assertThrows(ClassCastException.class, () -> {
            MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
        });
    }

    @Test
    void cglibProxy(){
        MemberServiceImpl target = new MemberServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.setProxyTargetClass(true);//CGLIB 프록시

        //프록시를 인터페이스로 캐스팅 성공
        MemberService memberServiceProxy = (MemberService)proxyFactory.getProxy();

        //JDK 동적 프록시를 구현 클래스로 캐스팅 시도 성공
        MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
    }
}

 테스트 코드를 살펴보면 CGLIB일 경우 잘 동작 하지만 JDK 동적 프록시일 경우 예외가 발생한다. 그 이유를 그림을 통해 보자.

 JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성하기 때문에 MemberService만 알지 MemberServiceImpl은 알지 못한다. 단지 호출해낼 뿐이다. 즉 그렇기 때문에 JDK Proxy는 MemberService로 타입 캐스팅이 가능하지만 MemberServiceImpl로는 캐스팅이 불가능 하다. 즉, 만약 어떤 클래스가 MemberServiceImpl을 의존하게 될때, 프록시가 적용 되지 않는 다는 문제가 발발한다.

 반면 아래 그림과 같이 CGLIB는 MemberService가 부모의 부모일 뿐이기 때문에 타입 캐스팅이 가능하다.

2.2 CGLIB 기술의 한계

 위에 표를 통해서 나타냈듯이 3가지 문제가 있다. 하나씩 보자면,,,

 - 대상 클래스의 기본 생성자 필수: 구체 클래스를 상속 받는다. 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다. CGLIB 객체를 생성할때 사용된다.

 - 생성자 2번 호출 문제: CGLIB는 구체 클래스를 상속 받는다. 그렇기 때문에 CGLIB객체를 생성할 때 + 실제 target 객체를 생성할 때 총 2번 호출된다.

 - final 키워드 클래스, 매서드 사용 불가: 만약 class 단위로 final일 경우 상속이 불가하고 method 단위일 경우 오버라이딩이 불가하다.

 

3. 뭐야 그럼 어떻게 해?

 스프링 또한 이에 대한 고민을 했기에 아래의 순서로 스프링을 발전 시켰다.

  • 스프링 3.2: CGLIB를 스프링 내부로 패키징. 스프링 내에 자동으로 있어서 dependency 추가 하지않아도 된다.
  • 스프링 4.0: objenesis라는 라이브러리로 기본 생성자 없이 생성자를 호출한다.이 덕에 기본 생성자 문제 뿐만 아니라 생성자 2번 호출 문제도 해결하였다.
  • 스프링 부트 2.0: JDK 동적 프록시를 사용하지 말고 CGLIB를 기본적으로 사용하게 되었다. 물론 application properties에서 설정하여 JDK 동적 프록시를 사용할 수 있다

물론 final은? 근데 사실 final을 잘 쓸 일이 없다. 나 또한 짧은 스프링 인생 동안 final을 사용해 본적이 없어서 ㅎㅎ. 괜찮을거 같다.

아무튼 스프링은 최종적으로 스프링 부트 2.0에서 CGLIB를 기본으로 사용하도록 결정했다. CGLIB를 사용하면 JDK 동적 프록시에서 동작하지 않는 구체 클래스 주입이 가능하다. 여기에 추가로 CGLIB의 단점들이 이제는 많이 해결되었다.

 

마무리

 AOP에 대해서도 잘 정리 되었다. 하나의 큰 목적을 위해 전반적으로 달려와서 머리속에 정말 잘 기억에 남을 거 같다. 재미있게 배웠고 다시한번 영한님께 정말 감사하다.(🥰🥰) 

 추가적으로 영한님께서 좋은 개발자로 성장하려면 끊임없이 겸손하라고 하셨다. 취준을 해보면서 가고 싶은 회사는 10년 15년 뒤에도 성장할 수 있는 회사에 입사하고 싶다는 생각이 들었다. 그런 회사에 쉽게 들어가지 못할까봐 석사의 길도 조금씩 생각했었다. 하지만 영한님 말씀을 들으면서 반성을 했다.

 스프링에 손을 댄지 10개월 정도 되는 시간에서 사실 어느정도 개발을 잘 한다고 생각했다. AOP를 끝으로 스프링은 이제 거의 마무리 되었고 디비 쪽에 대해서 잘 다루면 정말 훌륭한 개발자가 되겠지? 라고 기대했었다.

 그런것과 동시에 이제 어디서 성장할 수 있지? 라고 고민했다.

이게 끝인가?
이젠 경력이 성장인가?
<함께 자라기> 책에서는 그런 말씀 없으셨는데..  

 하지만 겸손한 자세로 끊임없이 받아들이고 공부하면 끝도 없을 것이다. 라는 영한님의 말씀을 잘 새겨야 겠다. 회사가 중요한게 아니라 내 태도가 성장의 여부를 결정 지을 것이라 깨달았다. 자만하지 말고 꾸준히 배우자.

 

[참고]

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard