트랜잭션과 프록시 — 같은 클래스의 메서드를 호출하면 트랜잭션이 안 걸리는 이유
@Transactional을 분명히 붙였는데 트랜잭션이 동작하지 않는다면, 어디를 의심해야 할까요?
Spring에서 가장 흔하게 발생하는 트랜잭션 관련 버그 중 하나가 바로 self-invocation(자기 호출) 문제입니다. 이 문제를 이해하려면 Spring AOP의 프록시 동작 방식을 알아야 합니다.
문제 상황
@Service
public class OrderService {
public void processOrder(OrderRequest request) {
// 검증 로직
validateOrder(request);
// 트랜잭션이 필요한 메서드를 내부 호출
saveOrder(request); // @Transactional이 동작하지 않음!
}
@Transactional
public void saveOrder(OrderRequest request) {
orderRepository.save(new Order(request));
paymentRepository.save(new Payment(request));
// 예외 발생 시 롤백이 되어야 하는데... 안 됨!
}
}
saveOrder()에 @Transactional이 붙어있지만, processOrder()에서 내부 호출하면 트랜잭션이 적용되지 않습니다.
왜 이런 일이 발생하는가
프록시 동작 구조
외부 클라이언트
│
▼
OrderService$Proxy (프록시)
│
├── processOrder() 호출
│ └── this.saveOrder() ← 프록시가 아닌 실제 객체의 메서드 호출!
│
└── saveOrder() 직접 호출 ← 프록시를 통과하므로 트랜잭션 적용
└── 트랜잭션 시작 → 실제 메서드 실행 → 커밋/롤백
핵심은 this입니다. 같은 클래스 내에서 this.saveOrder()를 호출하면 ** 프록시 객체가 아닌 실제 객체 **를 통해 호출됩니다. 프록시를 거치지 않으므로 트랜잭션 AOP가 동작하지 않습니다.
외부 호출 vs 내부 호출
// 외부 호출 — 트랜잭션 적용됨
@Controller
public class OrderController {
@Autowired OrderService orderService; // 프록시 주입
public void create() {
orderService.saveOrder(request); // 프록시.saveOrder() → 트랜잭션 O
}
}
// 내부 호출 — 트랜잭션 미적용
@Service
public class OrderService {
public void processOrder() {
this.saveOrder(request); // this(실제객체).saveOrder() → 트랜잭션 X
}
}
해결법 1: 클래스 분리 (가장 권장)
트랜잭션이 필요한 메서드를 별도 클래스로 분리합니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderTransactionService txService;
public void processOrder(OrderRequest request) {
validateOrder(request);
txService.saveOrder(request); // 외부 호출 → 프록시 통과 → 트랜잭션 O
}
}
이어서 트랜잭션이 적용된 메서드를 정의합니다.
@Service
public class OrderTransactionService {
@Transactional
public void saveOrder(OrderRequest request) {
orderRepository.save(new Order(request));
paymentRepository.save(new Payment(request));
}
}
이 방법이 ** 가장 깔끔하고 권장 **됩니다. 단일 책임 원칙에도 부합합니다.
해결법 2: TransactionTemplate (프로그래밍 방식)
@Service
@RequiredArgsConstructor
public class OrderService {
private final TransactionTemplate transactionTemplate;
public void processOrder(OrderRequest request) {
validateOrder(request);
// 프록시 없이 직접 트랜잭션 관리
transactionTemplate.execute(status -> {
orderRepository.save(new Order(request));
paymentRepository.save(new Payment(request));
return null;
});
}
}
TransactionTemplate은 프록시에 의존하지 않으므로 self-invocation 문제가 발생하지 않습니다.
TransactionTemplate 설정
// 읽기 전용 TransactionTemplate
TransactionTemplate readOnlyTemplate = new TransactionTemplate(transactionManager);
readOnlyTemplate.setReadOnly(true);
// 새 트랜잭션 TransactionTemplate
TransactionTemplate newTxTemplate = new TransactionTemplate(transactionManager);
newTxTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
해결법 3: 자기 자신 주입 (비권장)
@Service
public class OrderService {
@Autowired
private OrderService self; // 프록시가 주입됨
public void processOrder(OrderRequest request) {
validateOrder(request);
self.saveOrder(request); // 프록시를 통해 호출 → 트랜잭션 O
}
@Transactional
public void saveOrder(OrderRequest request) {
orderRepository.save(new Order(request));
}
}
동작은 하지만 ** 순환 참조 **처럼 보이고 코드가 부자연스럽습니다. 실무에서는 지양하는 것이 좋습니다.
해결법 4: AspectJ 모드
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
@Configuration
public class TransactionConfig { }
AspectJ는 프록시가 아닌 ** 바이트코드 위빙 **으로 동작하므로 self-invocation 문제가 발생하지 않습니다. 하지만 설정이 복잡하고 빌드 과정에 AspectJ 컴파일러가 필요합니다.
@Transactional이 동작하지 않는 다른 경우들
1. private 메서드
@Transactional
private void saveOrder() { } // 프록시가 오버라이드할 수 없음 → 동작 안 함
Spring AOP 프록시는 public 메서드에서만 동작합니다.
2. final 클래스/메서드
@Service
public final class OrderService { // CGLIB 상속 불가 → 프록시 생성 실패
@Transactional
public void save() { }
}
CGLIB 프록시는 클래스를 상속하므로 final 클래스나 final 메서드에는 프록시를 생성할 수 없습니다.
3. Spring 빈이 아닌 객체
OrderService service = new OrderService(); // Spring 빈이 아님
service.saveOrder(request); // 프록시가 아니므로 트랜잭션 미적용
@Transactional은 Spring이 관리하는 빈에서만 동작합니다.
프록시 동작 확인 방법
@Service
@RequiredArgsConstructor
public class DebugService {
private final OrderService orderService;
public void checkProxy() {
System.out.println(orderService.getClass());
// com.example.OrderService$$SpringCGLIB$$0 ← CGLIB 프록시
System.out.println(AopUtils.isAopProxy(orderService)); // true
}
}
주의할 점
1. 테스트에서는 문제가 드러나지 않을 수 있다
@SpringBootTest에서 서비스를 직접 주입받아 호출하면 프록시를 통해 호출되므로 트랜잭션이 정상 동작합니다. 하지만 실제 운영 코드에서 같은 클래스 내부의 메서드를 호출하면 self-invocation이 발생합니다. 단위 테스트만으로는 이 문제를 발견하기 어려우므로, 코드 리뷰 시 @Transactional 메서드의 호출 경로를 반드시 확인해야 합니다.
2. 자기 자신 주입(self-injection) 방식은 순환 참조 탐지에 걸릴 수 있다
Spring Boot 2.6부터 순환 참조가 기본적으로 금지되었습니다. @Autowired private OrderService self; 같은 self-injection은 spring.main.allow-circular-references=true 설정 없이는 애플리케이션 시작 자체가 실패합니다. 근본적으로 클래스를 분리하는 것이 더 안전합니다.
3. @Transactional이 붙은 protected, package-private 메서드는 Spring Framework 버전에 따라 동작이 다르다
Spring Framework 5.x까지는 public 메서드에서만 @Transactional이 동작했지만, 6.0부터는 protected 메서드에서도 동작합니다. 프로젝트의 Spring 버전을 확인하지 않고 protected 메서드에 @Transactional을 붙이면 "분명 어노테이션을 붙였는데 트랜잭션이 안 걸린다"는 문제에 빠질 수 있습니다.
정리
- self-invocation 문제 는 같은 클래스 내에서
this로 호출하면 프록시를 우회하기 때문에 발생합니다. - 클래스 분리 가 가장 깔끔한 해결법이며, 단일 책임 원칙에도 부합합니다.
- TransactionTemplate 은 프록시 없이 프로그래밍 방식으로 트랜잭션을 관리합니다.
@Transactional은 **public 메서드 **, **Spring 빈 **, final이 아닌 클래스 에서만 정상 동작합니다.- AspectJ 모드는 self-invocation을 해결하지만 설정이 복잡하여 실무에서는 잘 사용하지 않습니다.