ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring] 스프링 핵심 원리 - 스프링 AOP 주의사항
    Study/Spring 2025. 5. 7. 06:47

    프록시와 내부 호출 - 문제

    • 개요
      • 스프링은 프록시 방식의 AOP를 사용
      • AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)를 호출해야
      • 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않으므로 Advice(부가 기능)도 호출되지 않음
    • 일반적인 상황
      • AOP를 적용하면 스프링은 대상 객체 대신에 프록시 객체를 스프링 빈으로 등록
      • 따라서 스프링은 의존관계 주입 시 항상 프록시 객체를 주입
      • 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않음
    • 문제 상황
      • 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생
    • 프록시 방식의 AOP 한계 : 메서드 내부 호출에 프록시를 적용할 수 없음

     

    프록시와 내부 호출 - 대안

    1. 자기 자신 주입

    • 내부 호출을 해결하는 가장 간단한 방법은 자기 자신을 의존관계 주입 받는 것
      • 생성자 주입 방식은 순환 사이클을 만들기 때문에 사용 불가하므로 setter 주입 사용해야 함

     

    예시)

    더보기
    import lombok.extern.slf4j.Slf4j;
    import org.aspectj.lang.JoinPoint;
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.annotation.Before;
    
    @Slf4j
    @Aspect
    public class CallLogAspect {
    
        @Before("execution(* com.example.demo.proxy.internalcall..*(..))")
        public void doLog(JoinPoint joinPoint) {
            log.info("app={}", joinPoint.getSignature());
        }
    }
    • 아래 CallServiceV1에 AOP를 적용하기 위해 Aspect 정의함
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Component;
    
    @Slf4j
    @Component
    public class CallServiceV1 {
    
        private CallServiceV1 callServiceV1;
    
        @Autowired
        public void setCallServiceV1(CallServiceV1 callServiceV1) {
            this.callServiceV1 = callServiceV1;
        }
    
        public void external() {
            log.info("call external");
            callServiceV1.internal(); // 외부 메서드 호출로 변경
        }
    
        private void internal() {
            log.info("call internal");
        }
    }
    • 자기자신을 의존관계 주입으로 받는 경우 생성자 주입은 순환 사이클을 만들기 때문에 사용할 수 없음 (위에서는 setter 주입 대신 사용)
    • 스프링에서 AOP가 적용된 대상을 의존관계 주입 받으면 주입 받은 대상은 실제 자신이 아니라 프록시 객체임
    • 위에서 수정자를 통해 주입받은 callServiceV1은 프록시이므로 AOP를 적용할 수 있음

     

     

    2. 지연 조회

    • 스프링 빈을 지연해서 조회하는 방식으로, ObjectProvider 혹은 ApplicationContext를 사용하면 됨
      • ObjectProvider는 스프링 컨테이너에서 객체를 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있음

     

    예시)

    더보기

    아래 CallServiceV2에 AOP를 적용하기 위해 Aspect 정의한 상태라고 가정

    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.ObjectProvider;
    import org.springframework.stereotype.Component;
    
    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class CallServiceV2 {
    
        // private final ApplicationContext applicationContext;
        private final ObjectProvider<CallServiceV2> callServiceProvider;
    
        public void external() {
            log.info("call external");
            // CallServiceV2 callServiceV2 = applicationContext.getBean(CallServiceV2.class);
            CallServiceV2 serviceV2 = callServiceProvider.getObject();
            serviceV2.internal(); // 외부 메서드 호출
        }
    
        private void internal() {
            log.info("call internal");
        }
    }
    • `callServiceProvider.getObject()`를 호출하는 시점에 스프링 컨테이너에서 빈을 조회함 (프록시 객체가 반환됨)

     

    3. 구조 변경

    • 가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것 (실제 이 방법을 가장 권장함)
      • 구조를 변경하는 것은 클래스 분리말고도 다양한 방법들이 있을 수 있음

     

    예시)

    더보기
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    
    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class CallServiceV3 {
    
        private final InternalService internalService;
    
        public void external() {
            log.info("call external");
            internalService.internal(); // 외부 메서드 호출
        }
    }
    • 내부 호출을 InternalService라는 별도의 클래스로 분리함

     

     

    프록시 기술과 한계

    JDK 동적 프록시

    • 구체 클래스로 타입 캐스팅 불가
    • 인터페이스를 기반으로 프록시를 생성하기 때문 (인터페이스를 구현하여 프록시 생성)

     

    예시)

    더보기
    • 인터페이스를 구현하여 프록시를 생성하므로 인터페이스의 구체 클래스로 타입 캐스팅 불가
    • 구체 클래스를 상속하여 프록시를 생성하므로 인터페이스와 구체 클래스로 타입 캐스팅 가능

     

    CGLIB

    • 구체 클래스를 상속받기 때문에 다음과 같은 문제가 있음
      • 대상 클래스에 기본 생성자 필수
      • 대상 클래스의 생성자 2번 호출
      • final 키워드 클래스, 메서드 사용 불가

     

    예시)

    더보기

    대상 클래스에 기본 생성자 필수

    • 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 함
      • 이 부분이 코드상 생략되어 있다면 컴파일 시 컴파일러가 자식 클래스의 생성자 첫 줄에 부모 클래스의 기본 생성자를 호출하는 super()가 자동으로 추가함 (이것은 자바 문법 규약)
    • CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출함. 따라서 대상 클래스에 기본 생성자를 만들어야 함
      • 기본 생성자는 파라미터가 하나도 없는 생성자
      • 생성자가 하나도 없으면 자동으로 만들어짐

     

    대상 클래스의 생성자 2번 호출 문제

    • 아래 상황으로 생성자가 2번 호출됨
      • 실제 target의 객체를 생성할 때
      • 프록시 객체를 생성할 때 부모 클래스의 생성자 호출

     

    final 키워드 클래스, 메서드 사용 불가

    • final 키워드가 클래스에 있으면 상속이 불가능하고, 메서드에 있으면 오버라이딩 불가능함
    • CGLIB는 상속을 기반으로 하기 때문에 두 경우 프록시가 생성되지 않거나 정상 동작하지 않음

     

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

    스프링의 기술 선택 변화

    • 스프링 3.2부터 CGLIB를 스프링 내부에 함께 패키징
      • 스프링은 CGLIB 라이브러리를 스프링 내부에 함께 패키징해서 별도의 라이브러리 추가 없이 CGLIB를 사용할 수 있게 됨
    • CGLIB 기본 생성자 필수 문제 해결
      • 스프링 4.0부터 CGLIB의 기본 생성자가 필수인 문제가 해결됨
      • objenesis라는 특별한 라이브러리를 사용해 기본 생성자 없이 객체 생성이 가능해짐 (해당 라이브러리는 생성자 호출 없이 객체를 생성할 수 있게 해줌)
    • 생성자 2번 호출 문제
      • 스프링 4.0부터 CGLIB의 생성자 2번 호출 문제가 해결됨 (target 객체 생성할 때만 1번 호출됨)
      • 이것도 objenesis 라이브러리 덕분에 가능해짐
    • 스프링 부트 2.0부터 CGLIB 기본 사용
      • 스프링 부트는 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true로 설정해서 사용함 (CGLIB 사용)
      • 인터페이스가 있어도 JDK 동적 프록시가 아닌 항상 CGLIB를 사용해 구체 클래스를 기반으로 프록시를 생성함
      • 다음과 같이 설정하면 JDK 동적 프록시도 사용할 수 있음
        • application.properties > spring.aop.proxy-target-class=false

     

    정리하자면,

    • 스프링 부트 2.0에서부터 CGLIB를 기본으로 사용하도록 함
    • CGLIB를 사용하면 JDK 동적 프록시에서 동작하지 않는 구체 클래스 주입이 가능함
    • 추가로 objenesis 라이브러리를 사용해 CGLIB의 단점들이 많이 해결됨

     

    참고

    댓글

Designed by Tistory.