
✔️ 프록시와 내부 호출
프록시를 거치지 않고 target을 직접 호출하면 AOP가 적용되지 않고, 어드바이스도 호출되지 않는다.
예를 들어 이런 내부 호출이 있는 서비스에 AOP를 적용한다 하자.
애스팩트는 해당 클래스의 모든 메서드에 대해서 log를 찍는 간단한 어드바이스를 가진다고 하자.
public class CallService {
public void A() {
B(); // this.B();
}
public void B() {
}
}
CallService에 대한 프록시가 생성되고, target은 CallService가 될 것이다.
참고
B(); : 사실 this가 생략된 것이다.
프록시로 A() 호출 -> log 출력 -> B()호출 -> End
문제는 B()를 호출했을 때 분명 AOP 대상이지만 내부적으로 호출했기 때문에 어드바이스가 적용되지 않았다.

이러한 내부 호출 해결 방법을 알아보자
✔️ 대안1. 자기 자신 주입
@Slf4j
@Component
public class CallService {
private CallService callService; // AOP 프록시
@Autowired
public void setCallService(CallService callService) {
this.callService = callService;
}
public void A() {
callServiceV1.B(); // 외부 메서드 호출
}
public void B() {
}
}
이렇게하면 주입 받은 자신도 프록시 이므로, proxy.B()를 호출하는 것이다. 따라서 어드바이스가 호출된다.
그런데 문제가 있다.
생성자로 자신을 주입시 순환 사이클이 발생한다.
수정자 주입도 스프링부트 2.6부터 현재는 순환 사이클 오류가 발생하기에
application.properties에서 spring.main.allow-circular-references=true 을 추가해줘야한다.
✔️ 대안2. 지연 조회
대안1은사실상 쓰면 안된다. 스프링부트도 이를 막았기 때문에 안쓰는게 좋아보인다.
스프링 빈을 지연해서 조회하는 방법을 알아보자
스프링 기본 편에서 배웠던 ObjectProvider(provider), ApplicationContext 를 사용한다.
@Slf4j
@Component
public class CallService {
// private final ApplicationContext applicationContext;
private final ObjectProvider<CallServiceV2> callServiceV2Provider;
public CallService(ObjectProvider<CallService> callServiceProvider) {
this.callServiceProvider = callServiceProvider;
}
public void A() {
// CallService callService = applicationContext.getBean(CallService.class);
CallService callService = callServiceProvider.getObject();
callService.B(); // 외부 메서드 호출
}
public void B() {
}
}
단순 빈 조회이기때문에 ApplicationContext는 너무 크다.
ObjectProvidoer는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아닌 실제 객체를 사용하는 시점으로 지연 가능
즉, 호출하는 시점에서 스프링 컨테이너에서 빈을 조회
→ 자기 자신 주입이 아니기에 순환 사이클은 발생하지 않는다.
참고
AppicationContext: 스프링 컨테이너
ObjectProvider : 지정한 빈을 컨테이너에서 대신 찾아주는 DL( Dependency Lookup )서비스를 제공
✔️ 대안3. 구조 변경
가장 권장하는 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것이다.
@Slf4j
@Component
@RequiredArgsConstructor
public class CallService {
private final InternalService internalService;
public void A() {
internalService.B(); // 외부 메서드 호출
}
}
@Slf4j
@Component
public class InternalService {
public void B() {
}
}
B메서드를 새로운 클래스로 분리
그외에는
클라이언트에서 A, B 둘다 호출하는 방법이 있다.
보통 내부 호출 문제는 public → public 메서드 호출에서 일어난다.
✔️ 프록시 기술과 한계
이번에는 프록시 기술에 대해서 그리고 한계점을 알아보자
이미 JDK 동적 프록시와 CGLIB에 대해서 공부했기에 알겠지만
한 번 더 상기시킨다는 생각으로 공부해보자.
✔️ 타입 캐스팅
JDK 동적 프록시와 CGLIB를 통해 AOP 프록시를 만드는 방법에는 각각 장단점이 있다.
우선 스프링부트는 기본적으로 CGLIB를 AOP 프록시로 설정하고 있다.
JDK 동적 프록시는 한계
인터페이스 기반이기에 구체 클래스 타입으로 캐스팅이 불가

JDK Proxy를 Impl 즉, 인터페이스 구현 클래스 타입으로 캐스팅시 예외가 발생한다.
JDK Proxy는 인터페이스 기반 이다. 즉, MemberService는 알지만, MemberServiceImpl은 모른다는 것이다.
따라서 ClassCastException.class 예외가 발생한다.
CGLIB

