오늘은 Spring AOP에 대해 글을 써보려 한다.
Spring AOP가 어떤 방식으로 프록시를 사용하는지 알아보자.
재고 차감 시나리오
익숙할만한 코드를 하나 준비했다. 사용자가 주문하면 재고를 차감시키는 로직이다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
public void order(Long productId, int quantity) {
// 1. 주문 정보 저장
Order order = createOrder(productId, quantity);
// 2. 결제 진행
try {
return callPg(order);
} catch (Exception e) {
// fallback 처리
}
}
@Transactional
public Order createOrder(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("상품 없음"));
// 재고 차감
product.decreaseStock(quantity);
// 주문 데이터 생성
Order order = new Order(product, quantity);
return orderRepository.save(order);
}
private PgResult callPg(Order order) {
// 결제...
}
}
public class Product {
// ...
public void decreaseStock(int quantity) {
if (this.stock < quantity) {
throw new RuntimeException("재고가 부족합니다.");
}
this.stock -= quantity;
}
}
createOrder()에 @Transactional을 붙였으니 트랜잭션 안에서 decreaseStock()이 호출되고, dirty checking으로 인해 재고가 차감될 것 같은데... 재고가 안 빠진다.
주문은 생성됐는데 재고는 그대로인 상태가 되어버린 것이다.
분명 @Transactional을 붙였는데 왜 정상 동작하지 않을까?
Spring AOP는 프록시 클래스 기반으로 동작하기 때문이다.
원인 - 프록시를 거치지 않았다.
order()의 의존성을 도식화하면 다음과 같다.

하지만 우리는 @Transactional을 통해 Spring AOP를 적용했으니 프록시 객체가 생성되어 사용된다.

그림을 보면 @Transactional이 프록시 객체에 적용되어 있다. (동작을 그림으로 녹여낸것 뿐이다. 실제 구현 코드와는 다르다...)
이를 통해 createOrder()를 호출했을때 트랜잭션이 적용되지 않던 이유는, 원본 메서드를 직접 호출해서라는걸 알 수 있다.
OrderService를 개선해보자
단순하게 해결했다. orderService.order()를 외부 클래스로 옮겼다.
@RequiredArgsConstructor
public class OrderFacade {
private final OrderService orderService;
public void order(Long productId, int quantity) {
// 1. 주문 정보 저장
Order order = orderService.createOrder(productId, quantity);
// 2. 결제 진행
try {
return orderService.callPg(order);
} catch (Exception e) {
// fallback 처리
}
}
}
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
@Transactional
public Order createOrder(Long productId, int quantity) {
// ...
}
// public으로 외부 노출함
public PgResult callPg(Order order) {
// ...
}
}
심화 - Spring AOP에서의 Proxy 생성 방식
Spring AOP는 프록시를 생성할 때 JDK Dynamic Proxy, CGLib 두 가지 방식중 하나를 사용한다.
두 방식중 어떤걸 사용할지는 인터페이스 유무를 통해 정해진다.

JDK Dynamic Proxy
JDK Dynamic Proxy는 인터페이스를 통해 구현되기 때문에 원본 객체의 형제 프록시 객체를 생성한다.

그렇기에 Interface를 구현체를 인터페이스가 아닌 구현체 타입을 직접 가져다 쓰면 bean 주입에 실패한다.
즉, 아래와 같이 하면 실패한다.
@RequiredArgsConstructor
public class OrderController {
// IOrderService가 아닌 OrderService로 주입
private final OrderService orderService;
// ...
}
구체적인 에러는 다음과 같다.
Description:
The bean 'orderService' could not be injected because it is a JDK dynamic proxy
The bean is of type 'jdk.proxy2.$Proxy59' and implements:
com.example.demo.order.IOrderService
org.springframework.aop.SpringProxy
org.springframework.aop.framework.Advised
org.springframework.core.DecoratingProxy
Expected a bean of type 'com.example.demo.order.OrderService' which implements:
com.example.demo.order.IOrderService
Action:
Consider injecting the bean as one of its interfaces or forcing the use of CGLib-based proxies by setting proxyTargetClass=true on @EnableAsync and/or @EnableCaching.
대강 해석하면 다음과 같다.
"orderService bean은 JDK Dynamic Proxy로 생성되어 원본 클래스 타입으로 주입할 수 없다. bean을 주입하고 싶으면 CGLib 기반 프록시를 고려해라"
도식화한 그림을 다시 한번 보자.

OrderService와 OrderServiceProxy는 다른 클래스다. 인터페이스만 같다.
Nest.js라면 Duck Typing 으로 해결해볼 수도 있겠으나 자바는 그렇지 않다. 전혀 다른 객체로 인식하여 타입 호환이 불가능하다.
즉, 인터페이스를 구현할거라면 구현체를 타입으로 쓰지 말아야 의도대로 동작한다.
CGLib
CGLib는 상황이 좀 다르다. 상속을 이용하여 구현되기 때문에 원본 클래스가 상위 타입이 된다.

상속을 이용하기 때문에 클래스나 메서드에 final을 붙이면 동작하지 않는다... 주의하자.
주입된 bean이 어떤 프록시로 구현되었는지 어떻게 확인할까?
주입된 bean이 어떤 방식으로 생성되었는지 확인하려면 class의 name을 출력해보면 알 수 있다.
orderService.getClass().getName()
인터페이스를 구현하면 이렇게 출력되었다. JDK Proxy인것으로 보아 JDK Dynamic Proxy로 유추된다.
jdk.proxy2.$Proxy59
인터페이스를 사용하지 않으면 이렇게 나오는것을 확인할 수 있었다.
SpringCGLIB라고 출력되므로 CGLib 방식으로 구현된 것을 알 수 있다.
com.example.demo.order.OrderService$$SpringCGLIB$$0
Reference
https://gmoon92.github.io/spring/aop/2019/04/20/jdk-dynamic-proxy-and-cglib.html
https://5g-0.tistory.com/51#3)%20'JDK%20Dynamic%20Proxy'%20vs%20'CGLIB%20Proxy'-1-7
