스프링 AOP - 실무 주의사항

김상운(개발둥이)·2022년 8월 10일
0
post-thumbnail

스프링 핵심 원리

인프런 김영한님의 '스프링 핵심 원리-고급편' 강의 보러가기
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard


프록시와 내부 호출 - 문제

스프링은 프록시 방식의 AOP를 사용한다.

따라서 AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다.

이렇게 해야 프록시에서 먼저 어드바이스를 호출하고, 이후에 대상 객체를 호출한다.

만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 어드바이스도 호출되지 않는다.


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

따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다.

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

실무에서 반드시 한번은 만나서 고생하는 문제이기 때문에 꼭 이해하고 넘어가자.

CallServiceV0

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

자바 언어에서 메서드를 호출할 때 대상을 지정하지 않으면 앞에 자기 자신의 인스턴스를 뜻하는 this 가 붙게 된다.

그러니까 여기서는 this.internal() 이라고 이해하면 된다.

CallLogAspect

CallServiceV0Test

실제 호출

실행 결과를 보면 callServiceV0.external() 을 실행할 때는 프록시를 호출한다.

따라서 CallLogAspect 어드바이스가 호출된 것을 확인할 수 있다.

그리고 AOP Proxy는 target.external() 을 호출한다.

그런데 여기서 문제는 callServiceV0.external() 안에서 internal() 을 호출할 때 발생한다. 이때는 CallLogAspect 어드바이스가 호출되지 않는다.

결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서
어드바이스도 적용할 수 없다

외부에서 호출하는 경우 프록시를 거치기 때문에 internal() 도 CallLogAspect 어드바이스가 적용된 것을 확인할 수 있다.

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

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

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

CallServiceV1Test

실제 호출

수정자 주입을 통해 빈을 주입 받게 되는데, 주입 받는 빈은 스프링 컨테이너에 등록된 프록시이기 때문에 advice가 적용된다.

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

ApplicationContext 는 너무 많은 기능을 제공한다.

ObjectProvider 는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있다.

callServiceProvider.getObject() 를 호출하는 시점에 스프링 컨테이너에서 빈을 조회한다.

여기서는 자기 자신을 주입 받는 것이 아니기 때문에 순환 사이클이 발생하지 않는다.

CallServiceV2Test

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

앞선 방법들은 자기 자신을 주입하거나 또는 Provider 를 사용해야 하는 것 처럼 조금 어색한 모습을 만들었다.

가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것이다. 실제 이 방법을 가장 권장한다.

CallServiceV3

InternalService

CallServiceV3Test

실제 호출

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

프록시 기술과 한계 - 타입 캐스팅

JDK 동적 프록시와 CGLIB를 사용해서 AOP 프록시를 만드는 방법에는 각각 장단점이 있다.

JDK 동적 프록시는 인터페이스가 필수이고, 인터페이스를 기반으로 프록시를 생성한다.

CGLIB는 구체 클래스를 기반으로 프록시를 생성한다.

물론 인터페이스가 없고 구체 클래스만 있는 경우에는 CGLIB를 사용해야 한다. 그런데 인터페이스가 있는 경우에는 JDK 동적 프록시나 CGLIB 둘중에 하나를 선택할 수 있다.

스프링이 프록시를 만들때 제공하는 ProxyFactory 에 proxyTargetClass 옵션에 따라 둘중 하나를
선택해서 프록시를 만들 수 있다.

JDK 동적 프록시 한계

인터페이스 기반으로 프록시를 생성하는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다. 어떤 한계인지 코드를 통해서 알아보자.

ProxyCastingTest

JDK 동적 프록시

jdkProxy() 테스트에서 구현체타입을 기반으로 JDK 동적 프록시를 생성시 실패하는 것을 볼 수 있다.

JDK Proxy를 대상 클래스인 MemberServiceImpl 타입으로 캐스팅 하려고 하니 예외가 발생한다. 왜냐하면 JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성하기 때문이다.

JDK Proxy는 MemberService 인터페이스를 기반으로 생성된 프록시이다. 따라서 JDK Proxy는 MemberService 로 캐스팅은 가능하지만 MemberServiceImpl 이 어떤 것인지 전혀 알지 못한다.

따라서 MemberServiceImpl 타입으로는 캐스팅이 불가능하다.
캐스팅을 시도하면 ClassCastException.class 예외가 발생한다

CGLIB 프록시 캐스팅

