본문 바로가기
Spring/spring AOP

스프링 코어 2 - 주의사항1 (내부 호출 문제와 해결)

by 킹차니 2022. 1. 17.

프록시 내부 호출 문제

 

스프링은 프록시 방식의 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가 붙는다. 즉 this.internal()로 호출된다.

 

이제 위의 서비스에 로그를 출력하는 간단한 Aspect를 만들어보자.

@Slf4j
@Aspect
public class CallLogAspect {

    @Before("execution(* hello.aop.internalcall..*.*(..))")
    public void doBefore(JoinPoint joinpoint){
        log.info("[AOP] {}", joinpoint.getSignature());
    }
}

해당 Aspect는 JoinPoint.getSignature()를 사용하여 호출될 target의 타입과 메서드명을 출력해준다.

 

이를 테스트 해보자.

@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {

    @Autowired
    CallServiceV0 callServiceV0;

    @Test
    void external() {
        callServiceV0.external();
    }
    //실행결과:
	//[AOP] void hello.aop.internalcall.CallServiceV0.external()
	//call external
	//call internal

    @Test
    void internal() {
        callServiceV0.internal();
    }
    //실행결과:
	//[AOP] void hello.aop.internalcall.CallServiceV0.internal()
	//call internal
}

실행결과를 보면 external메서드가 내부에서 Internal을 호출할 때, 프록시가 적용되지 않은 것을 볼 수 있다.

 

 

이와 같은 문제의 이유는 다음과 같다.

자바 언어에서 메서드 앞에 별도의 참조가 없으면 this라는 뜻으로 자기 자신의 인스턴스를 가리킨다. 결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal()이 되는데, 여기서 this는 실제 대상 객체(target)의 인스턴스를 뜻한다. 즉 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 어드바이스를 적용할 수  없는 것이다.

 

 

하지만 Internal을 단독으로 호출할 때는 프록시가 적용된 것을 볼 수 있다.

 

프록시 방식의 AOP 한계

스프링은 프록시 방식의 AOP를 사용한다. 프록시 방식의 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다. 이 문제를 해결하는 방법을 하나씩 학습해보자.

 

참고
실제 코드에 AOP를 직접 적용하는 AspectJ를 사용하면 이런 문제가 발생하지 않는다. 프록시를 통하는 것이 아니라 해당 코드에 직접 AOP 적용 코드가 붙어 있기 때문에 내부 호출과 무관하게 AOP를 적용할 수 있다. 하지만 로드 타임 위빙 등을 사용해야 하는데, 설정이 복잡하고 JVM 옵션을 주어야 하는 부담이 있다. 그리고 지금부터 설명할 프록시 방식의 AOP에서 내부 호출에 대응할 수 있는 대안들도 있다.

 

 


 

 

해결방법 1 : 자기 자신 주입

내부 호출을 해결하는 가장 간단한 방법은 자기 자신을 의존관계 주입 받는 것이다.

 

코드로 보면 아래와 같다.

@Slf4j
@Component
public class CallServiceV1 {

    private CallServiceV1 callServiceV1;//자기 자신을 참조

    //setter 주입은 스프링이 생성된 이후에 주입할 수 있기 때문에 오류가 발생하지 않는다.
    @Autowired
    public void setCallServiceV1(CallServiceV1 callServiceV1) {
        log.info("callServiceV1 setter = {}", callServiceV1.getClass());
        this.callServiceV1 = callServiceV1;
    }

    public void external() {
        log.info("call external");
        callServiceV1.internal(); //내부 메서드 호출(this.internal())
    }

    public void internal(){
        log.info("call internal");
    }
}

현재 CallServiceV1은 setter를 통해 자기 자신을 의존관계로 주입받고 있다. 스프링에서 AOP가 적용된 대상을 의존관계 주입받으면 주입 받은 대상은 실제 자신이 아니라 프록시 객체이다.

external()을 호출하면 callServiceV1.internal()를 호출하게 된다. 주입받은 callServiceV1은 프록시이다. 따라서 프록시를 통해서 AOP를 적용할 수 있다.

참고
스프링 2.6.0버전 부터는 순환 참조가 기본적으로 금지되었다. 하여 스프링 2.6.0 이상을 사용한다면 위의 코드를 실행했을 시에 순환참조 에러가 발생한다. 하여 이를 실행시키기 위해서 application.propertis 파일에 다음을 추가해야 한다.
spring.main.allow-circular-references=true

 

즉 아래처럼 실행된다.

