프록시 패러독스: Spring @Transactional이 사라지는 이유
Source: Dev.to
문제
중요한 메서드에 @Transactional을 붙이고 테스트를 실행했을 때는 모든 것이 정상적으로 보입니다.
하지만 트랜잭션 로그를 확인하면 트랜잭션이 전혀 없습니다: 커넥션이 등록되지 않고, 타임아웃도 없으며, 오류 발생 시 롤백도 일어나지 않습니다. 메서드는 실행됐지만 트랜잭션 밖에서 실행된 것입니다.
이것이 프록시 패러독스 – Spring의 AOP 프록시가 같은 빈 내부에서 메서드가 서로 호출될 때 트랜잭션을 숨기는 현상입니다.
Spring AOP 작동 방식
Spring은 로깅, 보안, 트랜잭션 등과 같은 횡단 관심사를 적용하기 위해 **Aspect‑Oriented Programming (AOP)**을 사용합니다.
컨테이너가 시작될 때, Spring은 빈들을 스캔하여 @Transactional, @Async, @Cacheable 등과 같은 어노테이션을 찾습니다.
어노테이션이 발견되면 Spring은 프록시(JDK 동적 프록시 또는 CGLIB 서브클래스)로 해당 빈을 감쌉니다.
프록시는 외부 메서드 호출을 가로채어 인터셉터 체인(예: TransactionInterceptor)을 실행한 뒤, 원본 빈에 위임합니다.
자기 호출(Self‑Invocation) 문제
메서드가 같은 빈 내부의 다른 메서드를 호출하면 인터셉터 체인이 우회됩니다.
@Service
class WalletService {
// Entry point
public void pay(BigDecimal amount) {
// Internal call – bypasses proxy
withdrawMoney(amount);
}
@Transactional
public void withdrawMoney(BigDecimal amount) {
// ... database writes ...
}
}
WalletService외부에서withdrawMoney를 호출 → 프록시를 통과 → 트랜잭션 시작.pay메서드 안에서withdrawMoney를 호출 →this직접 호출 → 프록시가 건너뛰어 → 트랜잭션 없음.
AspectJ가 다르게 동작하는 이유
AspectJ 로드‑타임 위빙을 활성화하면:
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
AspectJ는 트랜잭션 로직을 바이트코드에 직접 삽입하므로 자기 호출도 정상적으로 동작합니다.
- 장점: 자기 호출이 바로 작동한다.
- 단점: Java 에이전트가 필요하고, 빌드 복잡도가 증가하며, 시작 시간이 늘어나고, 일반적인 웹 애플리케이션에는 과도한 경우가 많다.
해결 방안
1. 트랜잭션 메서드를 다른 빈으로 이동
@Service
class PaymentService {
private final WalletService walletService; // injected
public void pay(BigDecimal amount) {
walletService.withdrawMoney(amount); // external call
}
}
깨끗하고 SOLID‑친화적이며 테스트하기 쉽다.
2. 프록시를 자기 자신에게 주입 (필드 인젝션)
@Service
class WalletService {
@Lazy
@Autowired
private WalletService self; // proxy injected
public void pay(BigDecimal amount) {
self.withdrawMoney(amount); // goes through proxy
}
}
동작하지만 필드 인젝션에 의존하고, 생성자 인젝션을 사용할 경우 순환 참조 문제가 발생할 수 있다.
3. TransactionTemplate을 명시적으로 사용
@Autowired
private TransactionTemplate transactionTemplate;
public void pay(BigDecimal amount) {
transactionTemplate.execute(status -> {
withdrawMoney(amount);
return null;
});
}
조금의 보일러플레이트가 추가되지만 트랜잭션 경계가 명확해진다.
4. AOP 프록시를 직접 호출
import org.springframework.aop.framework.AopContext;
public void pay(BigDecimal amount) {
((WalletService) AopContext.currentProxy()).withdrawMoney(amount);
}
추상화가 새는 방법 – 비즈니스 코드가 Spring AOP 내부에 의존하게 된다. 필요할 때만 사용한다.
5. AspectJ 위빙으로 전환
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
많은 빈에 대해 효과적이지만 복잡도가 크게 증가한다; 몇몇 경우에만 고려한다.
요약
- 외부 호출 → 프록시 → 어스펙트 실행 → 비즈니스 로직.
- 내부 호출 → 순수
this객체 → 비즈니스 로직 (어스펙트 미실행).
패러독스를 피하려면 코드를 재구성하거나 필요에 따라 프록시를 주입하고, 혹은 TransactionTemplate을 사용한다. 자기 호출 메서드에 @Transactional에만 의존하지 말아야 한다.