여기에서 CGLIB Proxy를 대상 클래스인 MemberServiceImpl 타입으로 캐스팅하면 성공한다.
왜냐하면 CGLIB는 구체 클래스를 기반으로 프록시를 생성하기 때문이다.

CGLIB Proxy는 MemberServiceImpl 구체 클래스를 기반으로 생성된 프록시이다. 따라서 CGLIB Proxy는 MemberServiceImpl 은 물론이고, MemberServiceImpl 이 구현한 인터페이스인 MemberService 로도 캐스팅 할 수 있다.

정리

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

  • CGLIB 프록시는 대상 객체인 MemberServiceImpl 타입에 의존관계 주입을 할 수 있다.

MemberServiceImpl 타입으로 의존관계 주입을 받는 것 처럼 구현 클래스에 의존관계를 주입하면 향후 구현 클래스를 변경할 때 의존관계 주입을 받는 클라이언트의 코드도 함께 변경해야 한다.

따라서 올바르게 잘 설계된 애플리케이션이라면 이런 문제가 자주 발생하지는 않는다.

그럼에도 불구하고 테스트, 또는 여러가지 이유로 AOP 프록시가 적용된 구체 클래스를 직접 의존관계 주입 받아야 하는 경우가 있을 수 있다. 이때는 CGLIB를 통해 구체 클래스 기반으로 AOP 프록시를 적용하면 된다.

프록시 기술과 한계 - CGLIB

CGLIB 구체 클래스 기반 프록시 문제점

대상 클래스에 기본 생성자 필수
생성자 2번 호출 문제
final 키워드 클래스, 메서드 사용 불가

대상 클래스에 기본 생성자 필수

  • CGLIB는 구체 클래스를 상속 받는다. 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다.

  • CGLIB를 사용할 때 CGLIB가 만드는 프록시의 생성자는 우리가 호출하는 것이 아니다. CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출한다. 따라서 대상 클래스에 기본 생성자를 만들어야 한다

생성자 2번 호출 문제

CGLIB는 구체 클래스를 상속 받는다. 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 부모 클래스의 생성자도 호출해야 한다. 그런데 왜 2번일까?

  1. 실제 target의 객체를 생성할 때
  2. 프록시 객체를 생성할 때 부모 클래스의 생성자 호출

final 키워드 클래스, 메서드 사용 불가

final 키워드가 클래스에 있으면 상속이 불가능하고, 메서드에 있으면 오버라이딩이 불가능하다. CGLIB는 상속을 기반으로 하기 때문에 두 경우 프록시가 생성되지 않거나 정상 동작하지 않는다.

프레임워크 같은 개발이 아니라 일반적인 웹 애플리케이션을 개발할 때는 final 키워드를 잘 사용하지 않는다. 따라서 이 부분이 특별히 문제가 되지는 않는다.

프록시 기술과 한계 - 스프링의 해결책

스프링 3.2, CGLIB를 스프링 내부에 함께 패키징

CGLIB를 사용하려면 CGLIB 라이브러리가 별도로 필요했다. 스프링은 CGLIB 라이브러리를 스프링 내부에 함께 패키징해서 별도의 라이브러리 추가 없이 CGLIB를 사용할 수 있게 되었다.

CGLIB 기본 생성자 필수 문제 해결

스프링 4.0부터 CGLIB의 기본 생성자가 필수인 문제가 해결되었다.

objenesis 라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능하다.

참고로 이 라이브러리는 생성자 호출 없이 객체를 생성할 수 있게 해준다.

생성자 2번 호출 문제

스프링 4.0부터 CGLIB의 생성자 2번 호출 문제가 해결되었다.
이것도 역시 objenesis 라는 특별한 라이브러리 덕분에 가능해졌다.

이제 생성자가 1번만 호출된다.

스프링 부트 2.0 - CGLIB 기본 사용

스프링 부트 2.0 버전부터 CGLIB를 기본으로 사용하도록 했다.
이렇게 해서 구체 클래스 타입으로 의존관계를 주입하는 문제를 해결했다.

스프링 부트는 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true 로 설정해서 사용한다.

따라서 인터페이스가 있어도 JDK 동적 프록시를 사용하는 것이 아니라 항상 CGLIB를 사용해서 구체클래스를 기반으로 프록시를 생성한다

profile
공부한 것을 잊지 않기 위해, 고민했던 흔적을 남겨 성장하기 위해 글을 씁니다.

0개의 댓글