다시 테스트를 진행해보면 external메서드에서 internal을 실행할 때도 프록시가 잘 적용된다는 것을 알 수 있다.

@Slf4j
@Component
public class CallServiceV1 {

    private CallServiceV1 callServiceV1;//자기 자신을 참조

    //setter 주입은 스프링이 생성된 이후에 주입할 수 있기 때문에 오류가 발생하지 않는다.
    @Autowired
    public void setCallServiceV1(CallServiceV1 callServiceV1) {
        log.info("callServiceV1 setter = {}", callServiceV1.getClass());
        this.callServiceV1 = callServiceV1;
    }

    public void external() {
        log.info("call external");
        callServiceV1.internal();//이제 this.internal()이 아니다.
    }
}


출력결과:

[AOP] void hello.aop.internalcall.CallServiceV1.external()
call external
[AOP] void hello.aop.internalcall.CallServiceV1.internal()
call internal

 


 

해결방법 2: 지연 주입

앞서 생성자 주입이 실패하는 이유는 자기 자신을 생성하면서 주입해야 하기 때문이다. 이 경우 setter주입(위의 해결방법1) 또는 지연 조회를 사용하면 된다.

스프링 빈을 지연해서 조회하는 방법인데, ObjectProvider(Provider), ApplicationContext를 사용하면 된다.

(이때도 순환 참조 true설정을 해주어야 한다.)

 

코드로 보면 아래와 같다.

@Slf4j
@Component
public class CallServiceV2 {

    //ObjectProvider 사용
    private final ObjectProvider<CallServiceV2> callServiceProvider;
    private CallServiceV2 callServiceV2;

    public CallServiceV2(ObjectProvider<CallServiceV2> callServiceProvider) {
        this.callServiceProvider = callServiceProvider;
    }

    public void external() {
        log.info("call external");
        callServiceV2 = callServiceProvider.getObject();//ObjectProvider.getObject()
        callServiceV2.internal();
    }

    public void internal(){
        log.info("call internal");
    }
}

이 역시 테스트를 실행하면 external이 호출하는 internal에도 프록시가 잘 적용되는 것을 볼 수 있다.

 

 


 

해결방법 3: 구조 변경

 

위의 해결방법1, 2는 자기 자신을 주입하거나 Provider를 사용해야 하는 등 꽤나 억지스러운 부분이 있었다. 하여 더 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것이다. 이 방법이 제일 권장된다.

 

CallService에 있던 internal메서드를 외부로 분리하는 것이다. 코드로 보면 아래와 같다.

/*
* 구조를 변경
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class CallServiceV3 {

    private final InternalService internalService;

    public void external() {
        log.info("call external");
        internalService.internal();//외부 메서드 호출
    }
}

 

아래는 분리된 InternalService

@Slf4j
@Component
public class InternalService {
    public void internal(){
        log.info("call internal");
    }
}

이제 InternalService의 internal()메서드도 프록시 적용 대상이다. 하여 CallService의 external에서 internal을 호출하면 외부의 프록시를 호출하게 되는 것이다.

내부 호출 자체가 사라지고, callService internalService를 호출하는 구조로 변경되었다. 덕분에 자연스럽게 AOP가 적용된다.

여기서 구조를 변경한다는 것은 이렇게 단순하게 분리하는 것 뿐만 아니라 다양한 방법들이 있을 수 있다.

예를 들어서 다음과 같이 클라이언트에서 둘다 호출하는 것이다.

클라이언트 -> external()
클라이언트 -> internal()

물론 이 경우 external() 에서 internal() 을 내부 호출하지 않도록 코드를 변경해야 한다. 그리고 클라이언트 external() , internal() 을 모두 호출하도록 구조를 변경하면 된다. (물론 가능한 경우에 한해서)

 

참고
AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용된다. 쉽게 이야기해서 인터페이스에 메서드가 나올 정도의 규모에 AOP를 적용하는 것이 적당하다. 더 풀어서 이야기하면 AOP는 public 메서드에만 적용한다. private 메서드처럼 작은 단위에는 AOP를 적용하지 않는다. AOP 적용을 위해 private 메서드를 외부 클래스로 변경하고 public으로 변경하는 일은 거의 없다. 그러나 위 예제와 같이 public 메서드에서 public 메서드를 내부 호출하는 경우에는 문제가 발생한다. 

 

 

 

 

 

김영한님의 인프런 강의와 PDF를 바탕으로 정리하였습니다.