프록시와 내부 호출

프록시와 내부 호출 - 문제

스프링은 프록시 방식의 AOP를 사용한다.
따라서 AOP를 적용하려면 항상 프록시를 통해 대상 객체(Target)을 호출해야 한다.
이렇게 해야 프록시에서 먼저 Advice를 호출하고 이후에 대상 객체를 호출한다.
만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, Advice도 호출되지 않는다.

AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다.
따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다.
프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다.
하지만 대상 객체의 내부에서 메소드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다.

예제 코드

@Slf4j
@Component
public class CallServiceV0 {

    public void external() {
        log.info("call external");
        internal(); // 내부 메소드 호출(this.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());
    }
}

CallServiceV0.external()을 호출하면 내부에서 internal() 이라는 자기 자신의 메소드를 호출한다.

예제 테스트 코드

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

    @Autowired
    CallServiceV0 callServiceV0;

    @Test
    void external() {
        callServiceV0.external();
    }

    @Test
    void internal() {
        callServiceV0.internal();
    }
}

실행 결과

실행 결과를 보면 callServiceV0.external()을 실행할 때는 프록시를 호출한다.
따라서 CallLogAspect Advice가 호출된 것을 확인할 수 있다.
그리고 AOP Proxy는 target.external()을 호출한다.
그런데 문제는 callServiceV0.external() 안에 internal()을 호출할 때 발생한다.
이때는 CallLogAspect Advice가 호출되지 않는다.

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

프록시 방식의 AOP 한계

스프링은 프록시 방식의 AOP를 사용한다.
프록시 방식의 AOP는 메소드 내부 호출에 프록시를 적용할 수 없다.

참고

실제 코드에 AOP를 직접 적용하는 AsepctJ를 사용하면 이런 문제가 발생하지 않는다.
프록시를 통하는 것이 아니라 해당 코드에 직접 AOP 적용 코드가 붙어 있기 때문에 내부 호출과 무관하게 AOP를 적용할 수 있다.
하지만 로드 타임 위빙을 사용해야 하는데, 설정이 복잡하고 JVM 옵션을 줘야 하는 부담이 있다.

프록시와 내부 호출 - 대안1 자기 자신 주입

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

예제 코드

@Slf4j
@Component
public class CallServiceV1 {

    private CallServiceV1 callServiceV1;

    @Autowired
    public void setCallServiceV1(CallServiceV1 callServiceV1) {
        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를 적용할 수 있다.

참고로 이 경우 생성자 주입시 오류가 발생한다.
본인이 생성하면서 주입해야 하기 때문에 순환 사이클이 만들어지기 때문이다.
반면에 수정자 주입은 스프링이 생성된 이후에 주입할 수 있기 때문에 오류가 발생하지 않는다.

예제 테스트 코드

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

    @Autowired
    CallServiceV1 callServiceV1;


    @Test
    void external() {
        callServiceV1.external();
    }
}

실행 결과

this.internal()이 아닌 프록시의 internal()을 호출하므로 AOP가 적용되는 것을 확인할 수 있다.

하지만 스프링 2.6부터는 이 방법을 사용할 수 없다.

프록시와 내부 호출 - 대안2 지연 조회

대안1의 생성자 주입이 실패하는 이유는 자기 자신을 생성하면서 주입해야 하기 때문이다.
생성자 주입을 성공하기 위해 스프링 빈을 지연해서 조회하면 가능한데 이 때
ObjectProvider(Provider), ApplicatonContext를 사용하면 된다.

예제 코드

@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV2 {

    private final ObjectProvider<CallServiceV2> callServiceProvider;

    public void external() {
        log.info("call external");
        final CallServiceV2 callServiceV2 = callServiceProvider.getObject();
        callServiceV2.internal(); // 외부 메소드 호출
    }

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

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

    @Autowired
    CallServiceV2 callServiceV2;

    @Test
    void external() {
        callServiceV2.external();
    }

}

실행 결과

ObjectProvier는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있다.
callServiceProvider.getObject()를 호출하는 시점에 스프링 컨테이너에서 빈을 조회한다.
여기서는 자기 자신을 주입 받는 것이 아니기 때문에 순환 사이클이 발생하지 않는다.

프록시와 내부 호출 - 대안3 구조 변경

가장 이상적인 방법이다.

예제 코드

/**
 * 구조를 변경(분리)
 */
@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");
    }
}

내부 메소드를 별도의 클래스로 만들어 분리한 다음에 생성자 주입을 통해 주입받고 외부 메소드를 호출하도록 구조를 변경할 수 있다.

예제 테스트 코드

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

    @Autowired
    CallServiceV3 callServiceV3;

    @Test
    void external() {
        callServiceV3.external();
    }
}

실행 결과

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

여기서 구조를 변경한다는 것은 분리 뿐만이 아니다.
아래와 같이 클라이언트에서 둘다 호출 할 수 도 있다.

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

참고

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

좋은 웹페이지 즐겨찾기