AOP 주의 사항

bw1611·2023년 10월 16일
0

📕 프록시와 내부 호출


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());
    }
}
  • extenal을 호출하면 내부 호출로 internal을 호출하고 있다.
@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();
    }
}
  • external test에서 external을 호출하게 된다면 external 메서드에는 프록시가 잘 걸리는 것을 볼 수 있다. 하지만 내부로 호출한 internal은 프록시가 적용이 되지 않는다. 스프링 aop에서는 내부 호출하는 메서드는 프록시를 적용할 수 없는 한계가 있다.
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

그렇다면 내부 호출할 경우 프록시를 적용하기 위해서는 어떻게 해야할까?

  • 방법 1 : 자기 자신을 주입하기

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");
    }
}
  • setter를 통해 외부 메서드 호출로 변경해주면 내부 호출도 프록시에 등록되는 것을 확인할 수 있다.
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를 추가해주도록 하자!

  • 방법 2 : 지연 조회

방법 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");
    }
}
  • 방법 3 : 구조 변경

CallService3에서는 내부 호출이 발생하지 않도록 구조를 변경해보도록 하겠다.

  • 내부 메서드로 호출하던 intenal을 따로 클래스로 분리해주고 생성자로 주입해준다.
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {

    private final InternalService internalService;

    public void external() {
        log.info("call external");
        internalService.internal(); //외부 메서드 호출
    }
}
  • 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;
    }
}
  • jdkProxy()

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떄문에 두번 호출도 해결이 되었다.

profile
Java BackEnd Developer

0개의 댓글