스프링 AOP - 실전 예제, 실무 주의 사항
예제 만들기
지금까지 학습한 내용을 활용해서 유용한 스프링 AOP를 만들어보자.
@Trace
애노테이션으로 로그 출력하기@Retry
애노테이션으로 예외 발생시 재시도 하기
로그 출력 AOP
@Trace
가 메서드에 붙어 있으면 호출 정보가 출력되는 AOP를 만든다.
@Slf4j
@Aspect
public class TraceAspect {
@Before("@annotation(hello.aop.exam.annotation.Trace)")
public void doTrace(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
log.info("[trace] {} args={}", joinPoint.getSignature(), args);
}
}
@Import(TraceAspect.class)
를 사용해서 TraceAspect
를 스프링 빈으로 추가하자. 이제 애스펙트가 적용된다.
재시도 AOP
@Retry
애노테이션이 있으면 예외가 발생했을 때 다시 시도해서 문제를 복구한다.
@Retry
package hello.aop.exam.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
int value() default 3;
}
이 애노테이션에는 재시도 횟수로 사용할 값이 있다.
@Slf4j
@Aspect
public class RetryAspect {
@Around("@annotation(retry)")
public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
log.info("[retry] {} retry={}", joinPoint.getSignature(), retry);
int maxRetry = retry.value();
Exception exceptionHolder = null;
for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
try {
log.info("[retry] try count={}/{}", retryCount, maxRetry);
return joinPoint.proceed();
} catch (Exception e) {
exceptionHolder = e;
}
}
throw exceptionHolder;
}
}
@annotation(retry)
, Retry retry
를 사용해서 어드바이스에 애노테이션을 파라미터로 전달한다.
@annotation(retry)
에 타입 정보를 밝혀주지 않아도 되는 이유는, 파라미터에Retry retry
라는 타입 정보가 있기 때문이다.
참고
스프링이 제공하는 @Transactional
은 가장 대표적인 AOP이다.
실무 주의사항
지금까지 스프링 AOP의 동작 원리에 대해 깊이 있게 알아봤다. 그런데 스프링 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");
}
}
callServiceV0.external()
을 실행할 때는 프록시를 호출한다. 따라서 CallLogAspect
어드바이스가 호출된다.
그리고 AOP Proxy는 target.external()
을 호출한다.
그런데 여기서 문제는 callServiceV0.external()
안에서 internal()
을 호출할 때 발생한다. 이 때는 CallLogAspect
어드바이스가 호출되지 않는다.
내부 메서드(internal()
)를 호출할 때 this.internal()
이 되는데, 이런 내부 호출은 프록시를 거치지 않으므로 어드바이스를 적용할 수 없다.
프록시 방식의 AOP 한계
스프링은 프록시 방식의 AOP를 사용한다. 프록시 방식의 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다.
로드 타임 위버나, 컴파일 타임 AOP 경우 코드를 조작하거나 클래스 로딩 시점에 조작해버리기 때문에, 이런 일이 발생하지 않는다.
그렇다고 AspectJ를 쓸거냐? AspectJ를 사용하지 않고, 스프링 AOP만으로도 대응할 수 있는 대안이 있으므로, 대안을 알아보자.
프록시와 내부 호출 - 대안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");
}
}
callServiceV1
를 수정자를 통해서 주입 받아야 한다. (생성자 주입 방식은 순환 사이클 때문에 불가능 함)
주의
스프링 부트 2.6부터는 순환 참조를 기본적으로 금지하도록 정책이 변경되었다. 따라서 이번 예제를 스프링 부트 2.6 이상의 버전에서 실행하면 다음과 같은 오류 메시지가 나오면서 정상 실행되지 않는다.
이 문제를 해결하려면 application.properties
에 다음을 추가해야 한다.
spring.main.allow-circular-references=true
프록시와 내부 호출 - 대안2 지연 조회
스프링 빈(프록시)을 실제 실행 시점에 조회하여 해당 빈의 internal()
을 호출하는 것이다. (지연 조회)
/**
* ObjectProvider(Provider), ApplicationContext를 사용해서 지연(LAZY) 조회
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV2 {
// private final ApplicationContext applicationContext;
private final ObjectProvider<CallServiceV2> 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");
}
}
ApplicationContext
는 너무 많은 기능을 제공한다.ObjectProvider
는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있다.
프록시와 내부 호출 - 대안3 구조 변경
가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것이다. 실제 이 방법을 가장 권장한다.
/**
* 구조를 변경(분리)
*/
@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");
}
}
이 방법 외에도 다양한 방법들이 있을 수 있다. (ex. 클라이언트가 external()
, internal()
를 모두 직접 호출)
참고: private 메서드는?
AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용된다. 쉽게 이야기해서 인터페이스에 메서드가 나올 정도의 규모에 AOP를 적용하는 것이 적당하다. 더 풀어서 이야기하면 AOP는 public
메서드에만 적용한다. private
메서드처럼 작은 단위에는 AOP를 적용하지 않는다.
AOP 적용을 위해 private
메서드를 외부 클래스로 변경하고 public
으로 변경하는 일은 거의 없다고 한다.
AOP가 잘 적용되지 않으면 내부 호출을 의심해보자.
프록시 기술과 한계 - 타입 캐스팅
JDK 동적 프록시와 CGLIB를 사용해서 AOP 프록시를 만드는 방법에는 각각 장단점이 있다.
복습
스프링은 기본적으로 CGLIB를 사용하지만, 스프링이 프록시를 만들때 제공하는 ProxyFactory
에 proxyTargetClass
옵션에 따라 둘중 하나를 선택해서 프록시를 만들 수 있다.
proxyTargetClass=false
JDK 동적 프록시를 사용해서 인터페이스 기반 프록시 생성proxyTargetClass=true
CGLIB를 사용해서 구체 클래스 기반 프록시 생성- 옵션과 무관하게 인터페이스가 없으면 JDK 동적 프록시를 적용할 수 없으므로 CGLIB를 사용한다.
JDK 동적 프록시 한계
인터페이스 기반으로 프록시를 생성하는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다.
@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;
});
}
}
JDK Proxy를 대상 클래스인 MemberServiceImpl
타입으로 캐스팅 하려고 하니 예외가 발생한다. 왜냐하면 JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성하기 때문이다.
JDK Proxy는 MemberService
로 캐스팅은 가능하지만 MemberServiceImpl
이 어떤 것인지 전혀 알지 못한다. 따라서 MemberServiceImpl
타입으로는 캐스팅이 불가능하다
CGLIB라면?
@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;
}
CGLIB는 구체 클래스를 기반으로 프록시를 생성하기 때문에 성공한다.
그런데 프록시를 캐스팅 할 일이 많지 않을 것 같은데 왜 이 이야기를 하는 것일까? 진짜 문제는 의존관계 주입시에 발생한다.
프록시 기술과 한계 - 의존관계 주입
아래와 같은 Aspect가 있다고 해보자.
@Slf4j
@Aspect
public class ProxyDIAspect {
@Before("execution(* hello.aop..*.*(..))")
public void doTrace(JoinPoint joinPoint) {
log.info("[proxyDIAdvice] {}", joinPoint.getSignature());
}
}
@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");
}
}
spring.aop.proxy-target-class=false
설정을 사용해서 스프링 AOP가 JDK 동적 프록시를 사용하도록 해보자.
이렇게 실행하면 다음과 같이 오류가 발생한다.
실행 결과BeanNotOfRequiredTypeException: Bean named 'memberServiceImpl' is expected to be of type 'hello.aop.member.MemberServiceImpl' but was actually of type 'jdk.proxy3.$Proxy56'
타입과 관련된 예외가 발생한다. 자세히 읽어보면 memberServiceImpl
에 주입되길 기대하는 타입은 hello.aop.member.MemberServiceImpl
이지만 실제 넘어온 타입은 jdk.proxy3.$Proxy56
이다. 따라서 타입 예외가 발생한다고 한다.
JDK 프록시를 사용하면, 반드시 의존관계 주입 시 인터페이스에만 의존해야 한다.
spring.aop.proxy-target-class=ture
설정을 사용해서 스프링 AOP가 CGLIB를 사용하도록 해보자.
CGLIB를 사용하면, 인터페이스와 구체 클래스 타입 모두 주입 가능하다.
정리
- JDK 동적 프록시는 대상 객체인
MemberServiceImpl
타입에 의존관계를 주입할 수 없다. - CGLIB 프록시는 대상 객체인
MemberServiceImpl
타입에 의존관계 주입을 할 수 있다.
물론 올바르게 잘 설계된 애플리케이션이라면 이런 문제가 자주 발생하지는 않는다.
그럼에도 불구하고 테스트, 또는 여러가지 이유로 AOP 프록시가 적용된 구체 클래스를 직접 의존관계 주입 받아야 하는 경우가 있을 수 있다. 이때는 CGLIB를 통해 구체 클래스 기반으로 AOP 프록시를 적용하면 된다.
프록시 기술과 한계 - CGLIB
JDK 프록시만 단점이 있는 것은 아니다. CGLIB도 예전에 봤던 것처럼 구체 클래스 상속에서 오는 문제가 발생한다.
CGLIB 구체 클래스 기반 프록시 문제점
- 대상 클래스에 기본 생성자 필수
- 생성자 2번 호출 문제
- final 키워드 클래스, 메서드 사용 불가
- 실제
target
의 객체를 생성할 때 - 프록시 객체를 생성할 때 부모 클래스의 생성자 호출
프록시 기술과 한계 - 스프링의 해결책
JDK Proxy, CGLIB 모두 단점이 존재한다. 스프링은 어떤 방법을 권장할까?
- 스프링 3.2, CGLIB를 스프링 내부에 함께 패키징
spring-core
org.springframework
- CGLIB 기본 생성자 필수 문제 해결
- 스프링 4.0부터 CGLIB의 기본 생성자가 필수인 문제가 해결되었다.
objenesis
라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능하다.- 이 라이브러리가 생성자 2번 호출 문제도 해결했다.
스프링 부트 2.0 - CGLIB 기본 사용
스프링 부트 2.0 버전부터 CGLIB를 기본으로 사용하도록 했다.
이렇게 해서 구체 클래스 타입으로 의존관계를 주입하는 문제를 해결했다.
스프링 부트는 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true
로 설정해서 사용한다.
인터페이스가 있어도 구체 클래스를 상속받아 프록시를 생성하는 것이다.
final 문제
CGLIB의 남은 문제라면 final
클래스나 final
메서드가 있는데, AOP를 적용할 대상에는 final
클래스나 final
메서드를 잘 사용하지는 않으므로 이 부분은 크게 문제가 되지는 않는다.
'Spring > AOP' 카테고리의 다른 글
[Spring AOP] 스프링 AOP - 포인트컷 (0) | 2025.07.06 |
---|---|
[Spring AOP] 스프링 AOP 구현 (0) | 2025.07.06 |
[Spring AOP] 스프링 AOP 개념 (0) | 2025.07.06 |
[Spring AOP] @Aspect AOP (0) | 2025.07.06 |
[Spring AOP] 빈 후처리기 (0) | 2025.07.06 |