Spring AOP 프록시와 `self-invocation` 함정 — `@Transactional`이 왜 안 먹나요?
Spring AOP 프록시, 왜 알아야 하나요?
@Transactional을 분명히 달았는데 트랜잭션이 안 걸리는 상황을 한 번쯤 만나 봤을 겁니다.
- 같은 클래스의
public메서드를 불렀는데@Transactional이 무시됐습니다 @Async를 붙인 메서드를 불렀는데 비동기로 실행되지 않습니다@Cacheable을 붙였는데 캐시가 적용되지 않습니다private메서드에@Transactional을 붙였는데 경고도 없이 무시됐습니다
이 네 가지 증상의 원인은 전부 하나입니다. Spring AOP가 프록시 기반이기 때문입니다. 프록시는 호출이 "밖에서 들어올 때만" 가로챌 수 있는 구조라서, 같은 객체 내부에서 메서드를 직접 호출하면 프록시를 거치지 않고 AOP가 전부 우회됩니다. 이걸 self-invocation 문제라고 부르고, @Transactional을 비롯한 모든 Spring AOP 기반 애너테이션이 공통으로 겪는 함정입니다.
기준: 이 글은 Spring Framework 6 / Spring Boot 3.x 기준으로 작성합니다. AOP 프록시 동작과 self-invocation 한계는 Spring Framework Reference — Understanding AOP Proxies와 Spring AOP를 참조합니다. 트랜잭션 전파 속성은 앞 글에서 다뤘고, 이 글은 그 전파 속성이 프록시를 거치지 않으면 왜 전부 무력화되는지에 초점을 맞춥니다. 코드 예시는 Kotlin + Spring Boot입니다.
먼저 가장 짧은 답부터 보면
- Spring AOP는 프록시 객체를 만들어 빈을 감쌉니다. 외부 호출은 프록시를 거치지만, 같은 객체 내부 호출은 프록시를 거치지 않습니다
- 프록시가 없으면
@Transactional,@Async,@Cacheable,@Retryable,@Validated등 모든 AOP 기반 애너테이션이 동작하지 않습니다 - 가장 단순한 해결책은 메서드를 다른 빈으로 분리하는 것입니다. 한 빈 안에서 풀고 싶다면
self주입,ApplicationContext, AspectJ 세 가지 선택지가 있지만 대부분 분리가 먼저입니다 private/final메서드,static메서드에는 프록시가 적용되지 않습니다
Phase 1. Spring AOP는 실제로 무엇을 만들까요?
핵심: 빈을 직접 쓰지 않고, 빈을 감싼 "대리 객체"를 씁니다
Spring 컨테이너가 @Transactional이 붙은 빈을 발견하면, 그 빈을 감싸는 프록시 객체를 대신 생성해 컨테이너에 등록합니다. 주입 시점에 다른 빈이 받는 것은 원본이 아니라 프록시입니다.
컨테이너 등록
├─ OrderService (원본 빈) — 내부 구현만 보유
└─ OrderService$$SpringCGLIB$$0 (프록시) ← 다른 빈에 주입되는 것
프록시.placeOrder() 호출
├─ 트랜잭션 시작 (TransactionInterceptor)
├─ 원본.placeOrder() 호출
└─ 예외 여부 확인 후 커밋/롤백
이 프록시가 하는 일은 두 가지입니다.
- 메서드 호출 전에 AOP 어드바이스(advice) 를 먼저 실행
- 그다음에 원본 메서드로 위임
@Transactional의 "트랜잭션 시작 → 원본 실행 → 커밋/롤백" 흐름은 이 어드바이스가 하는 일입니다. 프록시를 안 거치면 이 어드바이스가 아예 실행되지 않습니다.
컨테이너 관점에서 벌어지는 일
@Service
class OrderService {
@Transactional
fun placeOrder() { ... }
}
@Service
class CheckoutService(
private val orderService: OrderService // ← 실제로는 프록시
) {
fun checkout() {
orderService.placeOrder() // 프록시 호출 → 트랜잭션 동작
}
}
CheckoutService의 필드 타입은 OrderService이지만, 주입되는 인스턴스는 OrderService$$SpringCGLIB$$0 같은 프록시 서브클래스입니다. 그래서 orderService.placeOrder()는 프록시를 거쳐 트랜잭션 어드바이스를 발동시킵니다.
Phase 2. 프록시 두 종류 — JDK Dynamic Proxy vs CGLIB
JDK Dynamic Proxy
- 인터페이스 기반입니다
- 빈이 인터페이스를 구현하고 있으면 기본적으로
java.lang.reflect.Proxy로 프록시를 만듭니다 - 프록시는 인터페이스 타입으로만 캐스팅 가능합니다. 구체 클래스로 캐스팅하면
ClassCastException이 납니다
CGLIB (Spring이 repackage한 fork)
- 서브클래스 기반입니다. 원본 클래스를 상속한 서브클래스를 런타임에 생성합니다
- 인터페이스가 없어도 프록시를 만들 수 있습니다
- 서브클래싱이라
final클래스와final메서드는 프록시를 만들 수 없습니다 - Spring Framework 6은 CGLIB을 직접 fork하여
spring-core안에 repackage해서 씁니다 (org.springframework.cglib.*)
Spring Boot의 기본값
Spring Boot 2.0부터 기본값은 CGLIB(서브클래스 방식) 입니다. @EnableAspectJAutoProxy(proxyTargetClass = true) 와 같은 의미가 기본값으로 설정되어 있습니다. 인터페이스 없이 구현 클래스만 있어도 프록시가 만들어지기 때문에 대부분의 실무 환경에서 이 설정이 편합니다.
spring:
aop:
proxy-target-class: true # 기본값
Kotlin에서 특히 중요한 주의점
Kotlin의 클래스는 기본이 final 입니다. 이 상태로는 CGLIB이 서브클래스를 만들 수 없어 프록시 생성이 실패합니다. Spring Boot는 이를 돕기 위해 kotlin-spring 플러그인을 제공합니다.
// build.gradle.kts
plugins {
kotlin("plugin.spring") version "..."
}
이 플러그인은 @Component, @Service, @Repository, @Transactional 등이 붙은 클래스를 자동으로 open 으로 바꿔 줍니다. 플러그인을 빠뜨리면 트랜잭션이 "조용히" 동작하지 않는 상황을 만나게 됩니다.
Phase 3. self-invocation이 왜 AOP를 건너뛸까요?
핵심: 프록시는 "외부에서 들어오는 호출"만 가로챌 수 있습니다
같은 객체 안에서 다른 메서드를 직접 호출하면, 그 호출은 프록시의 메서드 테이블을 통하지 않고 JVM 레벨에서 바로 원본 객체의 메서드 포인터로 점프합니다. 프록시가 감쌌어도 이 점프는 감쌀 방법이 없습니다.
@Service
class OrderService {
fun placeOrder() {
updateStock() // ← self-invocation: 프록시 거치지 않음
}
@Transactional
fun updateStock() {
// 프록시를 거치지 않았기 때문에 @Transactional이 동작하지 않음
}
}
placeOrder() 안의 updateStock() 호출은 this.updateStock() 과 동일합니다. 이 this는 프록시가 아니라 원본 객체입니다. 그래서 @Transactional이 무시됩니다.
그림으로 본 호출 경로
외부 호출 (정상)
[외부] → [프록시] → 어드바이스 발동 → [원본.메서드]
같은 객체 내부 호출 (self-invocation)
[외부] → [프록시] → 어드바이스 발동 → [원본.placeOrder]
└→ this.updateStock ← 프록시 우회
두 번째 그림에서 updateStock에 붙은 AOP 어드바이스(@Transactional, @Async 등)는 발동되지 않습니다.
이 구조는 왜 바뀌지 않나요?
JDK 동적 프록시도 CGLIB도 원본 객체의 메서드 포인터를 바꾸지는 않습니다. 메서드 호출을 가로채는 장치는 프록시 객체 위에서만 존재합니다. 원본 객체에서 this.메서드()를 호출하는 것은 JVM이 프록시와 무관하게 처리하기 때문에, Spring이 개입할 틈이 없습니다.
이 한계를 원천적으로 없애려면 AspectJ 처럼 바이트코드를 직접 조작하는 방식이 필요합니다. 이건 Phase 4의 네 번째 해결책에서 다룹니다.
Phase 4. 해결책 네 가지
1. 메서드를 다른 빈으로 분리 (거의 항상 1순위)
가장 간단하고 안전한 방법입니다. self-invocation이 일어나는 메서드를 다른 빈으로 이동시키면, 호출이 자연스럽게 프록시를 거치게 됩니다.
@Service
class OrderService(
private val stockService: StockService
) {
fun placeOrder() {
stockService.updateStock() // 다른 빈 호출 → 프록시 거침
}
}
@Service
class StockService {
@Transactional
fun updateStock() { ... }
}
이 방법의 장점은 추가 설정이 없다는 것입니다. 단점은 "이 정도로 빈을 나눠야 하나" 하는 저항감이 생길 수 있다는 점입니다. 다만 트랜잭션 경계가 다른 메서드를 같은 빈에 두는 것 자체가 책임 분리가 애매해진 신호일 때가 많습니다. 분리가 구조적으로 나쁜 선택이 아닐 가능성이 큽니다.
2. self 주입 — 자기 자신을 프록시로 주입
자기 자신을 주입받아 프록시를 통해 호출합니다.
@Service
class OrderService(
private val self: OrderService
) {
fun placeOrder() {
self.updateStock() // self는 프록시 → 어드바이스 발동
}
@Transactional
fun updateStock() { ... }
}
Spring 4.3부터는 이 순환 참조를 컨테이너가 자연스럽게 처리합니다. 다만 읽는 사람을 혼란스럽게 만드는 패턴입니다. "이 서비스가 자기 자신을 주입받는 이유가 뭐지?" 하는 질문이 반복되면, 구조를 나누는 편이 낫다는 신호입니다.
3. ApplicationContext로 프록시 빈 꺼내기
@Service
class OrderService(
private val context: ApplicationContext
) {
fun placeOrder() {
val proxy = context.getBean(OrderService::class.java)
proxy.updateStock()
}
@Transactional
fun updateStock() { ... }
}
테스트 용도라면 몰라도, 애플리케이션 코드에 넣기에는 컨테이너 API가 도메인 레이어에 섞여 좋지 않습니다. 실무에서 이걸 쓰는 일은 거의 없습니다.
4. AspectJ 위빙 — 프록시 없이 바이트코드에 직접 심기
AspectJ는 컴파일 타임 또는 클래스 로딩 타임에 바이트코드를 직접 수정해 어드바이스를 심습니다. 그래서 this.method() 같은 내부 호출에도 어드바이스가 동작합니다.
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
@EnableLoadTimeWeaving
@Configuration
class AppConfig
위빙 방식의 장점은 self-invocation을 비롯한 모든 제약이 사라진다는 것입니다. 단점은 설정이 복잡하고, 빌드 시스템(maven/gradle)과 JVM 옵션에 변경이 필요하다는 것입니다. 대부분의 프로젝트는 1번(빈 분리)으로 충분하기 때문에, AspectJ까지 도입하는 일은 드뭅니다.
Phase 5. 같은 함정을 공유하는 다른 애너테이션
self-invocation 문제는 @Transactional만의 문제가 아닙니다. Spring AOP 기반의 모든 애너테이션이 같은 구조를 공유합니다.
| 애너테이션 | 역할 | self-invocation 시 증상 |
|---|---|---|
@Transactional |
트랜잭션 경계 | 트랜잭션이 아예 시작되지 않음 |
@Async |
비동기 실행 | 호출 스레드에서 동기 실행 |
@Cacheable / @CacheEvict |
캐시 | 캐시가 적용되지 않음 |
@Retryable |
재시도 | 재시도 없이 예외가 그대로 전파 |
@Validated |
메서드 인자 검증 | 검증이 실행되지 않음 |
@PreAuthorize / @Secured |
메서드 수준 보안 | 권한 체크가 걸리지 않음 |
애너테이션이 달려 있는데 아무 동작도 하지 않는 것처럼 보일 때는 가장 먼저 self-invocation을 의심합니다. 로그에 에러도 경고도 찍히지 않고 "조용히" 무력화되는 것이 이 문제의 특징입니다.
Phase 6. 프록시가 아예 적용되지 않는 경우
private 메서드
JDK 동적 프록시도 CGLIB도 private 메서드를 가로채지 못합니다. 서브클래스에서 접근할 수 없거나(CGLIB), 인터페이스에 존재하지 않기 때문(JDK)입니다. private 메서드에 @Transactional을 붙이면 경고 없이 무시됩니다.
final 메서드 / final 클래스
CGLIB은 서브클래싱으로 동작하기 때문에 final 메서드와 final 클래스는 오버라이드할 수 없습니다. Spring Boot는 프록시 생성 실패 시 런타임 에러를 냅니다.
Cannot subclass final class ...
Kotlin에서는 앞서 언급한 kotlin-spring 플러그인이 해결해 줍니다. Java에서는 클래스와 메서드에 직접 final을 빼야 합니다.
static 메서드
Spring AOP는 인스턴스 메서드만 가로챕니다. static 메서드는 애너테이션이 붙어 있어도 프록시가 동작하지 않습니다. static 컨텍스트에서 트랜잭션/캐시가 필요하면 인스턴스 메서드로 옮기거나, 필요하면 AspectJ 위빙을 검토합니다.
프록시 내부에서 this로 호출
이 글의 핵심 주제인 self-invocation입니다. 반복하면 — 프록시가 감싸는 것은 외부에서 들어오는 호출뿐입니다. 원본 객체 안에서 this로 호출하면 전부 우회합니다.
정리
Spring AOP 기반의 애너테이션들은 프록시가 호출을 가로채는 구조 위에서 동작합니다. 이 구조는 외부 호출에는 자연스럽게 들어맞지만, 같은 객체 안에서의 내부 호출에는 개입할 수 없습니다. 결과적으로 @Transactional, @Async, @Cacheable, @Retryable 같은 애너테이션이 self-invocation에서는 전부 조용히 무력화됩니다.
실무에서 가장 먼저 꺼낼 답은 메서드를 다른 빈으로 분리하는 것입니다. self 주입, ApplicationContext, AspectJ 위빙도 가능하지만 대부분 빈 분리보다 복잡도가 높습니다. 그리고 Kotlin 프로젝트라면 kotlin-spring 플러그인을 빠뜨리지 않도록 빌드 설정부터 확인하는 것이 안전합니다.
"분명히 애너테이션을 달았는데 아무 일도 일어나지 않는다"라는 증상을 만났을 때, 점검 순서는 거의 정해져 있습니다.
- 프록시가 생성되어 외부에서 호출되고 있는가 (self-invocation 여부)
- 메서드가
public인가, 클래스/메서드가final이 아닌가 - Kotlin이면
kotlin-spring플러그인이 켜져 있는가 - 주입되는 필드 타입이 실제 프록시를 받고 있는가 (디버거로 확인 가능)
이 네 가지만 순서대로 봐도 대부분의 "조용한 무력화"를 빠르게 찾을 수 있습니다. 다음 글은 시리즈를 JPA 쪽으로 다시 돌려서, 영속성 컨텍스트의 flush와 OSIV가 실제로 어디까지 열려 있는지를 상세히 다룹니다.
Spring `@Transactional` 완전 정복 — 전파 속성과 롤백 규칙은 어떻게 동작하나요?
다음 글JPA `flush`와 OSIV 완전 정복 — 영속성 컨텍스트의 경계는 어디까지인가요?
다음으로 읽어볼 글
JPA `merge` vs `persist` 완전 정복 — `detached` 엔티티를 어떻게 다뤄야 하나요?
persist와 merge가 실제로 하는 일, Spring Data JPA의 save가 왜 둘을 섞어 쓰는지, detached 엔티티를 save로 저장할 때 생기는 구조적 위험, 그리고 find + modify 패턴이 왜 권장되는지를 정리합니다.
JPA 배치 쓰기 완전 정복 — `hibernate.jdbc.batch_size`와 `IDENTITY` 함정
JPA에서 대량 INSERT/UPDATE가 느린 진짜 원인은 네트워크 왕복입니다. JDBC 배치, hibernate.jdbc.batch_size, order_inserts, MySQL의 rewriteBatchedStatements, 그리고 IDENTITY 전략이 배치를 막는 이유와 해결책을 정리합니다.
JPA `flush`와 OSIV 완전 정복 — 영속성 컨텍스트의 경계는 어디까지인가요?
flush가 언제 일어나는지, FlushMode별 동작 차이, OSIV가 커넥션을 언제까지 붙들고 있는지, 그리고 실무에서 open-in-view를 끄면 벌어지는 일들을 Hibernate/Spring Boot 레퍼런스 기준으로 정리합니다.