스프링 AOP - 실무 주의사항

slee2·2022년 4월 3일
1

문제

스프링은 프록시 방식의 AOP를 사용한다.
그렇기 때문에 AOP를 적용하려면 항상 프록시를 통해서 객체를 호출해야한다.
만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않는다.

AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다.
그러므로 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다.

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

실무에서 반드시 한번은 만나서 고생하는 문제이다.

먼저 내부 호출이 발생할때 어떤 문제가 발생하는지 예제로 알아보자.

CallServiceV0

externel 메서드에서 internal메서드를 호출하는 모습을 확인할 수 있다.

CallLogAspect

간단하게 CallServiceV0가 포인트컷 범위에 들어가므로, Advice메서드인 doLog가 적용된다.

Test

이 테스트를 보면 callServiceV0가 처음에 프록시로 생성되기 때문에 externel()를 호출했을 때, doLog가 실행되어 처음에 로그가 찍힌 후, externel()이 실행된다. 그런데 이 메서드에는 internel()메서드가 실행되는데,
이 메서드에는 AOP 적용이 안된것을 확인할 수 있다.

이번에는 외부에서 internal()을 호출하는 테스트를 실행해보자.

요건 당연히 잘 된다.

프록시 방식 AOP의 한계
프록시를 사용하는 AOP에서는 내부 메서드에 프록시를 적용할 수 없다.
이 문제를 해결해보자.

참고
실제 AspectJ는 코드자체를 모든 메서드에 넣어버리기 때문에 이런 문제가 발생하지 않는다.
하지만, 설정이 복잡하고 JVM 옵션을 줘야하는 부담이 있기도 하며,
대안이 있기 때문에 실무에서 AspectJ를 직접 다루지는 않는다.

대안1 - 자기 자신 주입

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

CallServiceV1

생성자를 만들고 의존주입을 해버리면, 생성되지도 않은 자기 자신을 의존관계에 넣으려고 하기 때문에 순환참조 오류가 발생한다.
그래서 setter방식을 통해 생성 후 의존관계 주입을 하려 했으나,
스프링 부트 2.6에서부터 순환 참조를 기본적으로 금지하도록 변경이 되었다.

이를 해결하기 위해

설졍에 이 옵션을 추가해야 한다.

그럼 이렇게 성공한 것을 확인할 수 있다.

하지만, setter를 사용해야 한다는것이 조금 어색하다.
더 좋은 방안을 모색해보자.

대안2 - 지연 조회

이번에는 지연 조회를 통해 나중에 해당 인스턴스를 꺼내와 조회를 하는 것이다.
ObjectProvider, ApplicationContext 둘중에 하나를 사용하면 된다.

하지만, ApplicationContext의 경우에는 기능이 많고 무거운 친구이기 때문에, 딱 기능에 맞는 ObjectProvider를 사용해서 꺼내오게 되면, 해당 메서드에 AOP가 적용된다.

그런데 이것보다 더 좋은 대안이 있는 것 같다.

대안3 - 구조 변경

가장 좋은 대안은, 내부 호출이 발생하지 않도록 구조를 변경하는 것이다.
스프링에서도 이 방법을 지향한다.

InternalService

CallServiceV3

내부 호출을 없애고, 메서드를 실행하는 클래스를 새로 만들어 의존주입을 시키는 방법을 택했다.

구조 변경의 방법은 이 방법 말고도 여러 방법이 존재한다.

external()를 호출하는 곳에서 internel()도 호출하는 방법도 있다.

AOP가 잘 적용되지 않으면 내부 호출을 의심해보자.

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

프록시는 두가지 방식이 있다.
JDK 동적 프록시와 CGLIB이다.
JDK 동적 프록시는 인터페이스가 필수이고,
CGLIB는 구체 클래스 기반으로 프록시를 생성한다.

스프링에서 옵션을 이용해서 프록시를 생성할때 둘 중 하나를 선택해서 만들 수 있다.
proxyTargetClass=false - JDK 동적 프록시로만 생성
proxyTargetClass=true - CGLIB로만 생성
그리고 옵션과 무관하게 인터페이스가 없으면 CGLIB를 사용한다.

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


JDK 프록시는 MemberService를 프록시로 만들기 때문에 캐스팅이 가능하지만, MemberServiceImpl은 누군지도 모르기 때문에 캐스팅이 불가능하다.

CGLIB의 경우 MemberServiceImpl을 타겟으로 프록시를 생성하기 때문에 MemberServiceImpl은 물론이고, 그 부모인 MemberService도 캐스팅할 수 있다.

이 문제는 의존관계 주입시에 크게 작용한다.

프록시 기술과 한계 - 의존관계 주입

이 경우에 의존관계 주입과정에서 오류가 발생한다.
이유는 기대하던 값과 다른다는 이유다.

처음에 @Autowired MemberService memberService의 경우,
JDK 프록시는 MemberService 인터페이스로 생성이 가능하다.
하지만 @Autowired MemberServiceImpl memberServiceImpl의 경우 해당 인터페이스 MemberService를 기반으로 프록시를 생성하는데, 이 프록시는 MemberServiceImpl을 모르기 때문에 해당 타입에 주입을 할 수 없다.

하지만 CGLIB는 둘다 알기 때문에 의존주입이 가능하다.

프록시 기술과 한계 - CGLIB

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

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

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

CGLIB는 구체 클래스를 상속 받는다.
상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해줘야 한다. 생략되어있으면 super()가 생성자에 자동으로 들어간다.

생성자 2번 호출 문제

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

생성자가 두번 호출된다는 것은 큰 문제로 번질수도 있다.

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

이부분은 크게 문제가 되지는 않는다.

보통 개발할 때는 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를 사용해서 구체클래스를 기반으로 프록시를 생성한다.

물론 스프링은 우리에게 선택권을 열어주기 때문에 다음과 같이 설정하면 JDK 동적 프록시도 사용할 수 있다.

application.properties

spring.aop.proxy-target-class=false

정리
스프링은 최종적으로 스프링 부트 2.0에서 CGLIB를 기본으로 사용하도록 결정했다. CGLIB를 사용하면 JDK 동적 프록시에서 동작하지 않는 구체 클래스 주입이 가능하다. 여기에 추가로 CGLIB의 단점들이 이제는 많이 해결되었다. CGLIB의 남은 문제라면 final 클래스나 final 메서드가 있는데, AOP를 적용할 대상에는 final 클래스나 final 메서드를 잘 사용하지는 않으므로 이 부분은 크게 문제가 되지는 않는다.

아무튼 개발자는 아무 설정을 하지 않아도 편리하게 프록시를 사용할 수 있게 되었다.

0개의 댓글