CGLIB는 상속을 통해 프록시를 생성하기에 구현 클래스 캐스팅이 가능하다.
왜 이런얘기를 할까?
의존관계 주입에서 문제가 발생하기 때문이다.
✔️ 의존관계 주입
JDK 프록시를 의존관계 주입때 사용시 문제가 발생한다.
@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"}) // JDK 동적 프록시 실패
// @SpringBootTest(properties = {"spring.aop.proxy-target-class=true"}) // CGLIB 프록시 성공
@Import(ProxyDIAspect.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");
}
}
방금 봤듯이 JDK 동적 프록시는 인터페이스 기반이므로 구체클래스는 모른다. 따라서 memberServiceImpl로 캐스팅되지 않아
예외를 발생한다.
CGLIB는 구체 클래스를 기반으로 만들어지므로, MemberServiceImpl은 MemberService 인터페이스를 구현했기에 MemberService 인터페이스도 타입 캐스팅이 가능하다.


JDK 동적 프록시 | CGLIB | |
Interface | o | o |
ImplClass | x | o |
그럼 CGLIB가 좋으니까 CGLIB만 쓰면 되지 않나? 라는 생각이 든다.
CGLIB는 몇몇 문제점이 있다.
물론 이미 스프링 부트가 단점들을 해결했다. 그래도 알아보자
✔️ CGLIB 문제점
CGLIB는 구체 클래스를 상속 받기 때문에 몇몇 문제가 있다.
- 대상 클래스에 기본 생성자 필수
- 생성자 2번 호출 문제
- final 키워드 클래스, 메서드 사용 불가
대상 클래스에 기본 생성자 필수
자바 언어에서 상속받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야한다.
생략시 자식 기본 생성자에 super()가 자동으로 들어간다.
CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출해야하기때문에 대상 클래스에 기본 생성자를 만들어야한다.
생성자 2번 호출 문제
자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 부모 클래스의 생성자도 호출해야한다.
- 실제 target의 객체 생성 시
- 프록시 객체 생성 시 부모 클래스 생성자 호출

프록시가 만들어지면서 MemberServiceImpl 생성자 호출 (상속해서 만들기 때문에)
target 객체를 만들면서 MemberServiceImpl 생성자 호출
final 키워드 클래스, 메서드 사용 불가
final 키워드가 클래스에 있으면 상속 불가, 메서드에 있으면 오버라이딩 불가
CGLIB는 상속 기반이기에 두 경우 프록시가 생성되지 않거나 정상 작동하지 않는다.
✔️ 스프링의 해결책
스프링 3.2, CGLIB를 스프링 내부에 함께 패키징
CGLIB는 사실 별도의 라이브러리가 필요했는데 스프링이 이를 내부에 함께 패키징하여 바로 사용가능하게 했다.
CGLIB 기본 생성자 필수 문제 해결
스프링 4.0부터 CGLIB의 기본 생성자가 필수인 문제가 해결되었다.
objenesis라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능하다.
이 라이브러리는 생성자 호출 없이 객체를 생성할 수 있게 해준다.
생성자 2번 호출 문제
스프링 4.0부터 해결되었으며
이 역시 objenesis 라이브러리로 가능해졌다.
이제 생성자가 1번만 호출된다.
스프링 부트 2.0 - CGLIB 기본 사용
스프링 부트 2.0부터 CGLIB가 기본 값이다.
따라서 필요에 따라서 JDK 동적 프록시, CGLIB 중 선택해서 사용하면 된다.
//application.properties
spring.aop.proxy-target-class=false // jdk 동적 프록시
fianl 클래스나 메서드가 남았는데 AOP 를 적용할 대상에는 final 클래스나 메서드를 잘 사용하지 않으므로 크게 문제되지 않는다.
🔖 학습내용 출처
'Back-End > Spring Advance & Boot' 카테고리의 다른 글
스프링 부트 - 부트와 내장 톰캣 (0) | 2024.11.19 |
---|---|
스프링 부트 - 웹 서버와 서블릿 컨테이너 (2) | 2024.11.15 |
스프링 고급편 - 실전예제 (0) | 2024.11.13 |
스프링 고급편 - 포인트컷 (0) | 2024.11.11 |
스프링 고급편 - 스프링 AOP 구현 (0) | 2024.10.31 |