본문 바로가기

Spring

스프링 AOP 내부호출 문제와 한계

이번 포스팅에서는 스프링 AOP 를 적용했을 때 발생할 수 있는 문제와 한계점에 대해서 설명하고 스프링은 이러한 문제를 어떻게 해결했는지를 살펴본다.

 

대표적인 두 가지 문제는 다음과같다.

  1. 내부호출이 일어났을 때 프록시가 적용되지 않는 상황
  2. JDK 동적 프록시를 사용했을 때 타입캐스팅이나 의존관계 주입에서 오류가 발생할 수 있다.

이 두 가지 문제 상황을 살펴보자.

프록시와 내부호출 문제

  • 먼저 아래의 코드를 살펴보자.
public class CallServiceV0 {
    public void external(){
        log.info("call external");
        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());
    }
}

  • 위의 코드에서는 CallService 라는 서비스 및 패키지에 대해서 Aspect로 부가기능을 적용했다.
  • 이 두 클래스를 빈으로 등록했을 때 우리는 당연히 두 함수 모두 부가기능이 적용될 것으로 예상할 수 있다.
  • 다음은 해당 코드에 대한 테스트 코드와 그 결과이다.
@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {

    @Autowired
    CallServiceV0 callServiceV0;

    @Test
    void external() {
        log.info("target={}",callServiceV0.getClass());
        callServiceV0.external();
    }
}

2022-06-15 08:44:59.401  INFO 83500 --- [    Test worker] h.aop.internalcall.CallServiceV0Test     : target=class hello.aop.internalcall.CallServiceV0$$EnhancerBySpringCGLIB$$7359caac
2022-06-15 08:44:59.405  INFO 83500 --- [    Test worker] h.aop.internalcall.aop.CallLogAspect     : aop=void hello.aop.internalcall.CallServiceV0.external()
2022-06-15 08:44:59.431  INFO 83500 --- [    Test worker] hello.aop.internalcall.CallServiceV0     : call external
2022-06-15 08:44:59.432  INFO 83500 --- [    Test worker] hello.aop.internalcall.CallServiceV0     : call internal

  • 우리는 external() 메서드와 internal() 메서드 모두에 대해 AOP를 적용했음에도 internal 메서드에 대한 aspect가 적용되지 않음(로그를 출력하지 않음)을 확인할 수 있다.
    • 왜 이런 문제가 발생했을까?
    • external() 내부에서 internal() 함수를 호출할 때에는 this.internal() 이 자동적으로 호출된다. 이때 this는 프록시 객체가 아닌 실제 객체(타겟)이므로 어찌보면 aspect가 적용되지 않은 것은 당연하다.
  • 이러한 문제의 해결방안은 여러 가지인데 본질은 같다. 실제 객체가 아닌 프록시를 호출하도록 변경해주면 된다.
  • 프록시를 호출하도록 변경하는 세 가지 대안을 살펴보자.

대안 1. 자기자신 주입

@Component
public class CallServiceV1 {

    private CallServiceV1 callServiceV1;

    @Autowired
    public void setCallServiceV1(CallServiceV1 callServiceV1) {
        log.info("callServiceV1 setter={}", callServiceV1.getClass());
        this.callServiceV1 = callServiceV1;
    }

    public void external(){
        log.info("call external");
        callServiceV1.internal(); //내부 메서드 호출
    }

    public void internal() {
        log.info("call internal");
    }
}

  • 위의 코드에서 callService를 수정자 주입으로 의존성 주입을 해주게된다. 이렇게 하면 callServiceV1에 들어가는 객체는 Bean으로 등록된 프록시가 들어가게 되고 이 프록시객체를 통해서 internal() 메서드를 호출하게 되므로 다시 한 번 에스팩트가 추가될 수 있다. 

  • 참고로 이러한 구조를 순환 참조 구조라고 한다. 프록시는 실제 객체를 참조하고 실제 객체는 프록시를 참조하는 구조이다.
  • 스프링 최신 버전에서는 이러한 순환 참조 구조일 때 에러가 발생하게 되는데 spring.main.allow-circular-references=true 로 설정하면 순환참조를 허용할 수 있다.

대안 2. 지연 조회

  • 두 번째 방법은 지연 조회이다. 지연 조회를 하는 방법은 두 가지가 있다.
    1. ObjectProvider 를 사용하여 조회한다.
    2. ApplicationContext 를 통해 조회한다.
@Component
public class CallServiceV2 {

//    private final ApplicationContext applicationContext;
    private final ObjectProvider<CallServiceV2> callServiceProvider;

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

    public void external(){
        log.info("call external");
//        CallServiceV2 callServiceV2 = applicationContext.getBean(CallServiceV2.class);
        CallServiceV2 callServiceV2 = callServiceProvider.getObject();
        callServiceV2.internal(); //내부 메서드 호출
    }

