[스프링 핵심원리 - 고급] 스프링 AOP 실무 주의사항
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.
프록시와 내부 호출
문제점
스프링은 프록시 방식의 AOP를 사용한다.
따라서 AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다.
이렇게 해야 프록시에서 먼저 어드바이스를 호출하고, 이후에 대상 객체를 호출한다.
만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 어드바이스도 호출되지 않는다.
AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다.
프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다.
하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다.
@Slf4j
@Component
public class CallServiceV0 {
public void external() {
log.info("call external");
internal(); //내부 메서드 호출(this.internal())
}
public void internal() {
log.info("call internal");
}
}
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {
@Autowired
CallServiceV0 callServiceV0;
@Test
void external() {
callServiceV0.external();
}
@Test
void internal() {
callServiceV0.internal();
}
}
문제는 callServiceV0.external() 안에서 internal() 을 호출할 때 발생한다.
이때는 CallLogAspect 어드바이스가 호출되지 않는다.
스프링 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다.
해결1) 지연조회
ObjectProvider 는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있다.
@Slf4j
@Component
public class CallServiceV2 {
private final ObjectProvider<CallServiceV2> callServiceProvider;
public CallServiceV2(ObjectProvider<CallServiceV2> callServiceProvider) {
this.callServiceProvider = callServiceProvider;
}
public void external() {
log.info("call external");
CallServiceV2 callServiceV2 = callServiceProvider.getObject();
callServiceV2.internal(); //외부 메서드 호출
}
public void internal() {
log.info("call internal");
}
}
- callServiceProvider.getObject() 를 호출하는 시점에 스프링 컨테이너에서 빈을 조회한다.
- 여기서는 자기 자신을 주입 받는 것이 아니기 때문에 순환 사이클이 발생하지 않는다.
CallServiceV2 자기 자신을 ObjectProvider로 받아온다.
이걸 통해 자신(CallServiceV2) 객체를 필요할 때 다시 스프링 컨테이너에서 꺼낼 수 있다.
external() 메서드 안에서 this.internal()을 직접 호출하는 게 아니다.
새로 꺼내온 프록시(CallServiceV2)를 통해 internal()을 호출한다.
이 방식 덕분에, 만약 internal()에 AOP(예: 트랜잭션, 로깅 등)가 걸려있으면
프록시를 통해 호출되기 때문에 내부 호출로 인해 AOP가 무시되는 문제를 해결할 수 있다.
ObjectProvider(Provider)
일반적으로 스프링은 빈을 주입하면, 생성자 호출 시점에 딱 한 번 빈을 고정해서 넣어준다.
그런데 ObjectProvider를 사용하면, 언제든지 .getObject() 호출할 때마다 스프링 컨테이너에 요청해서 빈을 가져올 수 있다.
특징
- 지연 조회(lazy lookup)가 가능하다.
- 순환 참조, 내부 호출 같은 문제를 우회할 수 있다.
- Optional 지원 등 약간의 부가기능도 제공한다.
@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV2Test {
@Autowired
CallServiceV2 callServiceV2;
@Test
void external() {
callServiceV2.external();
}
}
해결2) 구조 변경
가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것이다. 실제 이 방법을 가장 권장한다.
@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");
}
}
@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV3Test {
@Autowired
CallServiceV3 callServiceV3;
@Test
void external() {
callServiceV3.external();
}
}
내부 호출 자체가 사라지고, callService -> internalService 를 호출하는 구조로 변경되었다. 덕분에 자연스럽게 AOP가 적용된다.
프록시 기술과 한계
CGLIB 기본 생성자 필수 문제 해결
- 스프링 4.0부터 CGLIB의 기본 생성자가 필수인 문제가 해결되었다.
- objenesis 라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능하다.
- 참고로 이 라이브러리는 생성자 호출 없이 객체를 생성할 수 있게 해준다.
생성자 2번 호출 문제
- 스프링 4.0부터 CGLIB의 생성자 2번 호출 문제가 해결되었다.
- 이것도 역시 objenesis 라는 특별한 라이브러리 덕분에 가능해졌다.
- 이제 생성자가 1번만 호출된다.
스프링 부트 2.0부터 CGLIB 기본 사용
- 스프링 부트 2.0 버전부터 CGLIB를 기본으로 사용하도록 했다.
- 이렇게 해서 구체 클래스 타입으로 의존관계를 주입하는 문제를 해결했다.
- 스프링 부트는 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true로 설정해서 사용한다.
- 따라서 인터페이스가 있어도 JDK 동적 프록시를 사용하는 것이 아니라 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성한다.
스프링은 최종적으로 스프링 부트 2.0에서 CGLIB를 기본으로 사용하도록 결정했다.
CGLIB를 사용하면 JDK 동적 프록시에서 동작하지 않는 구체 클래스 주입이 가능하다. 여기에 추가로 CGLIB의 단점들이 이제는 많이 해결되었다.
CGLIB의 남은 문제라면 final 클래스나 final 메서드가 있는데, AOP를 적용할 대상에는 final 클래스나 final 메서드를 잘 사용하지는 않으므로 이 부분은 크게 문제가 되지는 않는다.