22.10.25
13. 스프링 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");
}
}
CallServiceV0.external() 을 호출하면 내부에서 internal() 이라는 자기 자신의 메서드를 호출한다.
자바 언어에서 메서드를 호출할 때 대상을 지정하지 않으면 앞에 자기 자신의 인스턴스를 뜻하는 this 가 붙는다.
@Slf4j
@Aspect
public class CallLogAspect {
@Before("execution(* hello.aop.internalcall..*.*(..))")
public void doLog(JoinPoint joinPoint) {
log.info("aop={}", joinPoint.getSignature());
}
}
CallServiceV0 의 external(), internal() 모두 AOP 적용 대상이다.
실행 결과 - external()
1. //프록시 호출
2. CallLogAspect : aop=void hello.aop.internalcall.CallServiceV0.external()
3. CallServiceV0 : call external
4. CallServiceV0 : call internal

실행 결과를 보면 callServiceV0.external() 을 실행할 때 프록시를 호출한다. 따라서 CallLogAspect 어드바이스가 적용된다. 그리고 프록시가 target.external() 을 호출한다.
그런데 callServiceV0.external() 안에서 internal() 을 호출할 때 CallLogAspect 어드바이스가 호출되지 않는다.
자바 언어에서 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킨다. 결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal() 이 되는데, 여기서 this 는 실제 대상 객체(target)의 인스턴스를 뜻한다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 어드바이스도 적용할 수 없다.
proxy -> target.external() -> this.internal()
프록시 방식의 AOP 한계
스프링은 프록시 방식의 AOP를 사용한다. 프록시 방식의 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다.
지금부터 이 문제를 해결하는 방법을 하나씩 알아보자.
참고: 실제 코드에 AOP 코드를 직접 적용하는 AspectJ를 사용하면 이런 문제가 발생하지 않는다. 프록시를 통하는 것이 아니라 해당 코드에 직접 AOP 적용 코드가 붙어 있기 때문에 내부 호출과 무관하게 AOP를 적용할 수 있다.
하지만 로드 타임 위빙 등을 사용해야 하는데, 설정이 복잡하고 JVM 옵션을 주어야 하는 부담이 있다. 그리고 지금부터 설명할 프록시 방식의 AOP에서 내부 호출에 대응할 수 있는 대안들도 있다.
이런 이유로 AspectJ를 직접 사용하는 방법은 실무에서는 거의 사용하지 않는다.
스프링 애플리케이션과 함께 직접 AspectJ 사용하는 방법은 스프링 공식 메뉴얼을 참고하자.
https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#aop-using-asp
Core Technologies
In the preceding scenario, using @Autowired works well and provides the desired modularity, but determining exactly where the autowired bean definitions are declared is still somewhat ambiguous. For example, as a developer looking at ServiceConfig, how do
docs.spring.io
프록시와 내부 호출 - 대안
대안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");
}
}
callServiceV1 를 수정자를 통해서 주입 받는다. 스프링에서 AOP가 적용된 대상을 의존관계 주입 받으면 주입 받은 대상은 실제 자신이 아니라 프록시 객체이다.
external() 을 호출하면 callServiceV1.internal() 를 호출하게 된다. 주입받은 callServiceV1 은 프록시이다. 따라서 프록시를 통해서 AOP를 적용할 수 있다.
주의 스프링 부트 2.6부터는 순환 참조를 기본적으로 금지하도록 정책이 변경되었다. 따라서 이번 예제를 스프링 부트 2.6 이상의 버전에서 실행하면 다음과 같은 오류 메시지가 나오면서 정상 실행되지 않는다.
Error creating bean with name 'callServiceV1': Requested bean is currently in creation: Is there an unresolvable circular reference?
이 문제를 해결하려면 application.properties 에 다음을 추가해야 한다.
spring.main.allow-circular-references=true
실행 결과 - external()
CallLogAspect : aop=void hello.aop.internalcall.CallServiceV1.external()
CallServiceV2 : call external
CallLogAspect : aop=void hello.aop.internalcall.CallServiceV1.internal()
CallServiceV2 : call internal

external() 에서 internal() 을 호출할 때 자기 자신의 인스턴스를 호출하는 것이 아니라 프록시 인스턴스를 통해서 호출하는 것을 확인할 수 있다. 당연히 AOP도 잘 적용된다.
대안2 지연 조회
앞서 생성자 주입이 실패하는 이유는 자기 자신을 생성하면서 주입해야 하기 때문이다.
이 경우 수정자 주입을 사용하거나 지금부터 설명하는 지연 조회를 사용하면 된다.
스프링 빈을 지연해서 조회하면 되는데, ObjectProvider(Provider) , ApplicationContext 를 사용하면 된다.
@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(); //외부 메서드 호출
}
}
@Slf4j
@Component
public class InternalService {
public void internal() {
log.info("call internal");
}
}
- internal() 메서드를 다른 클래스로 분리하고 스프링 빈으로 등록
- CallServiceV3 에서는 InternalService 를 주입 받아 internal() 호출

내부 호출 자체가 사라지고, callService internalService 를 호출하는 구조로 변경되었다. 덕분에 자연스럽게 AOP가 적용된다.
여기서 구조를 변경한다는 것은 이렇게 단순하게 분리하는 것 뿐만 아니라 다양한 방법들이 있을 수 있다.
ex) 클라이언트가 external() , internal() 을 모두 호출하는 방법
클라이언트 -> external() ( internal() 을 내부 호출하지 않도록 코드 변경 )
클라이언트 -> 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 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다.
jdkProxy() 테스트
@Test
void jdkProxy(){
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory factory = new ProxyFactory(target);
factory.setProxyTargetClass(false); //JDK 동적 프록시
//JDK 동적 프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) factory.getProxy();
//JDK 동적 프록시를 구현 클래스로 캐스팅 실패
Assertions.assertThrows(ClassCastException.class, () -> {
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
});
}
- MemberServiceImpl 을 대상 클래스로 프록시 생성 -> MemberService 인터페이스를 구현한 JDK 동적 프록시
- JDK Proxy를 대상 클래스인 MemberServiceImpl 타입으로 캐스팅 하려고 하니 ClassCastException 이 발생한다.

JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성하기 때문에, JDK Proxy는 MemberService 로 캐스팅은 가능하지만 MemberServiceImpl 을 알지 못한다. (연관관계=참조가 없음)
따라서 MemberServiceImpl 타입으로는 캐스팅이 불가능하다.
cglibProxy() 테스트
@Test
void cglibProxy(){
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory factory = new ProxyFactory(target);
factory.setProxyTargetClass(true); //CGLIB 프록시
//CGLIB 프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) factory.getProxy();
//CGLIB 프록시를 구현 클래스로 캐스팅 성공
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
}
- MemberServiceImpl 을 대상 클래스로 프록시 생성 -> MemberServiceImpl 를 상속한 CGLIB 프록시
- CGLIB Proxy를 대상 클래스인 MemberServiceImpl 타입으로 캐스팅 성공

CGLIB는 구체 클래스를 기반으로 프록시를 생성하기 때문에, CGLIB Proxy는 MemberServiceImpl 은 물론이고, MemberServiceImpl 이 구현한 인터페이스인 MemberService 로도 캐스팅 할 수 있다.
정리
JDK 동적 프록시는 대상 객체인 MemberServiceImpl 로 캐스팅 할 수 없다.
CGLIB 프록시는 대상 객체인 MemberServiceImpl 로 캐스팅 할 수 있다.
프록시를 직접 캐스팅 할 일은 많지 않지만 문제는 의존관계 주입시에 발생한다.