@Transactional은 프록시로 동작한다
Spring의 @Transactional은 AOP(관점 지향 프로그래밍)를 기반으로 프록시(Proxy) 객체를 통해 동작한다. 해당 어노테이션이 붙은 메서드를 호출하면 원본 객체가 아닌 프록시 객체가 개입하여 트랜잭션을 시작한다.
비즈니스 로직이 정상적으로 완료되면 커밋을 수행하고, 로직 수행 중 RuntimeException 같은 언체크 예외가 발생하면 즉시 롤백 처리를 하여 데이터베이스의 정합성을 보장한다.
self-invocation 문제 — 내부 호출에서 트랜잭션이 깨진다
AOP 프록시는 외부에서 메서드를 호출할 때만 작동한다. 클래스 내부에서 다른 내부 메서드를 호출할 때는 프록시를 거치지 않고 실제 객체의 본인 메서드를 직접 호출하기 때문에, 그 메서드에 붙은 @Transactional이 무시된다.
여기서 주의해야 할 점은 스프링은 프록시를 메서드 단위가 아니라 클래스 단위로 통째로 감싸서 만든다. 그리고 프록시는 자기가 받은 호출(진입점)에 대해서만 부가 작업 여부를 판단한다.
호출이 어떻게 흘러가는가
클라이언트(Controller 등)가 프록시의 createOrder()를 호출한 경우를 따라가 보자.
- 클라이언트의 호출 — 프록시 객체의
createOrder()가 호출된다. - 포인트컷 검사 — 프록시는
createOrder()에@Transactional이나 다른 AOP 설정이 있는지 확인한다. - 부가 작업 생략 —
createOrder()에는 아무 어노테이션이 없으므로 프록시는 어떤 부가 작업도 하지 않는다. 이때 그 내부에서 호출된pay()에 붙은@Transactional도 함께 무시된다.
핵심은, 자바 컴파일러가 내부 호출 pay()를 this.pay()로 인식한다는 점이다. 여기서의 this는 프록시가 아니라 실제 본체 객체이기 때문에 프록시를 우회해버린다.
@Service
public class OrderService {
// 1. 클라이언트(Controller 등)가 이 메서드를 호출 (외부 호출 → 프록시 거침)
public void createOrder() {
System.out.println("주문 생성 로직 실행");
// 2. 문제의 내부 호출 발생!
// 자바 컴파일러는 이것을 this.pay() 로 인식한다.
// 여기서의 this는 프록시가 아니라 'OrderService의 실제 본체'다.
pay();
}
// 3. 트랜잭션을 기대하고 어노테이션을 붙였지만...
@Transactional
public void pay() {
// 비서(프록시)를 거치지 않고 사장님(본체)이 직접 실행했기 때문에
// 여기에는 트랜잭션이 전혀 적용되지 않는다! (Rollback 안 됨)
System.out.println("결제 로직 실행 (트랜잭션 X)");
}
}
@Transactional 메서드는 본체가 직접 실행해버려 트랜잭션이 걸리지 않는다.해결 방안 — 클래스(빈) 분리
가장 권장되는 해결책은 트랜잭션이 필요한 메서드를 별도의 빈(Bean)으로 분리하는 것이다. 다른 빈의 메서드를 호출하면 그 호출은 "외부 호출"이 되어 해당 빈의 프록시를 정상적으로 거치게 된다.
@Service
@RequiredArgsConstructor
public class OrderService {
// 결제 책임을 가진 다른 빈을 주입받음
private final PaymentService paymentService;
public void createOrder() {
System.out.println("주문 생성 로직 실행");
// this.pay()가 아니라 '다른 객체'의 메서드를 호출 (외부 호출)
// paymentService의 프록시를 거치므로 AOP 정상 동작!
paymentService.pay();
}
}
// 결제만 전담하는 분리된 클래스
@Service
public class PaymentService {
@Transactional // 이제 트랜잭션이 완벽하게 적용된다.
public void pay() {
System.out.println("결제 DB 저장 로직 수행");
}
}
클래스 분리는 단순히 문제를 피하는 우회책이 아니라, 책임을 나누는 설계 측면에서도 바람직하다. "주문 생성"과 "결제"라는 서로 다른 관심사를 각자의 빈으로 나누면 코드도 더 명확해진다.
그 외의 방법
상황에 따라 클래스 분리 외의 선택지도 있다. 다만 각각 트레이드오프가 있어, 일반적으로는 클래스 분리가 가장 깔끔하다.
| 방법 | 설명 / 주의점 |
|---|---|
| 클래스(빈) 분리 | 가장 권장. 책임 분리 관점에서도 자연스럽다. |
| 자기 자신 주입 | 자신의 프록시를 주입받아 self.pay()로 호출. 동작은 하지만 구조가 부자연스러워 권장도는 낮다. |
| AspectJ 적용 | 프록시가 아닌 바이트코드 위빙 방식이라 내부 호출도 처리된다. 다만 설정이 복잡해 일반 웹 애플리케이션에선 과한 선택일 수 있다. |
@Transactional의 기본 롤백 대상은 언체크 예외(RuntimeException)와 Error다. IOException 같은 체크 예외는 기본적으로 롤백되지 않는다. 체크 예외에도 롤백이 필요하면 @Transactional(rollbackFor = Exception.class)처럼 명시해야 한다.'JAVA' 카테고리의 다른 글
| 동시성 문제 (0) | 2026.06.26 |
|---|---|
| ThreadLocal과 OOM (Out of Memory) 발생 (0) | 2026.06.26 |
| Java 날짜 타입의 타임존 처리 (0) | 2026.05.31 |
| [ Java ] - QueryDSL이란 무엇인가? (+기본 세팅) (0) | 2024.05.19 |
| [ Java ] - 입출력 하기, 스트림 종류 및 예시 코드 (0) | 2023.10.30 |