만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 어드바이스도 호출되지 않는다.
AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. -따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다.
프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다.
하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다.
실무에서 반드시 한번은 만나서 고생하는 문제이기 때문에 반드시 이해하는 것이 좋다.
서비스
애스팩트
테스트
external()은 CallLogAspect가 적용된다.
그러나 external() 내부의 internal()은 CallLogAspect가 적용되지 않는다.
단순히 internal()을 호출하는 것은 CallLogAspect가 적용된다.
CallServiceV0의 external()에 보면 internal()가 실행된다.
자바에서는 메소드 앞에 별도의 참조가 없으면 자동으로 this를 추가한다.
즉, internal()는 this.internal()가 된다.
this는 실제 대상 객체(target)의 인스턴스를 뜻한다.
그래서 this를 통한 내부 호출은 프록시를 거치지 않는다.
프록시 방식의 AOP 한계
스프링은 프록시 방식의 AOP를 사용한다.
프록시 방식의 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다.
실제 코드에 AOP를 직접 적용하는 AspectJ를 사용하면 이런 문제가 발생하지 않는다.
AspectJ를 사용하면 프록시를 통하는 것이 아니라 해당 코드에 직접 AOP 적용 코드가 붙는다.
그래서 내부 호출과 무관하게 AOP를 적용할 수 있다.
하지만 로드 타임 위빙 등을 사용해야 하다 보니 설정이 복잡하고 JVM 옵션을 주어야 하는 부담이 있다.
프록시 방식의 AOP에서 내부 호출에 대응할 수 있는 대안들도 있기 때문에 이런 이유로 AspectJ를 직접 사용하는 방법은 실무에서는 거의 사용하지 않는다.
프록시와 내부 호출 - 대안1 자기 자신 주입
내부 호출을 해결하는 가장 간단한 방법은 자기 자신을 의존관계 주입 받는 것이다.
서비스
테스트
스프링 부트 2.6부터는 순환 참조를 기본적으로 금지하도록 정책이 변경되었다.
스프링 부트 2.6 이상일 경우에는 해당 테스트가 실패할테니 설정 파일에 해당 값을 설정해주자.
spring.main.allow-circular-references=true
프록시와 내부 호출 - 대안2 지연 조회
생성자 주입이 실패하는 이유는 자기 자신을 생성하면서 주입해야 하기 때문이다.
이 경우 수정자 주입을 사용하거나 지연 조회를 사용하면 된다.
ObjectProvider(Provider)와 ApplicationContext를 통해서 지연 조회를 사용해보자.
서비스
ApplicationContext 는 너무 많은 기능을 제공한다.
ObjectProvider는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있다.
callServiceProvider.getObject()를 호출하는 시점에 스프링 컨테이너에서 빈을 조회한다.
여기서는 자기 자신을 주입 받는 것이 아니기 때문에 순환 사이클이 발생하지 않는다.
테스트 생성
프록시와 내부 호출 - 대안3 구조 변경
앞선 방법들은 자기 자신을 주입하거나, Provider를 사용해야 하는 것처럼 조금 어색한 모습을 만들었다.
가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것이다.
실제로 이 방법이 가장 권장되는 방식이다.
보통 2가지 방식이 있다.
클래스 분리
내부에서 호출할 메소드를 별도의 클래스로 분리하는 방식
메소드 별도 호출
만약에 서비스.메소드1에서 서비스.메소드2를 내부로 호출하는 게 문제된다면?
그냥 해당 서비스를 호출하는 쪽에서 서비스.메소드1과 서비스.메소드2를 각각 호출하게 바꾸면 된다.
서비스
테스트 생성
프록시가 적용되지 않을 때 확인해야 할 사항
AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용된다. - 즉, 인터페이스에 메소드가 나올 정도의 규모에 AOP를 적용하는 것이 적당하다. - 그래서 보통 AOP는 public 메소드에만 적용한다. - private 메서드처럼 작은 단위에는 AOP를 적용하지 않는다.
AOP 적용을 위해 private 메서드를 외부 클래스로 변경하고 public 으로 변경하는 일은 거의 없다.
만약에 public 메서드에서 public 메서드를 내부 호출하는 경우에는 문제가 발생한다.
AOP가 잘 적용되지 않으면 내부 호출인지 확인해보자.
프록시 기술과 한계 - 타입 캐스팅
JDK 동적 프록시와 CGLIB를 사용해서 AOP 프록시를 만드는 방법에는 각각 장단점이 있다.
JDK 동적 프록시는 인터페이스가 필수이고, 인터페이스를 기반으로 프록시를 생성한다.
CGLIB는 구체 클래스를 기반으로 프록시를 생성한다.
인터페이스가 없고 구체 클래스만 있는 경우에는 CGLIB만 사용해야 한다.
하지만 인터페이스가 있는 경우에는 JDK 동적 프록시나 CGLIB 둘 중에 하나를 선택할 수 있다.
스프링이 프록시를 만들때 제공하는 ProxyFactory에 proxyTargetClass 옵션에 따라 둘중 하나를 선택해서 프록시를 만들 수 있다.
JDK 동적 프록시 한계
인터페이스 기반으로 프록시를 생성하는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다.
확인을 위해 테스트를 생성해보자.
JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성한다.
그래서 MemberService로는 캐스팅이 가능하다.
하지만 MemberServiceImpl가 어떤 것이지 알 수 없어서 ClassCastException.class 예외가 발생한다.
CGLIB로 변경
직전의 ProxyCastingTest에 테스트를 추가해보자.
CGLIB는 구체 클래스를 기반으로 프록시를 생성한다.
그래서 MemberServiceImpl로 캐스팅이 가능하다.
프록시 기술과 한계 - 의존관계 주입
타입 캐스팅이 문제가 되는 이유가 뭘까?
그 원인은 스프링의 3대 요소 중 하나인 DI(의존성 주입)에 있다.
애스팩트
테스트 생성
JDK 동적 프록시의 경우
테스트를 실행해보면 타입과 관련된 예외가 발생한다.
기댓값
com.example.app.MemberServiceImpl
결과값
jdk.proxy3.$Proxy67
왜 예외가 발생할까?
@Autowired MemberService memberService
JDK Proxy는 MemberService 인터페이스를 기반으로 만들어진다.
따라서 MemberService로 캐스팅 할 수 있다.
그래서 MemberService = JDK 프록시가 성립한다.
@Autowired MemberServiceImpl memberServiceImpl
JDK Proxy는 MemberService 인터페이스를 기반으로 만들어진다.
따라서 인터페이스 기반인 JDK 동적 프록시는 MemberServiceImpl가 무엇인지 이해할 수 없다.
MemberServiceImpl에 주입할 수 없는 이유
그래서 MemberServiceImpl = JDK 프록시가 성립하지 않는다.
CGLIB의 경우
테스트를 실행해보면 정상적으로 동작한다.
왜 성공했을까?
@Autowired MemberService memberService
CGLIB Proxy는 MemberServiceImpl 구체 클래스를 기반으로 만들어진다.
MemberServiceImpl은 MemberService 인터페이스를 구현했기 때문에 해당 타입으로 캐스팅 할 수 있다.
따라서 해당 타입으로 캐스팅 할 수 있다.
그래서 MemberService = CGLIB의 프록시가 성립한다.
@Autowired MemberServiceImpl memberServiceImpl
CGLIB Proxy는 MemberServiceImpl 구체 클래스를 기반으로 만들어진다.
따라서 MemberServiceImpl로 캐스팅 할 수 있다.
그래서 MemberServiceImpl = CGLIB의 프록시가 성립한다.
실제 개발할 때는 무엇을 사용해야 할까?
. 실제로 개발할 때는 인터페이스가 있으면 인터페이스를 기반으로 의존관계 주입을 받는 것이 맞다.
의존성 주입을 받는 클라이언트 코드의 변경 없이 구현 클래스를 변경할 수 있기 때문이다.
이렇게 하려면 인터페이스를 기반으로 의존관계를 주입 받아야 한다.
MemberServiceImpl 타입으로 의존관계 주입을 받는 것 처럼 구현 클래스에 의존관계를 주입하면 향후 구현 클래스를 변경할 때 의존관계 주입을 받는 클라이언트의 코드도 함께 변경해야 한다.
그래서 올바르게 잘 설계된 애플리케이션이라면 이런 문제가 자주 발생하지는 않는다.
그럼에도 불구하고 테스트나 여러가지 이유로 AOP 프록시가 적용된 구체 클래스를 직접 의존관계 주입 받아야 하는 경우가 있을 수 있다.
이 때는 CGLIB를 통해 구체 클래스 기반으로 AOP 프록시를 적용하면 된다.
여기까지 듣고보면 CGLIB를 사용하는 것이 좋아보인다.
실제로 CGLIB를 사용하면 사실 이런 고민 자체를 하지 않아도 된다.
하지만 그런 CGLIB에도 단점은 있다.
프록시 기술과 한계 - CGLIB
스프링에서 CGLIB는 구체 클래스를 상속 받아서 AOP 프록시를 생성할 때 사용한다.
CGLIB는 구체 클래스를 상속 받기 때문에 다음과 같은 문제가 있다.
대상 클래스에 기본 생성자 필수
생성자 2번 호출 문제
final 키워드 클래스, 메서드 사용 불가
대상 클래스에 기본 생성자 필수
CGLIB는 구체 클래스를 상속 받는다.
자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다.
부모 클래스의 생성자를 호출하는 부분이 생략되어 있다면 자식 클래스의 생성자 첫줄에 부모 클래스의 기본 생성자를 호출하는 super() 가 자동으로 들어간다. (자바 문법 규약)
CGLIB를 사용할 때 CGLIB가 만드는 프록시의 생성자는 우리가 호출하는 것이 아니다.
CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출한다.
따라서 대상 클래스에 기본 생성자를 만들어야 한다. (생성자가 없을 경우 자동으로 생성된다.)
생성자 2번 호출 문제
CGLIB는 구체 클래스를 상속 받는다.
자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 부모 클래스의 생성자도 호출해야 한다.
생성자가 2번 호출되는 이유
실제 target의 객체를 생성할 때 생성자 호출
프록시 객체를 생성할 때 부모 클래스의 생성자 호출
final 키워드 클래스, 메서드 사용 불가
final 키워드가 클래스에 있으면 상속이 불가능하고, 메서드에 있으면 오버라이딩이 불가능하다.
CGLIB는 상속을 기반으로 하기 때문에 두 경우 프록시가 생성되지 않거나 정상 동작하지 않는다.
프레임워크 같은 개발이 아니라 일반적인 웹 애플리케이션을 개발할 때는 final 키워드를 잘 사용하지 않는다.
그래서 이 부분이 특별히 문제가 되지는 않는다.
프록시 기술과 한계 - 스프링의 해결책
스프링은 AOP 프록시 생성을 편리하게 제공하기 위해 오랜 시간 고민하고 문제들을 해결해왔다.
스프링 3.2 (CGLIB를 스프링 내부에 함께 패키징)
CGLIB를 사용하려면 CGLIB 라이브러리가 별도로 필요했다.
스프링은 CGLIB 라이브러리를 스프링 내부에 함께 패키징해서 별도의 라이브러리 추가 없이 CGLIB를 사용할 수 있게 되었다.
스프링 4.0 (CGLIB 기본 생성자 필수 문제 해결)
스프링 4.0부터 CGLIB의 기본 생성자가 필수인 문제가 해결되었다.
objenesis라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능하다.
이 라이브러리를 통해 생성자 호출 없이 객체를 생성할 수 있게 해준다.
또한 스프링 4.0부터 CGLIB의 생성자 2번 호출 문제가 해결되었다.
해당 사항도 objenesis 라는 특별한 라이브러리 덕분에 가능해졌다.
덕분에 이제는 생성자가 1번만 호출된다.
스프링 부트 2.0 (CGLIB 기본 사용)
스프링 부트 2.0 버전부터 CGLIB를 기본으로 사용하도록 했다.
이렇게 해서 구체 클래스 타입으로 의존관계를 주입하는 문제를 해결했다.
스프링 부트는 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true로 설정해서 사용한다.
인터페이스가 있어도 JDK 동적 프록시를 사용하지 않는다.
항상 CGLIB를 사용해서 구체클래스를 기반으로 프록시를 생성한다.
설정 파일에 spring.aop.proxy-target-class=false를 작성하면 JDK 동적 프록시도 사용할 수 있다.
CGLIB의 아직 남은 문제점?
final 클래스나 final 메서드에는 AOP를 적용할 수 없다.
AOP를 적용할 대상에는 final 클래스나 final 메서드를 잘 사용하지는 않으므로 이 부분은 크게 문제가 되지는 않는다.