강의 링크는 아래와 같습니다.
포스팅을 하지는 않았지만 스프링 AOP를 배우면서 스프링 AOP 구현 방법을 배웠고,
어드바이스와 포인트컷에 관한 구체적인 내용을 배웠다. 주로 API 사용법이라 별도로 포스팅을 남기지는 않으려고 한다.
좋은 내용이 많으니 꼭 강의를 들어보는 것을 추천한다.
스프링 AOP 주의사항
스프링 AOP는 프록시 방식을 사용한다.
따라서 AOP를 적용하려면 항상 프록시를 통해서 대상 객체(target)을 호출해야 한다.
이렇게 해야 프록시에서 먼저 어드바이스를 호출하고, 이후에 대상 객체를 호출한다.
만약 프록시를 거치지 않고 직접 대상 객체를 호출하면 AOP가 적용되지 않는다. 즉 어드바이스가 호출되지 않는다.
AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다.
따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다.
프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다.
하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다.
이 문제를 한 번 해결하는 방법에 대해 학습해보겠다.
예제 코드는 다음과 같다.
@Slf4j
@Component
public class CallServiceV0 {
public void external() {
log.info("call external");
internal(); //내부 메서드 호출(this.internal()).
}
public void internal() {
log.info("call internal");
}
}
여기에 다음과 같은 @Aspect를 만들어 advisor를 빈으로 등록한다.
이렇게 되면 프록시 객체가 advisor 로직을 수행하고 타겟 객체를 호출하며 aop가 적용된다.
@Slf4j
@Aspect
public class CallLogAspect {
@Before("execution(* hello.aop.internalcall..*.*(..))")
public void doLog(JoinPoint joinPoint) {
log.info("aop={}", joinPoint.getSignature());
}
}
그리고 테스트 코드에서 다음과 같이 호출하자.
// 테스트 코드 설정 및 DI 코드 생략..
@Test
void external() {
callServiceV0.external();
}
@Test
void internal() {
callServiceV0.internal();
}
결과는 다음과 같다.
- 테스트 코드 external()에서는 callServiceV0.external을 호출하면 AOP가 적용되서 로그가 찍히는데,
external() 메서드에서 내부에서 호출하는 internal()에는 AOP가 적용이 되지 않아 로그가 찍히지 않는다. - 테스트 코드 internal()에서는 callServiceV0.internal()은 AOP가 적용된다.
둘의 차이점은 무엇일까?? 필자는 이 문제를 스프링 DB 2편에서 이미 학습한적이 있다.
바로 callServiceV0.external() 호출은 프록시 객체에서 이루어진다.
그러나 해당 메소드 내부의 internal()은 사실 this.internal()이다.
여기서 this는 target 즉 대상 객체다. 프록시 객체의 internal()이 아니라 타겟 대상의 internal()이므로 AOP가 적용되지 않는 것이다.
반면에 callServiceV0.internal()은 프록시 객체의 메서드를 호출한 것이므로 AOP가 적용되서 어드바이저의 로직이 실행되는 것이다.
선생님이 스프링 DB 2편과 스프링 고급편에서 언급하신 걸 보면 정말 중요한 부분인 것 같다. 그림으로 설명하면 다음과 같다.
해당 문제에 관한 해결 방법으로는 크게 3가지가 있었다.
- 자기 자신 주입(프록시 객체를 주입)
- 지연 조회: ObjectProvider를 사용해 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연시킨다.
- 구조 변경: internal() 메서드를 가지는 별도의 클래스 작성한다.
참고로 스프링 부트 2.6부터는 순환 참조를 기본적으로 금지하도록 변경되었다. 그래서 처음에 할 때 1번, 2번 실습에서 오류가 발생했다.
스프링이 권장하는 방법은 3번, 선생님께서도 실무에서는 3번, 구조 변경을 제일 많이 사용한다고 하셨다.
따라서 3번 방법을 택하도록 하자. 해결한 방식을 그림으로 나타내면 아래와 같다.
프록시 기술과 한계
스프링은 동적 프록시 생성을 위해 JDK 동적 프록시 기술과 CGLIB 기술을 사용한다.
이 두 방식을 통해 AOP 프록시를 만든다.
JDK 동적 프록시는 인터페이스가 필수며, 인터페이스를 기반으로 프록시를 생성한다.
CGLIB는 구체 클래스를 기반으로 프록시를 생성한다.
스프링이 프록시를 만들때 제공하는 ProxyFactory 에 proxyTargetClass 옵션에 따라 둘중 하나를 선택해서 프록시를 만들 수 있다.
- proxyTargetClass=false JDK 동적 프록시를 사용해서 인터페이스 기반 프록시 생성
- proxyTargetClass=true CGLIB를 사용해서 구체 클래스 기반 프록시 생성
- 참고로 인터페이스가 없으면 바로 CGLIB를 사용한다.
JDK 동적 프록시는 한계가 존재한다. 인터페이스를 기반으로 프록시를 생성하므로 구체 클래스로 타입 캐스팅이 불가능하다.
해당 문제를 코드로 확인해보자.
public interface MemberService{}
public class MemberServiceVImpl implements MemberService{}
@Test
void jdkProxy(){
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(false);//JDK 동적 프록시
//프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
log.info("proxy class={}", memberServiceProxy.getClass());
//JDK 동적 프록시를 구현 클래스로 캐스팅 시도 실패, ClassCastException 예외 발생
assertThrows(ClassCastException.class, () -> {MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy; } );
}
현재 상황을 표현한 그림은 다음과 같다.
JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성한다.
JDK 프록시는 MemberService 인터페이스를 기반으로 생성했다. 따라서 MemberService로는 캐스팅이 되지만,
MemberServiceImpl과는 전혀 관계가 없다. 그러므로 클래스를 캐스팅할 때 오류가 발생한다.
이번에는 CGLIB를 코드를 확인해보자.
public interface MemberService{}
public class MemberServiceVImpl implements MemberService{}
@Test
void cglibProxy(){
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(true);//CGLIB 동적 프록시
//프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
log.info("proxy class={}", memberServiceProxy.getClass());
//JDK 동적 프록시를 구현 클래스로 캐스팅 시도 실패, ClassCastException 예외 발생
//CGLIB 프록시를 구현 클래스로 캐스팅 시도 성공
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
}
CGLIB는 구체 클래스를 기반으로 프록시를 생성하므로 현재 상황은 아래 이미지와 같다.
CGLIB는 구체 클래스 MemberServiceImpl을 기반으로 생성되었다.
따라서 MemberServiceImpl로 당연히 캐스팅이 가능하고, MemberServiceImpl이 구현한 MemberService로도 캐스팅할 수 있다.
정리하면 다음과 같다.
- JDK 동적 프록시는 대상 객체인 MemberServiceImpl 로 캐스팅 할 수 없다.
- CGLIB 프록시는 대상 객체인 MemberServiceImpl 로 캐스팅 할 수 있다.
사실 강의를 들으면서 '프록시를 캐스팅할 일이 있을까??'라고 생각했는데 바로 선생님께서 문제가 발생할 수 있는 부분에 대해서 말씀해주셨다. 바로 의존관계 주입시에 문제가 발생할 수 있다고 한다.
이번 실습 코드에서 핵심 부분만 살피면 의존관계 주입 시에 발생하는 문제를 이해를 할 수 있다.
@Autowired MemberService memberService; //JDK 동적 프록시 OK, CGLIB OK. 인터페이스 타입 주입
@Autowired MemberServiceImpl memberServiceImpl; //JDK 동적 프록시 X, CGLIB OK. 구체 클래스 타입 주입
프록시 생성 방식을 JDK 동적 프록시 기술을 사용하면 구체 클래스에 의해서는 프록시 객체를 만들 수 없다는 것이 문제다!!!!!
바로 이전에 학습한 것 과 같이 타입 캐스팅 문제가 발생하기 때문이다. 그러나 CGLIB에서는 어떠한 경우에도 프록시가 생성된다.
프록시 기술의 한계 파트였지만 지금까지만 보면 JDK 동적 프록시 기술의 한계를 살펴본 것 같다.
사실 DI(의존관계 주입)의 장점은 코드의 변경 없이 구현 클래스를 변경할 수 있는 점이다.
이 장점을 살리려면 인터페이스를 기반으로 DI가 이루어져야한다.
MemberServiceImpl 타입을 의존관계 주입을 받는 것처럼 구현 클래스에 의존관계를 주입하면
향후 구현 클래스를 변경할 때 의존관계 주입을 받는 코드도 함께 변경해야 한다.
따라서 올바르게 잘 설계된 애플리케이션이라면 이런 문제가 자주 발생하지는 않는다.
그럼에도 불구하고 테스트, 또는 여러가지 이유로 AOP 프록시가 적용된 구체 클래스를 직접 의존관계 주입 받아야 하는 경우가 있을 수 있다고 한다. 이때는 CGLIB를 통해 구체 클래스 기반으로 AOP 프록시를 적용하면 된다.
CGLIB 기반 프록시 단점 및 스프링의 해결책
이러면 CGLIB가 만능인 것 같은데..? 그러면 CGLIB 기반 프록시 단점을 알아보자. 단점은 크게 3가지가 있다.
- 대상 클래스에 기본 생성자 필수
자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출. (자바 기본) - 생성자 2번 호출 문제:
대상 객체 생성, 프록시 객체를 생성하면 부모 클래스의 생성자 호출 - final 키워드 클래스, 메서드 사용 불가: 실제 웹 애플리케이션 개발 시에는 final을 잘 사용하지 않아 문제가 크게 안된다고 한다.
결국 1번과 2번이 문제다. 하지만 우리의 짱짱 스프링은 이미 문제를 해결했다. 해결 방법은 아래와 같다.
- 기본 생성자 필수 문제 해결: objenesis라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능해졌다.
- 생성자 2번 호출 문제: 이 역시도 objenesis를 사용해 해결했다. 생성자가 1번만 호출된다.
흥미로운 내용도 여러 가지 있었다.
원래 스프링에서 CGLIB를 사용하려면 CGLIB 라이브러리가 별도로 필요했다.
그러나 스프링은 CGLIB 라이브러리를 스프링 내부에 함께 패키징해서, 별도의 라이브러리 추가 없이 CGLIB를 개발자가 사용할 수 있게 만들었다.
스프링 부트 2.0 버전부터 CGLIB를 기본으로 사용하도록 했다. 따라서 인터페이스가 있어도 JDK 동적 프록시를 사용하는 것이 아니라 항상 CGLIB를 사용해서 구체클래스를 기반으로 프록시를 생성한다. 물론 설정을 바꾸면 JDK 동적 프록시 기술을 사용할 수 있다.
이상으로 스프링 고급편 학습 끝!!!! 이제 기초편부터 다시 정주행하려고 합니다. 이상으로 포스팅을 마칩니다. 감사합니다.
댓글