1. 목적
이 문서는 스프링 @Transactional이 어떤 방식으로 동작하는지, 그리고 JDK 동적 프록시와 클래스 프록시(CGLIB)가 어떤 차이를 가지며 어떤 경우 트랜잭션이 적용되지 않는지 정리한다.
2. @Transactional 동작 원리(요약)
스프링 트랜잭션은 보통 “프록시 기반 AOP”로 구현된다.
- 외부에서 메서드 호출이 들어오면 프록시가 호출을 가로챈다(intercept)
- 트랜잭션 시작
- 실제 대상 메서드 호출
- 정상 종료 시 커밋, 예외 시 롤백
- 트랜잭션 종료
핵심
- 트랜잭션이 적용되려면 “프록시를 통해 호출되어야 한다”
3. 프록시 종류
3.1 JDK 동적 프록시(JDK Dynamic Proxy)
- 인터페이스 기반 프록시
- 런타임에 “인터페이스를 구현한 프록시 객체”를 생성한다(
java.lang.reflect.Proxy)
- 전제: 대상 빈이 하나 이상의 인터페이스를 구현해야 한다
특징
- 프록시는 “구현 클래스 타입”이 아니라 “인터페이스 타입”으로 노출된다
- 인터페이스에 선언된 메서드 호출을 중심으로 가로챈다
3.2 클래스 프록시(Class-based Proxy, CGLIB 계열)
- 상속 기반 프록시
- 대상 클래스를 상속한 서브클래스를 생성하고 메서드를 오버라이드해서 가로챈다
특징
- 인터페이스가 없어도 적용 가능
- 상속/오버라이드 제약을 그대로 받는다
중요 제약
final class는 상속 불가
final method는 오버라이드 불가
private method는 오버라이드 불가
static method는 프록시 가로채기 대상이 아님
4. 트랜잭션이 “안 되는” 대표 케이스
4.1 self-invocation(자기 호출)
가장 흔한 원인이다.
- 프록시는 “외부에서 프록시를 통해 들어오는 호출”만 가로챈다
- 같은 클래스 내부에서 다른 메서드를 호출하면
this.inner() 호출이 되어 프록시를 거치지 않는다
- 내부 메서드에 선언한
@Transactional 옵션(전파, readOnly 등)이 적용되지 않을 수 있다
특히 문제가 되는 상황
- 내부 메서드에
REQUIRES_NEW를 기대했으나 새 트랜잭션이 열리지 않음
- 내부 메서드에
readOnly, 격리 수준 변경을 기대했으나 적용되지 않음
참고
- 외부 진입 메서드가 이미 트랜잭션을 열어둔 상태라면 내부 호출 로직은 그 트랜잭션 안에서 실행될 수 있다.
- 문제는 “내부 메서드에 선언한 트랜잭션 속성”이 별도로 적용되지 않는 점이다.
4.2 private 메서드에 @Transactional
- 프록시가 가로채려면 인터셉트 지점이 필요하다
private는 오버라이드/가로채기가 불가능하여 적용되지 않는다
4.3 final 클래스/메서드(클래스 프록시 사용 시)
- 클래스 프록시는 상속 기반이므로
final이 있으면 가로채기 어렵다
- 트랜잭션 적용이 예상과 다르게 동작할 수 있다
4.4 스프링 컨테이너 밖에서 생성된 객체
new로 생성한 객체는 프록시가 아니므로 @Transactional이 동작하지 않는다
5. 롤백이 기대와 다른 대표 케이스(“트랜잭션은 걸렸지만 결과가 다름”)
5.1 기본 롤백 규칙
- 기본적으로
RuntimeException과 Error에서 롤백한다
- Checked Exception은 기본 롤백 대상이 아닐 수 있다
필요 시
rollbackFor = Exception.class 등으로 명시한다
5.2 예외를 잡아먹는 경우
- try/catch로 예외를 처리하고 다시 던지지 않으면 롤백 트리거가 발생하지 않는다
- 롤백을 원하면 예외를 재던지거나, 명시적으로 롤백 처리를 해야 한다
6. 문제 해결 가이드(권장 순)
6.1 self-invocation 해결(권장)
1) 트랜잭션 경계를 “빈 간 호출”로 만들기
- 내부 메서드를 별도 서비스(별도 빈)로 분리
- 외부 호출이 반드시 프록시를 통과하게 만들어 트랜잭션 속성을 정상 적용
2) TransactionTemplate 사용
- AOP 대신 코드로 트랜잭션 경계를 명시
- private 메서드, 내부 호출 구조에서도 안정적으로 적용 가능
3) 프록시를 통한 자기 자신 호출
- 기술적으로 가능하나 구조를 복잡하게 만들 수 있어 우선순위가 낮다
6.2 트랜잭션이 필요한 초기화/워밍업 작업
@PostConstruct에 트랜잭션을 넣기보다, 애플리케이션 기동 이후 훅(예: ApplicationReadyEvent) 또는 TransactionTemplate 기반 실행을 고려한다
7. 요약
@Transactional은 프록시를 통해 메서드 호출을 가로채는 방식으로 트랜잭션을 적용한다
- 프록시는 JDK 동적 프록시(인터페이스 기반) 또는 클래스 프록시(CGLIB, 상속 기반)로 생성된다
- 트랜잭션이 적용되지 않는 가장 흔한 원인은 self-invocation(자기 호출)과 private/final 제약, 컨테이너 밖 객체 생성이다
- 롤백은 기본 규칙(RuntimeException/Error)과 예외 처리 방식(try/catch)에 의해 기대와 다르게 보일 수 있다