지난 포스팅에서는 AOP중 포인트컷의 종류와 각 포인트컷의 특징에 대해서 알아보았다.
이번 포스팅에서는 스프링 AOP를 적용할 때 직면하게 되는 내부호출 문제와 그 해결 방안에 대해서 알아보려고 한다.
스프링에서는 AOP를 적용하려면 스프링 빈으로 등록된 프록시 객체를 거쳐야 한다. 그래야 프록시에서 적절한 어드바이스를 적용하고, 타겟을 호출한다.
위와 같은 상황이라면 AOP가 적용되지 않는다.
스프링에서는 프록시 방식의 AOP를 사용하기 때문에 프록시를 거치지 않으면 어드바이스를 적용할 수 없다.
그런데 위의 상황처럼 타겟의 내부에서 또 다른 메서드를 호출하면 그 사이에 프록시가 끼어들 틈이 없기 때문에 AOP가 적용되지 않는 것이다.
코드로 확인해보자.
@Slf4j
@Component
public class CallServiceV0 {
public void external() {
log.info("call external");
internal();
}
public void internal() {
log.info("call internal");
}
}
@Slf4j
@Aspect
public class CallLogAspect {
@Before("execution(* hello.aop.internalcall..*.*(..))")
public void doLog(JoinPoint joinPoint) {
log.info("aop={}", joinPoint.getSignature());
}
}
@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {
@Autowired
CallServiceV0 callServiceV0;
@Test
void external() {
callServiceV0.external();
}
@Test
void internal() {
callServiceV0.internal();
}
}
@Import를 사용해서 어드바이스를 빈으로 등록해주자.
external 호출 결과
external에는 어드바이스가 적용된 반면 내부호출된 internal은 어드바이스가 적용되지 않은 것을 확인할 수 있다.
아래의 그림을 보자.
프록시가 external(타겟)을 호출할 때는 어드바이스가 적용되지만,
external(타겟)에서 internal(타겟)을 호출할 때는 프록시를 거치지 않기 때문에 어드바이스가 적용되지 않는다.
internal 호출 결과
그럼 internal을 직접 호출하면 어떻게 될까?
아래의 결과처럼 당연히 AOP가 적용된다.
프록시 객체에서 internal을 호출하고 있기 때문이다.
AspectJ를 직접 사용하면 이런 문제를 해결할 수 있다. AspectJ를 이용하면 실제 코드에 어드바이스 코드를 직접 붙이기 때문이다.
그럼 이런 내부호출 문제는 어떻게 해결할 수 있을까?
코드로 바로 확인해보자.
@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");
}
}
타겟이 필드로 자기 자신을 가지고 있고, setter를 통해 스프링으로부터 프록시를 주입받고 있다.
여기서 setter로 주입을 받는 이유는 객체 생성단계와 setter를 처리하는 단계가 나뉘어 있는데,
생성자를 통해 주입받으려고 시도를 하면 자기 자신을 생성하면서 주입을 해야하기 때문에 순환참조 예외가 발생한다.
따라서 setter를 이용해 주입을 받아야 한다.
스프링부트 2.6 이후부터는 스프링에서 순환참조를 기본적으로 금지하고 있기 때문에 properties에 아래의 값을 추가해주자.
# 순환참조 허용
spring.main.allow-circular-references=true
스프링 컨테이너의 생성이 완료되고, 런타임 시점에 자기 자신을 주입받는 방식이다.
코드로 확인해보자.
@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");
}
}
이렇게 하면 스프링 설정이 모두 완료되고, 어플리케이션이 실행된 런타임 시점에 자기 자신을 조회하는데, 이 시점에 스프링 빈으로 등록된 객체는 프록시이므로 프록시를 주입받게 된다.
1번이나 2번 같은 경우에는 순환 참조가 일어나거나, ObjectProvider를 사용해야 한다는 점이 있다. 문제가 되는 것은 아니지만 어색한면이 있다.
반면 internal메서드를 별도의 클래스로 정의하는 것처럼 구조를 변경하면 이런 문제를 가장 깔끔하게 변경할 수 있다. 실제로도 이 방법을 가장 권장하셨다.
코드로 확인하자.
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {
private final InternalService internalService;
public void external() {
log.info("call external");
internalService.internal(); // 외부 메서드 호출
}
public void internal() {
log.info("call internal");
}
}
@Slf4j
@Component
public class InternalService {
public void internal() {
log.info("call internal");
}
}
internal 메서드를 별도의 InternalService클래스로 정의하고, 기존 타겟에서는 InternalService을 주입받아 이 객체를 호출하고 있다.
이때 주입받는 객체는 프록시이므로 internal 메서드에도 어드바이스가 적용된다. 아래의 그림을 참고하자.
출처 : 김영한 - 스프링 핵심 원리 고급편