Spring AOP - 실무 주의사항

조갱·2024년 7월 20일
0

스프링 강의

목록 보기
23/23

Spring 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());
    }
}

위와 같은 로직이 있다고 치자.
CallService 에 로그를 찍는 AOP가 적용 되어있다.

이제 테스트코드로 external() 과 internal() 메소드를 각각 호출해보자.

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {
    @Autowired
    CallServiceV0 callServiceV0;
    @Test
    void external() {
        callServiceV0.external();
    }
    @Test
    void internal() {
        callServiceV0.internal();
    }
}

external() 호출 응답

aop=void hello.aop.internalcall.CallServiceV0.external()
call external
call internal 

internal() 호출 응답

aop=void hello.aop.internalcall.CallServiceV0.internal()
call internal

위 예시에서 볼 수 있듯, external() 호출 시에 internal() 메소드에는 AOP가 적용되지 않았다.
external 호출 시에는 아래와 같은 플로우로 동작된다.
즉, AOP가 적용된 Proxy.internal() 이 아닌 원본 객체의 internal() 이 호출 된 것이다.

internal() 메소드를 바로 호출하면 아래와 같은 플로우로 동작한다.
이 때는 AOP가 적용된 Proxy.internal() 이 호출되어 로그가 함께 출력된다.

해결 방법 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");
    }
}

Setter 주입을 사용하여 Proxy 객체를 주입받는다.
이후, internal() 메소드 호출 시에 Proxy 객체의 internal() 을 호출하도록 수정한다.

해결 방법 2 - 지연 조회

@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV2 {

    private final ObjectProvider<CallServiceV2> callServiceProvider;

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

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

ObjectProvider 를 사용하여, 실제로 호출하는 시점에 Proxy 객체의 internal() 메소드를 호출한다.

Spring Bean은 어플리케이션이 실행되는 시점에 만들어지기 때문에, 호출하는 시점이라면 이미 프록시 객체가 만들어진 상태이다.

해결 방법 3 - 구조 변경

앞선 방식들은 조금은 억지스럽게 해결한 모습이다.
이런 상황이 발생하지 않도록 internal 메소드를 분리하자.
그리고 이 방법이 실무에서도 사용되는 해결 방법이다.

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

타입 캐스팅 문제

프록시에는 두 가지 방법이 있다. 다시 한 번 복습해보자.

  • JDK 동적 프록시 : 인터페이스가 있어야 사용가능. 인터페이스를 상속받아 프록시 객체를 만든다.
  • CGLIB 프록시 : 인터페이스가 없어도 구체 클래스를 상속받아 프록시 객체를 만든다.

이 때 발생할 수 있는 문제점에 대해 살펴보자.

@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 예외 발생
        assertThrows(ClassCastException.class, () -> {
            MemberServiceImpl castingMemberService = (MemberServiceImpl)memberServiceProxy;
        });
    }

    @Test
    void cglibProxy() {
        MemberServiceImpl target = new MemberServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.setProxyTargetClass(true);//CGLIB 프록시
        
        //프록시를 인터페이스로 캐스팅 성공
        MemberService memberServiceProxy = (MemberService)proxyFactory.getProxy();
        log.info("proxy class={}", memberServiceProxy.getClass());
        
        //CGLIB 프록시를 구현 클래스로 캐스팅 시도 성공
        MemberServiceImpl castingMemberService = (MemberServiceImpl)memberServiceProxy;
    }
}

인터페이스를 구현하는 구체클래스로 JDK 동적 프록시와 CGLIB 프록시를 생성하고,
각각을 구체 클래스와 인터페이스로 다시 형변환하는 테스트 코드이다.

JDK 동적 프록시, CGLIB 프록시 모두 인터페이스로 형변환은 가능하다.
하지만, 구체 클래스로 형변환 하는것은 CGLIB 프록시만 가능하다.

JDK 동적 프록시는 인터페이스를 구현한 프록시 객체이기 때문에, 그 구현 클래스가 무엇인지 모르고, 그래서 형변환에 실패한다.

이는 바로 다음에 다룰 주제인 의존관계 주입 문제에서 문제를 일으킬 수 있다.

의존관계 주입 문제

의존관계를 주입할 때 어떤 프록시를 사용하는지 / 의존관계를 주입 할 객체의 타입
에 따라 가능할 수도, 불가능할 수도 있다.

@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"}) //JDK 동적 프록시, DI 예외 발생
//@SpringBootTest(properties = {"spring.aop.proxy-target-class=true"}) //CGLIB 프 록시, 성공
@Import(ProxyDIAspect.class)
public class ProxyDITest {

    @Autowired
    MemberService memberService; //JDK 동적 프록시 OK, CGLIB OK
    @Autowired
    MemberServiceImpl memberServiceImpl; //JDK 동적 프록시 X, CGLIB OK

    @Test
    void go() {
        log.info("memberService class={}", memberService.getClass());
        log.info("memberServiceImpl class={}", memberServiceImpl.getClass());
        memberServiceImpl.hello("hello");
    }
}

CGLIB 프록시를 사용한다면 인터페이스인 MemberService, 구체 클래스인 MemberServiceImpl 에 전부 의존관계 주입이 가능하지만

JDK 동적 프록시를 사용한다면 인터페이스인 MemberService만 가능하다.

CGLIB 프록시의 문제

위에서 다룬 이슈들을 살펴보면, CGLIB 프록시를 사용한다면 무적일 것 같다.
하지만 CGLIB도 만능은 아니다.

CGLIB의 원리는, 구체 클래스를 상속받는 클래스를 프록시 객체로 만드는 것 인데
클래스를 상속받을 때 발생하는 문제를 고스란히 안고가기 때문이다.

  • 기본 생성자 필수

    자바 문법 상, 자식 클래스의 생성자를 호출할 때 부모 클래스의 생성자도 호출해야 한다.
    (이러한 코드를 직접 구현하지 않았다면, 부모 클래스의 기본 생성자를 호출하는 super() 가 자동으로 호출된다.)
    
    CGLIB 프록시의 생성자는 우리가 제어하는 것이 아니다.
    CGLIB은 기본적으로 프록시 객체가 만들어질 때 부모클래스의 기본 생성자를 호출하게 한다.
    
    그래서 기본 생성자를 만들어주어야만 한다. (아무런 생성자를 만들지 않았다면 기본 생성자가 자동으로 생성된다.)
  • 생성자 2번 호출

    target 객체가 생성될 때 1회,
     super()를 호출하면서 부모(target 객체)의 생성자가 1회
     총 2회 호출된다.
  • final 클래스, 메소드가 있으면 적용 불가

    final 클래스는 상속이 불가능하고, final 메소드는 오버라이드가 불가능하기 때문.

스프링의 노력

스프링은 프록시 기술을 편리하게 사용할 수 있게 기능을 개선해왔다.

  • Spring 3.2
    CGLIB 을 스프링 내부에 내장시킴 (이전에는 별도로 추가했어야 함)
  • Spring 4.0
    objenesis 라이브러리를 통해 '기본 생성자 필수', '생성자 2번 호출' 문제를 해결했다.
     objenesis 라이브러리는 생성자 호출 없이 객체를 만들 수 있게 해준다.
  • Spring boot 2.0
    CGLIB 프록시를 기본적으로 사용한다.
    JDK 동적 프록시를 사용하려면 어플리케이션 설정을 아래와 같이 하면 된다.
    spring.aop.proxy-target-class=false
profile
A fast learner.

0개의 댓글