프록시의 한계 - self-invocation, final, JDK와 CGLIB의 선택
@Transactional을 붙였는데 트랜잭션이 안 걸린다. @Cacheable을 붙였는데 캐시가 안 된다. @Async를 붙였는데 같은 스레드에서 돈다. 증상은 다르지만 원인은 하나다. 그 호출이 프록시를 지나지 않았다. 스프링 AOP가 프록시 기반이라는 사실 하나로 이 셋이 전부 설명된다.
프록시가 하는 일
스프링은 @Transactional이 붙은 빈을 감싸는 대리 객체를 만들어 컨테이너에 등록한다. 주입받는 쪽은 원본이 아니라 이 대리 객체를 받는다.
1
호출자 → [프록시] → 트랜잭션 시작 → [원본 객체의 메서드] → 커밋/롤백
부가 기능은 프록시에 있지 원본 객체에 있지 않다. 그래서 프록시를 거치지 않는 호출에는 부가 기능이 없다.
self-invocation: 가장 흔한 사고
1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class OrderService {
public void processAll(List<Order> orders) {
for (Order o : orders) {
processOne(o); // this.processOne(...) - 프록시를 지나지 않는다
}
}
@Transactional
public void processOne(Order o) { ... } // 트랜잭션이 걸리지 않는다
}
processOne 앞에 this가 생략돼 있고, this는 프록시가 아니라 원본 객체다. 컴파일도 되고 실행도 되며 예외도 나지 않는다. 그냥 조용히 트랜잭션 없이 실행된다.
해결은 셋 중 하나다.
| 방법 | 설명 |
|---|---|
| 클래스를 분리 | processOne을 다른 빈으로 옮긴다. 대개 이것이 맞다 |
| 자기 자신을 주입 | @Lazy로 자기 프록시를 주입받아 호출. 순환 참조를 감수 |
AopContext.currentProxy() | @EnableAspectJAutoProxy(exposeProxy = true) 필요. 코드가 AOP에 묶인다 |
분리가 기본이다. 나머지 둘은 “왜 프록시를 우회하고 싶은가”를 다시 묻게 만드는 신호에 가깝다.
JDK 동적 프록시와 CGLIB
두 가지 구현이 있다.
JDK 동적 프록시는 인터페이스를 구현한 새 클래스를 만든다. 인터페이스에 선언된 메서드만 프록시된다. 인터페이스가 없으면 쓸 수 없다.
CGLIB은 클래스를 상속한 서브클래스를 만든다. 인터페이스가 없어도 되지만 상속의 제약을 그대로 받는다.
final클래스는 상속할 수 없다. 프록시 생성이 실패한다.final메서드는 오버라이드할 수 없다. 그 메서드만 조용히 프록시되지 않는다. 예외가 아니라 무시다.private메서드도 같다. 오버라이드 대상이 아니다.- 생성자가 두 번 호출된다. 서브클래스 인스턴스를 만들면서 부모 생성자가 돈다. 생성자에 부수 효과를 넣으면 그것이 두 번 일어난다.
Spring Boot는 2.0부터 CGLIB을 기본으로 쓴다(proxyTargetClass=true). 인터페이스가 있어도 클래스 기반이다. 인터페이스를 만들면 JDK 프록시가 되던 시절의 조언이 지금은 맞지 않는다.
코틀린에서 특히 자주 만난다
코틀린은 클래스와 메서드가 기본적으로 final 이다. open을 붙이지 않으면 CGLIB이 프록시를 만들 수 없다. kotlin-spring 플러그인이 @Component, @Transactional 등이 붙은 곳을 자동으로 open으로 만들어 주는 이유가 이것이다. 플러그인이 빠진 프로젝트에서 “트랜잭션이 안 걸린다”가 나오면 여기부터 본다.
이 설명이 깨지는 곳
- AspectJ 위빙은 프록시가 아니다. 컴파일 타임이나 로드 타임에 바이트코드를 고치므로 self-invocation도
final도 문제가 되지 않는다. 대신 빌드와 기동에 단계가 추가된다. 스프링 AOP의 한계가 실제로 막히는 경우에 고려한다. - 프록시는
this참조를 바꾸지 않는다. 원본 객체가 자기 참조를 다른 곳에 넘기면(리스너 등록 등) 그 참조에는 부가 기능이 없다. @PostConstruct안에서는 프록시가 아직 완성되지 않았을 수 있다. 초기화 콜백에서 트랜잭션에 의존하는 코드는 피한다.- 프록시 순서는
@Order가 정한다. 트랜잭션과 캐시가 함께 붙으면 어느 쪽이 바깥인지가 동작을 바꾼다.
무엇을 재면 확인되는가
동작 여부는 성능이 아니라 사실 확인이라 관찰로 끝난다.
TransactionSynchronizationManager.isActualTransactionActive()를 메서드 안에서 찍어 본다. self-invocation이면false다.- 주입받은 빈의 클래스 이름을 찍어
$$SpringCGLIB$$가 붙는지 본다. logging.level.org.springframework.transaction=TRACE로 트랜잭션 경계 로그를 켠다.
spring-internals-lab에서 프록시 생성 과정을 소스 수준으로 따라간 적이 있다. 이 글은 그것을 “무엇이 안 되는가”의 목록으로 뒤집은 것이다.
실무와의 접점
정산 중 수정 차단에서 AOP로 경량 락을 걸었다. 그 설계가 성립한 전제가 “모든 진입이 프록시를 지난다”였다. 같은 클래스 안에서 부르는 경로가 하나라도 있었다면 그 락은 조용히 비어 있었을 것이다. AOP로 무언가를 강제할 때는 우회 경로가 없는지가 설계의 일부다. 프록시는 진입점에만 있고, 진입점이 여럿이면 그중 하나만 새도 보호가 사라진다.
정리
- 부가 기능은 프록시에 있다. 프록시를 지나지 않는 호출에는 부가 기능이 없다.
- self-invocation은 예외 없이 조용히 무시된다. 기본 해결은 클래스 분리다.
- Spring Boot의 기본은 CGLIB이다.
final클래스는 실패하고,final·private메서드는 조용히 프록시되지 않는다. - 코틀린은 기본이
final이라kotlin-spring플러그인이 필요하다. - AOP로 규칙을 강제한다면 우회 경로가 없는지가 설계의 일부다.
댓글
아직 댓글이 없습니다