ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring 핵심 원리 고급편 - 13. 스프링 AOP - 실무 주의사항
    Spring-Boot/스프링핵심원리 - 고급편 2022. 6. 10. 10:07

    프록시와 내부 호출 - 문제

    - 스프링은 프록시 방식의 AOP를 사용한다

    - 따라서 AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출한다

    - 이렇게 해야 프록시에서 먼저 어드바이스를 호출하고, 이후에 대상 객체를 호출

    - 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 어드바이스도 호출되지 않는다

     

    - AOP를 적용하면 스프링은 대상 객체 대신 프록시를 스프링을 등록

    - 따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다

    - 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생 X

    - 하지만 대상 객체의 내부에서 메서드를 호출하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다

     

    @Slf4j
    @Component
    public class CallServiceV0 {
    
        public void external() {
            log.info("call external");
            internal(); // 내부 메서드 호출 (this.internal())
        }
    
        public void internal() {
            log.info("call internal");
        }
    }

    - 자바 언어에서 메서드를 호출할 때 대상을 지정하지 않으면 앞에 자기 자신의 인스턴스를 뜻하는 this 가 붙게된다.

    - 그러니깐 여기서는 this.internal( ) 이라고 이해하면 된다.

     

    - 결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal()이 되는데, 여기서 this는 실제 대상 객체(target) 의 인스턴스를 뜻한다.

    - 결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 어드바이스도 적용할 수 없다.

     

    - 외부에서 호출하는 경우 프록시를 거치기 때문에 internal( ) 도 어드바이스가 적용 O

     

    * 프록시 방식의 AOP 한계 *

    - 스프링은 프록시 방식의 AOP를 사용한다. 프록시 방식의 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다.

    - 이 문제를 해결하는 방법을 알아보자

     

    * 참고 *

    - 실제 코드에 AOP를 직접 적용하는 AspectJ를 사용하면 이런 문제가 발생하지 않는다.

    - 프록시를 통하는 것이 아니라 해당 코드에 직접 AOP 적용 코드가 붙어 있기 때문에 내부 호출과 무관하게 AOP를 적용

    - 하지만 로드 타임 위빙 등을 사용해야 하는데, 설정이 복잡하고 JVM옵션을 주어야 하는 부담이 있다. 


    대안1 자기 자신 주입

    - 내부 호출을 해결하는 가장 간단한 방법은 자기 자신을 의존관계 주입 받는 것이다.

    @Slf4j
    @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");
        }
    }

    - 생성자 주입은 순환참조 문제가 일어나므로 setter를 이용한다.

     

    - 자신의 인스턴스를 호출하는 것이 아니라 프록시 인스턴스를 통해서 호출하는 것을 확인 가능

      당연히 AOP도 잘 적용된다.

     

    * 주의 *

    - 스프링 부트 2.6부터는 순환 참조를 기본적으로 금지하도록 정책이 변경되었다. 따라서 이번 예제를 스프링 부트 2.6 이상의 버전에서 실행하면 다음과 같은 오류 메시지가 나오면서 정상 실행되지 않는다.

    Error creating bean with name 'callService1': Requested bean in currently in
    creation: Is there an unresolvable circular reference?

    - 이문제를 해결하려면 application.properties 에 다음을 추가해야한다

    spring.main.allow-circular-references=true

    대안2 지연 조회

    - 앞서 생성자 주입이 실패하는 이유는 자기 자신을 생성하면서 주입해야 하기 때문이다

    - 이 경우 수정자 주입을 사용하거나 지금부터 설명하는 지연 조회를 사용하면 된다.

    - 스프링 빈을 지연해서 조회하면 되는데, ObjectProvider(Provider), ApplicationContext 를 사용하면 된다.

    /**
     * ObjectProvider(Provider), ApplicationContext를 사용해서 지연 ( Lazy ) 조회
     */
    @Slf4j
    @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");
        }
    }

    - ApplicationContext 는 너무 많은 기능을 제공한다

    - ObjectProvider 는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있다

    - callServiceProvider.getObject( ) 를 호출하는 시점에 스프링 컨테이너에서 빈을 조회한다

    - 여기서는 자기 자신을 주입 받는 것이 아니기 떄문에 순환 사이클이 발생하지 않는다.

     


    대안3 구조 변경

    - 앞선 방법들은 자기 자신을 주입하거나 또는 Provider를 사용해야 하는 것처럼 조금 어색한 모습을 만들었다

    - 가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것이다. 실제 이 방법을 가장 권장한다.

     

    /**
     * 구조를 분리(변경)
     */
    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class CallServiceV3 {
    
        private final InternalService internalService;
    
        public void external() {
            log.info("call external");
            internalService.internal(); // 외부 메서드 호출
        }
    }

    - 내부 호출자체가 사라지고 callService → internalService 를 호출하는 구조로 변경되었다. 덕분에 자연스럽게 AOP가 적용된다

     

    - 여기서 구조를 변경한다는 것은 이렇게 단순하게 분리하는 것 뿐만 아니라 다양한 방법들이 있을 수 있다

         ㆍ예를 들어서 클라이언트에서 둘다 호출하는 것이다 ( 가능할 때 )

             클라이언트 external( )

             클라이언트 internal( )

     

    * 참고 *

    - AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용된다.

    - 쉽게 이야기해서 인터페이스에 메서드가 나올 정도의 규모에 AOP를 적용하는 것이 적당하다

    - 더 풀어서 이야기하면 AOP는 ' public ' 메서드에만 적용한다. ' private ' 메서드처럼 작은 단위에는 AOP를 적용하지 않는다

    - AOP 적용을 위해 private 메서드를 외부 클래스로 변경하고, public으로 변경하는 일은 거의 없다.

    - 그러나 위 예제와 같이 public 메서드에서 public메서드를 내부 호출하는 경우네는 문제가 발생한다.

    - 실무에서 꼭 한번은 만나는 문제이기에 이번 강의에서 다루었다

    - AOP가 잘 적용되지 않으면 내부 호출을 의심해보자

     


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

    - JDK 동적 프록시와 CGLIB를 사용해서 AOP프록시를 만드는 방법에는 각각 장단점이 있다

    - JDK 동적 프록시는 인터페이스가 필수이고, 인터페이스를 기반으로 프록시를 생성한다

    - CGLIB는 구체 클래스를 기반으로 프록시를 생성한다.

     

    - 물론 인터페이스가 없고 구체 클래스만 있는 경우에는 CGLIB를 사용해야 한다. 그런데 인터페이스가 있는 경우에는 JDK 동적 프록시나 CGLIB 둘중에 하나를 선택할 수 있다

     

    - 스프링이 프록시를 만들 때 제공하는 'ProxyFactory' 에 'ProxyTargetClass' 옵션에 따라 둘중 하나를 선택해서 프록시를 만들 수 있다

         ㆍproxyTargetClass=false : JDK 동적 프록시를 사용해서 인터페이스 기반 프록시 생성

         ㆍproxyTargetClass=true : CGLIB를 사용해서 구체 클래스 기반 프록시 생성

         ㆍ참고로 옵션과 무관하게 인터페이스가 없으면 JDK 동적 프록시를 적용할 수 없으므로 CGLIB를 사용한다

     

    * JDK 동적 프록시 한계 *

    - 인터페이스 기반으로 프록시를 생성하는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다.

     

    * 정리 *

    - JDK 동적 프록시는 대상 객체인 'Impl' 로 캐스팅 할 수 없다

    - CGLIB 프록시는 대상 객체인 'Impl'로 캐스팅 할 수 있다.

    - 프록시를 캐스팅할 일이 많지 않을 것 같은데... 진짜 문제는 의존관계 주입시에 발생한다.

     


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

    @Slf4j
    @SpringBootTest(properties = {"spring.aop.proxy-target-class=false"})   // JDK 동적 프록시
    @Import(ProxyDITest.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());
            memberServiceImpl.hello("hello");
        }
    }

    - @SpringBootTest : 내부에 컴포넌트 스캔을 포함

     

    * JDK 동적 프록시에 구체 클래스 타입 주입 *

    - 실행결과

    BeanNotOfRequiredTypeException: Bean named 'memberServiceImpl' is expected to be of type
    'hello.aop.member.MemberServiceImpl' but was actually of type 'com.sum.proxy.$Proxy54'

    - 타입과 관련된 예외가 발생한다

    - 자세히 읽어보면 memberServiceImpl 에 주입되길 기대하는 타입은 hello.aop.member.MemberServiceImpl 이지만 실제넘어온 타입은 com.sun.proxy.$Proxy54 이다

    - 따라서 타입 예외가 발생한다고 한다.

     

    - @Autowired MemberService memberService : 이 부분은 문제가 없다. JDK Proxy는 ' MemberService ' 인터페이스를 기반으로 만들어진다. 따라서 해당 타입으로 캐스팅할 수 있다.

         ㆍMemberService = JDK Proxy 성립

    - @Autowired MemberServiceImpl memberServiceImpl : 문제는 여기다. JDK Proxy는 ' MemberService ' 인터페이스를 기반으로 만들어진다. 따라서 ' MemberServiceImpl ' 타입이 뭔지 전혀 모른다. 그래서 해당 타입에 주입할 수 없다

         ㆍMemberServiceImpl = JDK Proxy 가 성립하지 않는다

     

    * CGLIB 프록시에 구체 클래스 타입 주입 *

    - @Autowired MemberService memberService : CGLIB Proxy 는 MemberServiceImpl 구체 클래스를 기반으로 만들어진다. MemberServiceImpl 은 MemberService 인터페이스를 구현했기 때문에 해당 타입으로 캐스팅할 수 있다

         ㆍMemberService = CGLIB Proxy 성립

    - @Autowired MemberServiceImpl memberServiceImpl : CGLIB Proxy 는 MemberServiceImpl 구체 클래스 기반으로 만들어진다. 따라서 해당 타입으로 캐스팅할 수 있다

         ㆍMemberServiceImpl = CGLIB Proxy 성립

     

    * 정리 *

    - JDK 동적 프록시는 대상 객체인 ' Impl ' 타입에 의존 관계를 주입할 수 없다

    - CGLIB 프록시는 대상 객체인 ' Impl ' 타입에 의존관계 주입을 할 수 있다.

     

    - 지금까지 JDK 동적 프록시가 가지는 한계점을 알아보았다.

    - 실제로 개발할 때는 인터페이스가 있으면 인터페이스를 기반으로 의존관계 주입을 받는 것이 맞다.

    - DI의 장점이 무엇인가? DI 받는 클라이언트 코드의 변경 없이 구현 클래스를 변경할 수 있는 것이다.

    - 이렇게 하려면 인터페이스를 기반으로 의존관계를 주입받아야 한다.

    - MemberServiceImpl 타입으로 의존관계 주입을 받는 것처럼 구현 클래스에 의존관계를 주입하면 향후 구현 클래스를 변경할 떄 의존관계 주입을 받는 클라이언트의 코드도 함께 변경해야한다.

    - 따라서 올바르게 잘 설계된 애플리케이션이라면 이런 문제가 자주 발생하지는 않는다.

    - 그럼에도 불구하고 테스트, 또는 여러가지 이유로 AOP 프록시가 적용된 구체 클래스를 직접 의존관계 주입 받아야 하는 경우가 있수 있다

    - 이때 CGLIB를 통해 구체 클래스 기반으로 AOP 프록시를 적용하면 된다


    프록시  기술과 한계 - CGLIB

    * CGLIB 구체 클래스 기반 프록시 문제점 *

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

    - 생성자 2번 호출 문제

    - final 키워드 클래스, 메서드 사용 불가 : 상속을 받기때문에

     

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

    - CGLIB는 구체 클래스를 상속 받는다.

    - 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야한다. ( 이 부분이 생략되어 있다면 자식 클래스의 생성자 첫줄에 부모 클래스의 기본 생성자를 호출하는 super( ) 가 자동으로 들어간다 ) 이 부분은 자바 문법 규약이다

    - CGLIB를 사용할 때 CGLIB가 만드는 프록시의 생성자는 우리가 호출하는 것이 아니다.

    - CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출한다.

    - 따라서 대상 클래스에 기본 생성자를 만들어야 한다. ( 기본 생성자는 파라미터가 하나도 없는 생성자를 뜻한다. 생성자가 하나도 없으면 자동으로 만들어진다 )

     

    * 생성자 2번 호출 문제 *

    - CGLIB는 구체 클래스를 상속 받는다. 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 부모 클래스의 생성자도 호출한다. 그런데 왜 2번일까?

         ㆍ1. 실제 target의 객체를 생성할 떄

         ㆍ2. 프록시 객체를 생성할 때 부모 클래스의 생성자 호출 

     

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

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

     

    - 프레임워크 같은 개발이 아니라 일반적인 웹 어플리케이션을 개발할 때는 final 키워드를 잘 사용하지 않는다. 따라서 이부분은 특별히 문제가 되지는 않는다

     

    * 정리 *

    JDK 동적 프록시는 대상 클래스 타입으로 주입할 때 문제가 있고, CGLIB는 대상 클래스에 기본 생성자 필수, 생성자 2번 호출 문제가 있다

     


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

    * 스프링 3.2, CGLIB를 스프링 내부에 함께 패키징 *

    - CGLIB를 사용하려면 CGLIB 라이브러리가 별도로 필요했다.

    - 스프링은 CGLIB 라이브러리를 스프링 내부에 함께 패키징해서 별로의 라이브러리 추가 없이 CGLIB를 사용할 수 있게 되었다. ' CGLIB spring-core org.springframework '

     

    * CGLIB 기본 생성자 필수 문제 해결 *

    - 스프링 4.0 부터 CGLIB의 기본 생성자가 필수인 문제가 해결되었다

    - objenesis 라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능하다

    - 참고로 이 라이브러리는 생성자 호출 없이 객체를 생성할 수 있게 해준다

     

    * 생성자 2번 호출 문제 *

    - 스프링 4.0 부터 CGLIB의 생성자 2번 호출 문제가 해결 되었다

    - 이것도 역시 objenesis 라는 특별한 라이브러리 덕분에 가능해 졌다

    - 이제 생성자가 1번만 호출된다

     

    * 스프링 부트 2.0 - CGLIB 기본 사용 *

    - 스프링 부트 2.0 버전 부터 CGLIB를 기본으로 사용하도록 했다

    - 이렇게 해서 구체 클래스 타입으로 의존관계를 주입하는 문제를 해결했다.

    - 스프링 부트의 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true 로 설정해서 사용한다

    - 따라서 인터페이스가 있어도 JDK 동적 프록시를 사용하는 것이 아니라 항상 CGLIB를 사용해서 구체클래스를 기반으로 프록시를 생성한다

    - 물론 스프링은 우리에게 선택권을 열어주기 때문에 다음과 같이 설정하면 JDK 동적 프록시도 사용할 수 있다.

    // application.properties
    spring.aop.proxy-target-class = false

     

    * 정리 *

    - 스프링은 최종적으로 스프링 부트 2.0에서 CGLIB를 기본으로 사용하도록 결정했다.

    - CGLIB를 사용하면 JDK 동적 프록시에서 동작하지 않는 구체 클래스 주입이 가능하다

    - 여기에 추가로 CGLIB의 단점들이 이제는 많이 해결되었다

    - CGLIB의 남은 문제라면 final 클래스나 final 메서드가 있는데, AOP를 적용할 대상에는 final 클래스나 fianl 메서드를 잘 사용하지는 않으므로 이 부분은 크게 문제가 되지는 않는다.

     

    - 개발자 입장에서 보면 사실 어떤 프록시 기술을 사용하든 상관이 없다

    - JDK 동적 프록시든 CGLIB든 또는 어떤 새로운 프록시 기술을 사용해도 된다. 

    - 심지어 클라이언트 입장에서 어떤 프록시 기술을 사용하는지 모르고 잘 동작하는 것이 가장 좋다

    - 단지 문제 없고, 개발하기에 편리하면 되는 것이다

     

     

     

    출처 : 인프런 김영한님의 스프링 핵심원리 - 고급편

    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

    댓글

Designed by Tistory.