이번 포스팅에서는 스프링 AOP 를 적용했을 때 발생할 수 있는 문제와 한계점에 대해서 설명하고 스프링은 이러한 문제를 어떻게 해결했는지를 살펴본다.
대표적인 두 가지 문제는 다음과같다.
- 내부호출이 일어났을 때 프록시가 적용되지 않는 상황
- JDK 동적 프록시를 사용했을 때 타입캐스팅이나 의존관계 주입에서 오류가 발생할 수 있다.
이 두 가지 문제 상황을 살펴보자.
프록시와 내부호출 문제
- 먼저 아래의 코드를 살펴보자.
public class CallServiceV0 {
public void external(){
log.info("call external");
internal(); //내부 메서드 호출
}
public void internal() {
log.info("call internal");
}
}
@Aspect
public class CallLogAspect {
@Before("execution(* hello.aop.internalcall..*.*(..))")
public void doLog(JoinPoint joinPoint) {
log.info("aop={}", joinPoint.getSignature());
}
}
- 위의 코드에서는 CallService 라는 서비스 및 패키지에 대해서 Aspect로 부가기능을 적용했다.
- 이 두 클래스를 빈으로 등록했을 때 우리는 당연히 두 함수 모두 부가기능이 적용될 것으로 예상할 수 있다.
- 다음은 해당 코드에 대한 테스트 코드와 그 결과이다.
@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {
@Autowired
CallServiceV0 callServiceV0;
@Test
void external() {
log.info("target={}",callServiceV0.getClass());
callServiceV0.external();
}
}
2022-06-15 08:44:59.401 INFO 83500 --- [ Test worker] h.aop.internalcall.CallServiceV0Test : target=class hello.aop.internalcall.CallServiceV0$$EnhancerBySpringCGLIB$$7359caac
2022-06-15 08:44:59.405 INFO 83500 --- [ Test worker] h.aop.internalcall.aop.CallLogAspect : aop=void hello.aop.internalcall.CallServiceV0.external()
2022-06-15 08:44:59.431 INFO 83500 --- [ Test worker] hello.aop.internalcall.CallServiceV0 : call external
2022-06-15 08:44:59.432 INFO 83500 --- [ Test worker] hello.aop.internalcall.CallServiceV0 : call internal
- 우리는 external() 메서드와 internal() 메서드 모두에 대해 AOP를 적용했음에도 internal 메서드에 대한 aspect가 적용되지 않음(로그를 출력하지 않음)을 확인할 수 있다.
- 왜 이런 문제가 발생했을까?
- external() 내부에서 internal() 함수를 호출할 때에는 this.internal() 이 자동적으로 호출된다. 이때 this는 프록시 객체가 아닌 실제 객체(타겟)이므로 어찌보면 aspect가 적용되지 않은 것은 당연하다.
- 이러한 문제의 해결방안은 여러 가지인데 본질은 같다. 실제 객체가 아닌 프록시를 호출하도록 변경해주면 된다.
- 프록시를 호출하도록 변경하는 세 가지 대안을 살펴보자.
대안 1. 자기자신 주입
@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");
}
}
- 위의 코드에서 callService를 수정자 주입으로 의존성 주입을 해주게된다. 이렇게 하면 callServiceV1에 들어가는 객체는 Bean으로 등록된 프록시가 들어가게 되고 이 프록시객체를 통해서 internal() 메서드를 호출하게 되므로 다시 한 번 에스팩트가 추가될 수 있다.
- 참고로 이러한 구조를 순환 참조 구조라고 한다. 프록시는 실제 객체를 참조하고 실제 객체는 프록시를 참조하는 구조이다.
- 스프링 최신 버전에서는 이러한 순환 참조 구조일 때 에러가 발생하게 되는데 spring.main.allow-circular-references=true 로 설정하면 순환참조를 허용할 수 있다.
대안 2. 지연 조회
- 두 번째 방법은 지연 조회이다. 지연 조회를 하는 방법은 두 가지가 있다.
- ObjectProvider 를 사용하여 조회한다.
- ApplicationContext 를 통해 조회한다.
@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");
}
}
- 지연 조회를 통해 프록시 객체를 조회하고 이를 통해 내부 호출 하게하면 에스팩트를 추가할 수 있다.
- 이렇게 하면 순환참조가 발생하지 않는다.
대안 3. 구조변경
- 사실 가장 좋은 해결책은 구조를 변경하는 것이다. 내부호출을 외부호출로 바꾸는 것!
- 새로운 클래스를 정의하고 이 클래스를 참조하여 메서드 호출을 하면 AOP가 적용될 것이다.
프록시 기술과 한계 - 타입캐스팅
- 스프링 프록시 팩토리로 프록시를 생성할 때에는 JDK 동적 프록시와 CGLIB를 통해 생성된다. 우리는 JDK 동적프록시는 인터페이스가 있는 경우, CGLIB은 (인터페이스가 있어도 됨)구체클래스가 있는 경우에 사용할 수 있다고 했다.
- 그렇다면 이런 차이로 인해 발생하는 문제가 무엇일까?
- JDK 동적프록시는 인터페이스를 기반으로 프록시를 생성하기 때문에 구체클래스로 타입캐스팅을 하게 되면 오류가 발생한다.
- 아래의 코드를 살펴보자
@Test
void jdkProxy(){
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(false); //jdk 동적 프록시
//프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
//실패 ClassCastException 발생
assertThrows(ClassCastException.class, () -> {
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;});
}
@Test
void cglibProxy(){
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(true); //CGLIB 동적 프록시
//프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
log.info("memberServiceProxy = {}", memberServiceProxy.getClass());
//성공 ClassCastException 발생
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
}
- 코드에서 확인할 수 있듯이 JDK 동적 프록시로 만들어진 객체를 구체 클래스 타입으로 캐스팅했을 때 ClassCastException이 발생함을 확인할 수 있다.
- 반면 CGLIB 으로 프록시를 생성했을 때에는 구체클래스를 상속받아 프록시를 만들기 때문에 구체클래스든 인터페이스든 캐스팅을 해도 문제가 없다.
- 여기까지 봤을 때 이러한 특징은 당연한 것이고 이게 무슨 문제란 말인가? 라고 생각할 수 있다.
- 이게 어떠한 문제를 일으킬 수 있는지 살펴보자
프록시 기술과 한계 - 의존관계 주입
@Import(ProxyDIAspect.class)
@SpringBootTest
//@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"})
//@SpringBootTest(properties = {"spring.aop.proxy-target-class=true"})
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");
}
}
- 위의 코드를 보면 Aspect가 적용된 서비스에 대해 의존성 주입을 받으려 하는 것이다.
- properties 에 spring.aop.proxy-target-class=true 로 세팅하면 타겟 클래스로 프록시를 생성한다는 의미이므로 CGLIB으로 프록시를 생성하고 false로 세팅하면 인터페이스 기반으로 프록시를 생성하게된다.
- 결과적으로 인터페이스에 의존관계 주입은 두 방식 모두 정상이지만 구체클래스에 의존관계를 주입할 때에는 JDK 동적 프록시로는 이전에 살펴보았듯 타입캐스팅 오류가 발생한다.
프록시 기술과 한계 - CGLIB
- 지금까지는 JDK 동적프록시의 단점에 대해 살펴봤는데 CGLIB은 어떠한 한계가 있을까?
- 대상 클래스에 기본 생성자가 필수이다.
- CGLIB에서는 대상 클래스를 상속하여 프록시 객체를 생성하는데 이 때 프록시 생성자에서 부모 생성자를 호출한다.
- 생성자 2번 호출 문제
- 타겟 클래스를 생성할 때 실제 객체 생성자 호출
- 프록시 객체 생성할 때 부모 클래스(타겟 실제 객체) 생성자 호출
- final 키워드 클래스, 메서드 사용 불가
- final 키워드가 있으면 상속 불가
프록시 기술과 한계 - 스프링의 해결책
- 위에서 설명했듯이 여러가지 문제가 있지만 스프링은 이미 이러한 문제를 고민하고 해결했다.
- 스프링은 objenesis 라는 라이브러리를 통해 기본생성자 없이도 객체 생성이 가능하도록 했고, 생성자 2번 호출 문제도 1번 호출로 가능하게 했다.
- 따라서 스프링 부트 2.0 부터는 CGLIB 을 기본으로 사용하도록 했다.
- spring.aop.proxy-target-class=true (default)
Reference
- 스프링 핵심 원리 - 고급편, 김영한
'Spring' 카테고리의 다른 글
스프링 영속성 관리 (영속성 컨텍스트) (0) | 2022.07.08 |
---|---|
JDBC와 트랜잭션 문제, 스프링의 해결책 (0) | 2022.06.17 |
스프링 AOP (0) | 2022.06.12 |
빈 후처리기, BeanPostProcessor (0) | 2022.06.11 |
스프링이 제공하는 프록시, 프록시 팩토리 (0) | 2022.06.09 |