이번 글에선 스프링 AOP를 실제로 사용할 때, 주의해야할 사항들에 대해 살펴보자.
스프링 AOP를 프록시 방식의 AOP를 사용하기에 다음과 같은 문제점들이 있다.
1) 내부 호출시 실제 객체를 사용하여 프록시 적용이 안되는 문제
2)프록시 기술 자체의 한계
프록시와 내부 호출 - 문제
스프링은 프록시 방식의 AOP를 사용한다.
따라서 AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다.
이렇게 해야 프록시에서 먼저 어드바이스를 호출하고, 이후에 대상 객체를 호출한다.
만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 어드바이스도 호출되지 않는다.
AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다.
하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다.
ex: A 메소드안에서 같은 클래스 내에 @Transactional이 붙은 B 메소드를 호출 시( this.B(); ), 의존관계 주입을 통해 객체를 가져오지 않기에,
프록시가 아닌 실제 객체를 가져오게되고, B메소드의 프록시가 수행하는 @Trascational에 대한 부가 기능을 수행하지 않기에,
B메소드는 @Transactional을 통한 트랜잭션 관리 기능을 사용하지 못한다.
실무에서 반드시 한번은 만나서 고생하는 문제이기 때문에 꼭 이해하고 넘어가자.
코드 예시
external이 internal을 this.intenal()로 내부 호출하도록 구성한다.
@Slf4j
@Component
public class CallServiceV0 {
public void external() {
log.info("call external");
internal(); //내부 메서드 호출(this.internal()) -> 프록시를 호출하는 것이 아닌, 실제 객체 호출
}
public void internal() {
log.info("call internal");
}
}
aspect를 구성한다.
@Slf4j
@Aspect
public class CallLogAspect {
@Before("execution(* 테스트코경로..*.*(..))")
public void doLog(JoinPoint joinPoint) {
log.info("aop={}", joinPoint.getSignature());
}
}
위와 같이, 테스트 코드 안의 클래스들에 프록시를 적용하도록 구성한다.
이제 해당 aspect를 빈에 등록한 뒤, aspect의 포인트컷에 명시한 패키지 경로안의 테스트 코드를 수행해보자.
Test
@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {
@Autowired CallServiceV0 callServiceV0;
@Test
void external() {
callServiceV0.external();
}
@Test
void internal() {
callServiceV0.internal();
}
}
실행결과
스프링 AOP는 프록시 방식이고, 수행 시점은 메소드 시점만 가능하기에 external(), internal 모두 프록시가 적용되어 수행되어야하지만, 아래와 같이 external에 대해서만 CallAspect가 동작한다.
이는 external에서 실제 internal() 객체를 호출하여 프록시가 적용되지 않기 때문이다.
이는 스프링 AOP가 프록시 방식의 AOP를 사용하기에 발생한 문제이다.
만약 aspectj 를 통해 컴파일 시점, 로드 타임 위빙 AOP 등 다른 방식의 AOP를 적용하면, 이러한 문제가 없다.
하지만, 컴파일 시점 AOP는 실사용 불가한 문제들이 많고, 로드 타임 위빙은 복잡한 설명 및 JVM 옵션을 주어야하는 문제가 있기에 잘 사용하지 않는다
이런 이유로 AspectJ를 직접 사용하는 방법은 실무에서는 거의 사용하지 않는다.
그럼 이제 이러한 내부 호출 시 스프링 AOP(프록시 방식 AOP) 미적용 문제를 해결하는 방법을 살펴보자.
프록시와 내부 호출 - 대안1 자기 자신 주입(Bad Solution)
자기자신을 의존성 주입을 통해 받아오면, 등록된 프록시를 가져오기에, 내부 호출시 프록시 미적용 문제가 발생하지 않는다.
참고로, 생성자 주입은 순환참조가 발생해서 안된다.( 자신의 생성시점에 자신을 요구해봤자, 생성되지 않는다.)
따라서 필드 주입 방식을 사용해서 자기 자신을 스프링 컨테이너에게 DI받아야한다.
이제는 internal() 을 호출할 때 자기 자신의 인스턴스를 호출하는 것이 아니라 프록시 인스턴스를 통해서 호출하기에 당연히 AOP도 잘 적용할 수 있다.
프록시와 내부 호출 - 대안2 지연 조회(Bad Solution)
위와 같이 자기자신을 의존성 주입받는 방법이다.
다만 필드 주입 방식은 비권장되는 방식이기에, 생성자 주입을 사용하되, 순환 참조가 발생되지 않도록 애플리케이션 서버가
전부 올라가고 나서 생성자가 수행되어서, 스프링 빈 조회를 지연하면 된다.
이는 ObjectProvider(Provider) , ApplicationContext 를 사용하면 된다.
하지만 ApplicationContext는 매우 거대하기에 전체를 가져오는건, 너무 낭비이다.
ObjectProvider를 사용하면, 내가 실제로 필요한 [빈 조회 기능] 만 주입받아서 실행 시점을 정할 수 있다.
Object Provider: 스프링 컨테이너객체(ApplicationContext)에서 실제 빈 조회 관련 기능이다.
Object Provider를 생성자에서 주입 받은 뒤, 내가 내부 참조로 사용할 빈의 실제 사용 시점에서 .getbean()을 통해
의존관계를 가져오는 것으로 빈 조회 시점을 .getbean() 호출 시점까지 지연할 수 있다.
Code Example
@Slf4j
@Component
public class CallServiceV2 {
// private final ApplicationContext applicationContext; //-> 너무 거대하다. bad!
private final ObjectProvider<CallServiceV2> callServiceProvider;
public CallServiceV2(ObjectProvider<CallServiceV2> callServiceProvider) {
this.callServiceProvider = 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");
}
}
하지만 대안 1,2 모두 억지로 하는 느낌이 든다.(안되는 걸 되게하는 느낌)
프록시와 내부 호출 - 대안3 구조 변경(good Solution)
앞선 방법들은 자기 자신을 주입하거나 또는 Provider 를 사용해야 하는 것 처럼 조금 어색한 모습을 만들었다.
가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것이다. 실제 이 방법을 가장 권장한다.
스프링에서 안되는 것은 다 이유가 있고, 스프링의 사용의도를 알기 위해선 주어진 제약조건을 최대한 어기지 않는 것이 좋다.
쉽게 말해서 그냥 "내부 호출"(순환 참조의 일부)이 스프링에서 막혀있는 것은 의도되어 있고, 이러한 의도를 어기지 않도록 내부 호출 자체를 없도록 설계하라는 의미이다.
가장 단순한 내부 호출을 사용하지 않는 구조 변경으로는, 클래스 자체를 분리해버리는 방법이 있다.
기존에 CallService라는 동일한 클래스 내에서 inernal, external 호출을 사용했는데, 이제 internal() 메소드를 별도의 클래스로 분리한 뒤, DI 받으면 된다.
external() 메소드를 수행하는 클래스
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {
private final InternalService internalService;
public void external() {
log.info("call external");
internalService.internal(); //외부 메서드 호출
}
}
inernal()을 수행하는 클래스를 CallService로 부터 분리한다.
@Slf4j
@Component
public class InternalService {
public void internal() {
log.info("call internal");
}
}
여기서 구조를 변경한다는 것은 이렇게 단순하게 분리하는 것 뿐만 아니라 다양한 방법들이 있을 수 있다.
예를 들어서 다음과 같이 클라이언트에서 둘다 호출하는 것이다.
클라이언트 -> external()
클라이언트 -> internal()
물론 이 경우 external() 에서 internal() 을 내부 호출하지 않도록 코드를 변경해야 한다. 그리고 클라이언트가 external() , internal() 을 모두 호출하도록 구조를 변경하면 된다. (물론 가능한 경우에 한해서)
**참고
AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용된다.(거대한 범위에 적용되는 것만...)
쉽게 이야기해서 인터페이스에 메서드가 나올 정도의 규모에 AOP를 적용하는 것이 적당하다.
더 풀어서 이야기하면 AOP는 public 메서드에만 적용한다.
private 메서드처럼 작은 단위에는 AOP를 적용하지 않는다.
AOP 적용을 위해 private 메서드를 외부 클래스로 변경하고 public 으로 변경하는 일은 거의 없다.
그러나 위 예제와 같이 public 메서드에서 public 메서드를 내부 호출하는 경우에는 문제가 발생한다.
프록시 기술과 한계
JDK 동적 프록시와 CGLIB를 사용해서 AOP 프록시를 만드는 방법에는 각각 장단점이 있다. JDK 동적 프록시는 인터페이스가 필수이고, 인터페이스를 기반으로 프록시를 생성한다. CGLIB는 구체 클래스를 기반으로 프록시를 생성한다.
이러한 차이점으로 인해, JDK 동적 프록시와 CGLIB는 각각의 장단점이 존재한다.
proxyFactory 기본 설정
- 구체 클래스만 있는 경우에는 CGLIB만 사용된다.
- 인터페이스가 있는 경우에는 JDK 동적 프록시나 CGLIB 둘중에 하나를 선택할 수 있다.
스프링이 프록시를 만들때 제공하는 ProxyFactory 에 proxyTargetClass 옵션에 따라 둘중 하나를 선택해서 프록시를 만들 수 있다.
proxyTargetClass=false JDK 동적 프록시를 사용해서 인터페이스 기반 프록시 생성
proxyTargetClass=true CGLIB를 사용해서 구체 클래스 기반 프록시 생성
참고로 옵션과 무관하게 인터페이스가 없으면 JDK 동적 프록시를 적용할 수 없으므로 CGLIB를 사용한다.
이번에는, JDK 동적 프록시, CGLIB 각가의 장단점을 알아보자.
우선 JDK 동적 프록시의 단점을 살펴보자.
JDK 동적 프록시 한계
- 구체 클래스로 타입 캐스팅이 불가능하다
- 따라서 구체 클래스를 DI받을 수 없다.
JDK 동적 프록시 한계- 타입 캐스팅
인터페이스 기반으로 프록시를 생성하는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다. 어떤 한계인지 코드를 통해서 알아보자.
@Test
void jdkProxy() {
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(false); //JDK 동적 프록시
//프록시를 인터페이스로 캐스팅 성공
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
//JDK 동적 프록시를 구현 클래스로 캐스팅 시도 실패, ClassCastException 예외 발생
assertThrows(ClassCastException.class, () -> {
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
});
}
동적 JDK 사용시, 프록시가 MemberService 인터페이스를 기반으로 생성되는데, MemberServiceImpl에 대해서는 전혀 알지 못한다.
만약 동적 JDK로 생성한 프록시를 구체 클래스로 타입 캐스팅시, 실패한다.
MemberService(프록시가 알고 있는 객체) -> MemberService(캐스팅할 타입)으로는 타입 캐스팅이 가능하지만,
MemberService(프록시가 알고 있는 객체) -> MemberServiceImpl(캐스팅할 타입) 으로는 타입 캐스팅이 불가능하다.
**참고
왜 MemberService -> MemberServiceImpl 는 타입 캐스팅이 안될까?
구체적인 영역(구체 클래스)을 덜 구체적으로 만드려면 구체적인 영역중 몇가지를 제외하기만 하면된다.
반면 덜 구체적인 영역을 더 구체적으로 만들기 위해선, 단순 타입 캐스팅만 수행하면 구체적인 영역에서 추가된 멤버들이
구현되어있지 않기에 ClassCastException 에러를 만나게된다.
그럼 CGLIB를 사용해서 구체 클래스를 상속받아 프록시를 생성하면?
MemberServiceImpl(프록시가 알고 있는 객체) -> MemberService(캐스팅할 타입)
MemberServiceImpl(프록시가 알고 있는 객체) -> MemberServiceImpl(캐스팅할 타입)
당연히 둘다 가능하다. (덜 구체적으로 만드는건 앞서 말했듯이, 단순히 추가 구현된 부분을 배제하면 된다.)
하지만, 실제로 프록시를 캐스팅 하는 일을 흔치 않다. 진짜 문제는 의존관계 주입에서 발생한다.
JDK 동적 프록시 한계 - 의존관계 주입
동적 JDK 사용시, 의존관계 주입을 할 때, 구체 클래스를 DI받으면, 타입 캐스팅 에러가 발생한다.
**참고
구체 클래스를 DI받는건 나쁜 설계이기에 최대한 피해야한다. 하지만 테스트 등 불가피한 경우도 있는 법이기에, 무시할 수 없다.
우선 AOP 프록시 구서을 위해 간단한 Aspect를 생성해보자.
@Slf4j
@Aspect
public class ProxyDIAspect {
@Before("execution(* hello.aop..*.*(..))")
public void doTrace(JoinPoint joinPoint) {
log.info("[proxyDIAdvice] {}", joinPoint.getSignature());
}
}
Test
@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"}) //JDK 동적 프록시
//@SpringBootTest(properties = {"spring.aop.proxy-target-class=true"}) //CGLIB 프록시
//@SpringBootTest
@Import(ProxyDIAspect.class)
public class ProxyDITest {
@Autowired
MemberService memberService; //정상적으로 DI 된다
@Autowired
MemberServiceImpl memberServiceImpl; //문제 발생! memberService 기반의 프록시는 memberServiceImpl를 알지 못한다.
@Test
void go() {
log.info("memberService class={}", memberService.getClass());
log.info("memberServiceImpl class={}", memberServiceImpl.getClass());
memberServiceImpl.hello("hello");
}
}
테스트 코드에서 memberService, memberServiceImpl을 주입받고, 앞서 만든 Aspect를 @Import를 사용하여 빈에 등록한다.
{"spring.aop.proxy-target-class=true"}를 통해인터페이스가 들어올 경우, JDK 동적 프록시를 사용하도록 코드를 구성했다.
결과
테스트 수행시, 테스트가 실패한다.
앞서 말한 타입 캐스팅 문제가 발생하는 것.
memberService 기반의 프록시가 생성되었는데, memberServiceImpl를 DI받으려고하면, 타입 매칭을 통해 빈을 찾게 된다.
하지만, memberServiceImpl -> memberService로 타입 캐스팅이 불가하기에
결론적으로, 동적 JDK를 통한 인터페이스 기반 프록시 구성 시, 구체 클래스는 DI 받을 수 없다.
CGLIB 사용시
타입 캐스팅 때 봤지만, 당연히 구체 클래스도 DI를 받을 수 있다.
( memberServiceImpl -> memberService 타입 캐스팅 가능)
정리
- JDK 동적 프록시는 대상 객체인 MemberServiceImpl 타입에 의존관계를 주입할 수 없다.
- CGLIB 프록시는 대상 객체인 MemberServiceImpl 타입에 의존관계 주입을 할 수 있다.
실제로 개발할 때는 인터페이스가 있으면 인터페이스를 기반으로 의존관계 주입을 받는 것이 올바른 설계이다.
(인터페이스, 구체 클래스간 느슨한 결합, 클라이언트 코드의 변경 없이 구체 클래스 변경 가능 등등 여러 이점)
따라서 DI의 장점을 살린 올바른 설계라면 JDK 동적 프록시를 사용하는 것이 문제 없어 보인다.
하지만, 테스트, 기타 등등의 이유로 구체 클래스를 주입받아야하는 경우에는 CGLIB를 사용해야한다.
여태까지는 동적 JDK의 단점에 대해 알아봤다.
이제 CGLIB의 단점에 대해 알아보자.
CGLIB 구체 클래스 기반 프록시 문제점
- 대상 클래스에 기본 생성자 필수
- 생성자 2번 호출 문제
- final 키워드 클래스, 메서드 사용 불가
CGLIB 구체 클래스 기반 프록시 한계 - target클래스에 기본 생성자 필수
CGLIB는 구체 클래스를 상속 받는다. 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다. (이 부분이 생략되어 있다면 자식 클래스의 생성자 첫줄에 부모 클래스의 기본 생성자를 호출하는 super() 가 자동으로 들어간다.) 이 부분은 자바 문법 규약이다.
CGLIB를 사용할 때 CGLIB가 만드는 프록시의 생성자는 우리가 호출하는 것이 아니다. CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출( super(); )한다. 따라서 대상 클래스에 기본 생성자를 만들어야 한다. (기본 생성자는 파라미터가 하나도 없는 생성자를 뜻한다. 생성자가 하나도 없으면 자동으로 만들어진다.)
CGLIB 구체 클래스 기반 프록시 한계 - 실제 target 객체의 생성자 2번 호출 문제
1. 실제 target의 객체를 생성할 때
2. 프록시 객체를 생성할 때 부모 클래스의 생성자 호출
말도 안되는 설계이지만, 만약 MemberServiceImpl의 생성자에 과금 로직이 있다면,
CGLIB를 사용했기에 과금이 2번 수행될 수 도 있다.
CGLIB 구체 클래스 기반 프록시 한계 - final 키워드 클래스, 메서드 사용 불가
final 키워드가 클래스에 있으면 상속이 불가능하고, 메서드에 있으면 오버라이딩이 불가능하다. CGLIB는 상속을 기반으로 하기 때문에 두 경우 프록시가 생성되지 않거나 정상 동작하지 않는다.
프레임워크 같은 개발이 아니라 일반적인 웹 애플리케이션을 개발할 때는 final 키워드를 잘 사용하지 않는다. 따라서 이 부분이 특별히 문제가 되지는 않는다.
정리
JDK 동적 프록시는 대상 클래스 타입으로 주입할 때 문제가 있고,
CGLIB는 대상 클래스에 기본 생성자 필수, 생성자 2번 호출 문제가 있다.
그렇다면 스프링은 어떤 방법을 권장할까?
프록시 기술과 한계 - 스프링의 해결책
1) CGLIB가 스프링 내부에 패키징되어서 라이브러리를 추가안해도 된다.
2)CGLIB 기본 생성자 필수 문제 해결
스프링 4.0부터 objenesis라는 특별한 라이브러리를 도입하여 CGLIB를 사용해도
기본 생성자 필수 문제, 타겟 객체 생성자 2번 호출 문제가 해결되었다.
스프링 부트 2.0 - CGLIB 기본 사용
스프링 부트 2.0 버전부터 CGLIB를 기본으로 사용하도록 했다.
이렇게 해서 구체 클래스 타입으로 의존관계를 주입하는 문제를 해결했다.
스프링 부트는 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true 로 설정해서 사용한다.
따라서 인터페이스가 있어도 JDK 동적 프록시를 사용하는 것이 아니라 항상 CGLIB를 사용해서 구체클래스를 기반으로 프록시를 생성한다.
물론 스프링은 우리에게 선택권을 열어주기 때문에 아래 설정을 통해 JDK 동적 프록시도 사용할 수 있다.
spring.aop.proxy-target-class=false
정리
스프링은 최종적으로 스프링 부트 2.0에서 CGLIB를 기본으로 사용하도록 결정했다. CGLIB를 사용하면 JDK 동적 프록시에서 동작하지 않는 구체 클래스 주입이 가능하다. 여기에 추가로 CGLIB의 단점들이 이제는 많이 해결되었다. CGLIB의 남은 문제라면 final 클래스나 final 메서드가 있는데, AOP를 적용할 대상에는 final 클래스나 final 메서드를 잘 사용하지는 않으므로 이 부분은 크게 문제가 되지는 않는다.
'Spring boot' 카테고리의 다른 글
스프링 MVC (0) | 2023.10.21 |
---|---|
스프링 핵심원리 12 [커스텀 AOP 어노테이션 만들기] (1) | 2023.06.19 |
스프링 핵심원리 11 [포인트 컷] (0) | 2023.06.17 |
스프링 핵심원리 10 [스프링 AOP 사용법] (0) | 2023.06.17 |
스프링 핵심원리 9 [스프링 AOP] (0) | 2023.06.17 |