    public void internal() {
        log.info("call internal");
    }
}

  • 지연 조회를 통해 프록시 객체를 조회하고 이를 통해 내부 호출 하게하면 에스팩트를 추가할 수 있다.
  • 이렇게 하면 순환참조가 발생하지 않는다.

대안 3. 구조변경

  • 사실 가장 좋은 해결책은 구조를 변경하는 것이다. 내부호출을 외부호출로 바꾸는 것!
  • 새로운 클래스를 정의하고 이 클래스를 참조하여 메서드 호출을 하면 AOP가 적용될 것이다.

프록시 기술과 한계 - 타입캐스팅

  • 스프링 프록시 팩토리로 프록시를 생성할 때에는 JDK 동적 프록시와 CGLIB를 통해 생성된다. 우리는 JDK 동적프록시는 인터페이스가 있는 경우, CGLIB은 (인터페이스가 있어도 됨)구체클래스가 있는 경우에 사용할 수 있다고 했다.
  • 그렇다면 이런 차이로 인해 발생하는 문제가 무엇일까?
    • JDK 동적프록시는 인터페이스를 기반으로 프록시를 생성하기 때문에 구체클래스로 타입캐스팅을 하게 되면 오류가 발생한다.
  • 아래의 코드를 살펴보자
@Test
void jdkProxy(){
    MemberServiceImpl target = new MemberServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.setProxyTargetClass(false); //jdk 동적 프록시

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

    //실패 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();

    log.info("memberServiceProxy = {}", memberServiceProxy.getClass());
    //성공 ClassCastException 발생
    MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
}

  • 코드에서 확인할 수 있듯이 JDK 동적 프록시로 만들어진 객체를 구체 클래스 타입으로 캐스팅했을 때 ClassCastException이 발생함을 확인할 수 있다.
  • 반면 CGLIB 으로 프록시를 생성했을 때에는 구체클래스를 상속받아 프록시를 만들기 때문에 구체클래스든 인터페이스든 캐스팅을 해도 문제가 없다.
  • 여기까지 봤을 때 이러한 특징은 당연한 것이고 이게 무슨 문제란 말인가? 라고 생각할 수 있다.
  • 이게 어떠한 문제를 일으킬 수 있는지 살펴보자

프록시 기술과 한계 - 의존관계 주입

@Import(ProxyDIAspect.class)
@SpringBootTest
//@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"})
//@SpringBootTest(properties = {"spring.aop.proxy-target-class=true"})
public class ProxyDITest {
    @Autowired
    MemberService memberService;
    @Autowired
    MemberServiceImpl memberServiceImpl;

    @Test
    void go() {
        log.info("memberService class={}", memberService.getClass());
        log.info("memberServiceImpl class={}", memberServiceImpl.getClass());
        memberServiceImpl.hello("hello");
    }
}

  • 위의 코드를 보면 Aspect가 적용된 서비스에 대해 의존성 주입을 받으려 하는 것이다.
  • properties 에 spring.aop.proxy-target-class=true 로 세팅하면 타겟 클래스로 프록시를 생성한다는 의미이므로 CGLIB으로 프록시를 생성하고 false로 세팅하면 인터페이스 기반으로 프록시를 생성하게된다.
  • 결과적으로 인터페이스에 의존관계 주입은 두 방식 모두 정상이지만 구체클래스에 의존관계를 주입할 때에는 JDK 동적 프록시로는 이전에 살펴보았듯 타입캐스팅 오류가 발생한다.

프록시 기술과 한계 - CGLIB

  • 지금까지는 JDK 동적프록시의 단점에 대해 살펴봤는데 CGLIB은 어떠한 한계가 있을까?
  1. 대상 클래스에 기본 생성자가 필수이다.
    • CGLIB에서는 대상 클래스를 상속하여 프록시 객체를 생성하는데 이 때 프록시 생성자에서 부모 생성자를 호출한다.
  2. 생성자 2번 호출 문제
    1. 타겟 클래스를 생성할 때 실제 객체 생성자 호출
    2. 프록시 객체 생성할 때 부모 클래스(타겟 실제 객체) 생성자 호출
  3. final 키워드 클래스, 메서드 사용 불가
    • final 키워드가 있으면 상속 불가

프록시 기술과 한계 - 스프링의 해결책

  • 위에서 설명했듯이 여러가지 문제가 있지만 스프링은 이미 이러한 문제를 고민하고 해결했다.
  • 스프링은 objenesis 라는 라이브러리를 통해 기본생성자 없이도 객체 생성이 가능하도록 했고, 생성자 2번 호출 문제도 1번 호출로 가능하게 했다.
  • 따라서 스프링 부트 2.0 부터는 CGLIB 을 기본으로 사용하도록 했다.
    • spring.aop.proxy-target-class=true (default)

Reference

  • 스프링 핵심 원리 - 고급편, 김영한