FireDrago

[Spring] 스프링 AOP - 실무 주의사항 본문

프로그래밍/Spring

[Spring] 스프링 AOP - 실무 주의사항

화이용 2024. 5. 22. 12:35

프록시와 내부호출 

AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 

따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다. 

프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다. 

그러나 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 호출하는 문제가 발생한다. 

 

<내부호출 대안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 를 수정자를 통해서 주입 받는다. 

생성자 주입시 오류가 발생한다. 본인을 생성하면서 주입해야 하기 때문에 순환 사이클이 만들어진다. 

 

<내부호출 대안 2 - 지연호출>

/**
 * ObjectProvider(Provider), ApplicationContext를 사용해서 지연(LAZY) 조회
 * 순환 사이클이 발생하지 않는다.
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV2 {

//    private final ApplicationContext applicationContext;
    private final ObjectProvider<CallServiceV2> 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");
    }
}

ObjectProvider 는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 

실제 객체를 사용하는 시점으로 지연할 수 있다.

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");
    }
}

내부 호출 자체가 사라지고, callService ==> internalService 를 호출하는 구조로 변경되었다. 

AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용된다.

AOP는 public  메서드에만 적용한다. private 메서드처럼 작은 단위에는 AOP를 적용하지 않는다.

따라서 AOP 적용을 위해 private  메서드를 외부 클래스로 변경하고 public 으로 변경하는 일은 거의 없다.

그러나 위 예제와 같이 public 메서드에서 public 메서드를 내부 호출하는 경우에는 문제가 발생한다. 

 

프록시 기술과 한계

<타입 캐스팅>

인터페이스 기반으로 프록시를 생성하는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다.

@Slf4j
public class ProxyCastingTest {

    @Test
    void jdkProxy() {
        MemberServiceImpl target = new MemberServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.setProxyTargetClass(false); // JDK 동적 프록시 사용 설정

        // 프록시를 인터페이스로 캐스팅 성공
        MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
        log.info("proxy class={}", memberServiceProxy.getClass());

        // JDK 동적 프록시를 구현 클래스로 캐스팅 시도 실패, ClassCastException 발생
        Assertions.assertThatThrownBy(() -> {
            MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
        }).isInstanceOf(ClassCastException.class);
    }

JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성다.

JDK Proxy는 MemberService 인터페이스를 기반으로 생성된 프록시이다. 

따라서 JDK Proxy는 MemberService 로 캐스팅은 가능하지만 MemberServiceImpl 이 어떤 것인지 전혀 알지 못한다. 

따라서 MemberServiceImpl 타입으로는 캐스팅이 불 가능하다. 

캐스팅을 시도하면 ClassCastException.class 예외가 발생한다.

 

CGLIB은 구체 클래스 기반으로 프록시를 생성하므로, 타입변환 문제가 발생하지 않는다.

 

 

<의존관계 주입>

@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"}) // JDK 동적 프록시, DI 예외 발생
@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");
    }
}

properties = {"spring.aop.proxy-target-class=false"} : 스프링이 AOP 프록시를 생성할 때 JDK 동적 프록시를 우선 생성

@Import(ProxyDIAspect.class) : 앞서 만든 Aspect를 스프링 빈으로 등록한다.

 

Bean named 'memberServiceImpl' is expected to be of type 'hello.aop.member.MemberServiceImpl' but was actually of type 'jdk.proxy2.$Proxy52' 오류 발생 

@Autowired MemberServiceImpl memberServiceImpl 

JDK Proxy는 MemberService 인터페이스를 기반으로 만들어진다. 

따라서 MemberServiceImpl 타입이 뭔지 전혀 모른다. 그래서 해당 타입에 주입할 수 없다.

JDK 동적 프록시는 대상 객체인 MemberServiceImpl 타입에 의존관계를 주입할 수 없다.

 

<스프링의 기술 선택 변화>

스프링은 최종적으로 스프링 부트 2.0에서 CGLIB를 기본으로 사용하도록 결정했다. 

CGLIB를 사용하면 JDK 동적 프록시에서 동작하지 않는 구체 클래스 주입이 가능하다. 

여기에 추가로 CGLIB의 단점들이 이제는 많이 해결되었다. 


CGLIB의 남은 문제라면 final 클래스나 final 메서드가 있는데, AOP를 적용할 대상에는 final 클래스나
final 메서드를 잘 사용하지는 않으므로 이 부분은 크게 문제가 되지는 않는다.