이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
@Transactional이 붙은 메서드는 실제로 언제 트랜잭션 프록시를 통과하는가?- self-invocation, private 메서드, 직접 생성 객체에서 트랜잭션이 어긋나는 이유는 무엇인가?
- 클래스 레벨과 메서드 레벨 트랜잭션 설정은 어떻게 나누어야 하는가?
개요
@Transactional은 마법 버튼이 아니다. Spring이 관리하는 Bean에 트랜잭션 Advisor가 적용되고, 외부 호출자가 프록시를 통해 메서드를 호출할 때 동작한다. AOP 문서에서 다룬 프록시 원리가 트랜잭션에서도 그대로 반복된다. Spring 6에서는 class-based proxy에서 protected/package-visible 메서드도 고려될 수 있지만, interface proxy와 일관된 설계를 원한다면 공개 유스케이스 메서드에 트랜잭션 경계를 두는 것이 가장 읽기 쉽다.
그래서 트랜잭션 문제를 디버깅할 때는 애너테이션 위치보다 호출 경로를 먼저 봐야 한다. 누가 이 메서드를 호출하는가, 그 객체는 Spring Bean인가, 호출이 프록시를 지나가는가가 핵심이다.
프록시 동작
Spring은 @Transactional 메타데이터를 기반으로 트랜잭션 인터셉터를 적용한다. 호출자가 프록시 메서드를 호출하면 인터셉터가 트랜잭션 속성을 확인하고 TransactionManager를 통해 트랜잭션을 시작한다. 메서드가 정상 종료되면 commit하고, rollback 규칙에 맞는 예외가 밖으로 던져지면 rollback한다.
같은 클래스 내부 호출은 이 프록시 경계를 지나지 않는다. 이미 Target 객체 내부에 들어온 상태에서 this.saveSomething()을 호출하면 프록시가 다시 개입하지 않는다. 따라서 내부 메서드의 REQUIRES_NEW나 별도 rollback 정책이 기대대로 적용되지 않을 수 있다.
코드 예제
@Service
public class CouponService {
@Transactional
public void issueCoupons(List<Long> memberIds) {
for (Long memberId : memberIds) {
issueOne(memberId);
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void issueOne(Long memberId) {
// 내부 호출이면 새 트랜잭션이 시작되지 않을 수 있다.
}
}이 코드는 보기에는 각 쿠폰 발급이 독립 트랜잭션처럼 보인다. 하지만 issueCoupons 안에서 this.issueOne() 흐름으로 호출되면 issueOne의 프록시 설정을 거치지 않을 수 있다.
@Service
public class CouponBatchService {
private final SingleCouponIssuer singleCouponIssuer;
public CouponBatchService(SingleCouponIssuer singleCouponIssuer) {
this.singleCouponIssuer = singleCouponIssuer;
}
@Transactional
public void issueCoupons(List<Long> memberIds) {
memberIds.forEach(singleCouponIssuer::issueOne);
}
}
@Service
public class SingleCouponIssuer {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void issueOne(Long memberId) {
// 별도 Bean 외부 호출이므로 새 트랜잭션 의도가 선명하다.
}
}독립 트랜잭션이 진짜 설계 의도라면 별도 Bean으로 책임을 분리하는 편이 테스트와 추론에 좋다.
적용 위치
클래스 레벨 @Transactional(readOnly = true)는 조회 서비스의 기본값을 표현할 때 유용하다. 쓰기 메서드에는 메서드 레벨에서 readOnly = false나 필요한 전파 정책을 명시한다.
반대로 모든 Service 클래스에 관성적으로 클래스 레벨 트랜잭션을 붙이면 어떤 메서드가 쓰기 경계인지 흐려진다. 트랜잭션은 중요한 유스케이스 계약이므로 의도적으로 보이게 해야 한다.
실전 팁
- 트랜잭션이 예상대로 동작하지 않으면 애너테이션보다 호출 경로와 Bean 주입 여부를 먼저 확인한다.
REQUIRES_NEW가 필요하다는 말은 보통 책임 분리 신호다. 별도 Bean이나 별도 서비스로 빼는 것을 검토한다.- private 메서드에 트랜잭션을 붙이지 않는다. 트랜잭션 경계는 외부 호출 가능한 유스케이스에 둔다.
- non-public 메서드 트랜잭션은 proxy 종류와 Spring 버전에 따라 해석이 달라질 수 있으므로, 팀 기준은 public Service method 중심으로 맞춘다.
- 테스트에서 직접
new로 만든 Service를 호출하면 트랜잭션 프록시가 없다. - 같은 클래스 안의 읽기 메서드와 쓰기 메서드 정책이 크게 다르면 클래스 분리를 검토한다.
위험 신호!
- self-invocation 문제를 해결하려고 자기 자신을 주입받는다.
- private 메서드에
@Transactional을 붙이고 동작을 기대한다. - proxy 종류를 모른 채 protected/package-visible 메서드의 트랜잭션 적용을 전제로 설계한다.
- 모든 Service 클래스에 무조건
@Transactional을 붙인다. - 테스트는 통과하지만 실제 Spring Bean 호출에서만 트랜잭션 문제가 난다.
REQUIRES_NEW가 많은데 왜 독립 commit이 필요한지 설명되지 않는다.
확인 질문
확인 질문
@Transactional이 실제로 적용되려면 어떤 호출 경로가 필요한가?
- Spring이 관리하는 Bean을 외부에서 프록시를 통해 호출하는 경로가 필요하다.
- self-invocation에서 별도 트랜잭션 설정이 적용되지 않을 수 있는 이유는 무엇인가?
- 같은 객체 내부의
this호출은 트랜잭션 프록시를 다시 통과하지 않기 때문이다.- 독립 트랜잭션 메서드가 필요할 때 좋은 구조적 해법은 무엇인가?
- 책임을 별도 Bean으로 분리해 외부 프록시 호출이 되게 만드는 것이다.