- Published on
Spring Boot 3.2에서 @Transactional 무시되는 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그에는 분명 @Transactional이 찍히는데, DB에는 커밋이 되어 있거나(롤백이 안 됨), 아예 트랜잭션이 열리지 않는 경우가 있습니다. Spring Boot 3.2(=Spring Framework 6.x)에서도 원리는 동일합니다. 스프링의 선언적 트랜잭션은 “AOP 프록시를 통한 메서드 호출”이 성립할 때만 적용됩니다.
이 글에서는 Spring Boot 3.2에서 @Transactional이 무시(또는 기대와 다르게 동작)되는 대표적인 7가지 케이스를, 원인 → 재현 코드 → 해결 순서로 정리합니다.
> 운영 장애를 추적할 때는 “애플리케이션 레벨 원인”과 “인프라 레벨 원인”을 분리하는 게 중요합니다. 인프라 관점의 트러블슈팅은 예를 들어 EKS Ingress 502인데 Pod 로그가 비면? ALB/NLB 헬스체크부터 같은 체크리스트가 도움이 됩니다.
1) 같은 클래스 내부 호출(Self-invocation)
증상
service.outer()안에서this.inner()를 호출했는데inner()의@Transactional이 적용되지 않음- 로그로 보면
inner()가 실행되지만 트랜잭션 경계가 생기지 않음
원인
스프링 트랜잭션은 프록시가 가로채는 방식입니다. 같은 객체 내부에서 this로 호출하면 프록시를 거치지 않기 때문에 AOP가 동작하지 않습니다.
재현 코드
@Service
public class OrderService {
public void outer() {
// 같은 클래스 내부 호출 -> 프록시 우회
inner();
}
@Transactional
public void inner() {
// 기대: 트랜잭션 시작
}
}
해결
- 트랜잭션 메서드를 별도 빈으로 분리해서 프록시 호출이 되게 하거나
- (권장도 낮고 복잡도 상승)
AopContext.currentProxy()사용 +exposeProxy=true설정
권장 패턴(분리):
@Service
public class OrderService {
private final TxOrderWorker worker;
public OrderService(TxOrderWorker worker) {
this.worker = worker;
}
public void outer() {
worker.inner(); // 프록시 경유
}
}
@Service
public class TxOrderWorker {
@Transactional
public void inner() {
}
}
2) final/private/static 메서드에 붙임(프록시가 못 가로챔)
증상
- 메서드에
@Transactional을 붙였는데 전혀 적용되지 않음
원인
기본 프록시 전략이 JDK 동적 프록시(인터페이스 기반) 또는 CGLIB(클래스 기반) 인데, 어떤 방식이든 공통적으로 가로채기(intercept)가 불가능한 형태가 있습니다.
private메서드: 외부에서 호출 자체가 안 되고 프록시가 끼어들 여지가 없음final메서드(CGLIB): 오버라이드가 불가해 프록시가 메서드 인터셉트 불가static메서드: 인스턴스 프록시로 감쌀 대상이 아님
재현 코드
@Service
public class PaymentService {
@Transactional
private void payInternal() { // 적용 안 됨
}
@Transactional
public final void payFinal() { // CGLIB에서도 적용 어려움
}
@Transactional
public static void payStatic() { // 적용 안 됨
}
}
해결
public(또는 최소protected) 인스턴스 메서드로 노출- 트랜잭션 경계는 “외부에서 호출되는 서비스 계층 public 메서드”에 두는 것이 정석
3) Bean이 아닌 객체에서 호출(스프링 컨테이너 밖)
증상
new SomeService()로 만든 객체에서@Transactional이 동작하지 않음- 유틸/헬퍼 클래스로 분리했는데 트랜잭션이 사라짐
원인
@Transactional은 스프링 컨테이너가 만든 빈에 프록시를 씌워서 동작합니다. 즉, 스프링이 관리하지 않는 객체는 AOP 대상이 아닙니다.
재현 코드
public class ManualCaller {
public void run() {
OrderService service = new OrderService(); // 스프링 빈 아님
service.placeOrder(); // @Transactional 무시
}
}
해결
- 호출 주체도 스프링 빈으로 만들고 DI로 주입받기
- 배치/CLI/스케줄러라면
@Component+ 주입 구조로 변경
4) 롤백 조건 오해: Checked Exception은 기본 롤백이 아님
증상
- 예외가 발생했는데도 DB가 커밋됨
- 특히
IOException, 커스텀 checked exception에서 자주 발생
원인
스프링의 기본 정책은 다음과 같습니다.
RuntimeException/Error발생 시 롤백- Checked Exception(예:
Exception,IOException)은 기본적으로 롤백하지 않음
재현 코드
@Service
public class MemberService {
@Transactional
public void register() throws Exception {
// insert...
throw new Exception("checked exception");
}
}
해결
rollbackFor를 명시하거나 예외를 런타임으로 감싸기:
@Transactional(rollbackFor = Exception.class)
public void register() throws Exception {
throw new Exception("checked exception");
}
또는 도메인 정책상 롤백이 필요한 예외는 RuntimeException 계열로 설계하는 것이 일반적입니다.
5) 예외를 잡아먹고 정상 종료(커밋됨)
증상
- 내부에서 에러가 났는데 메서드가 정상 리턴
- 결과적으로 트랜잭션이 커밋됨
원인
트랜잭션 롤백은 보통 “메서드 밖으로 예외가 전파”될 때 트리거됩니다. try/catch로 예외를 삼키면 스프링은 정상 종료로 판단하고 커밋합니다.
재현 코드
@Transactional
public void updateProfile() {
try {
// update...
throw new IllegalStateException("boom");
} catch (Exception e) {
// 로깅만 하고 끝내면 -> 커밋
}
}
해결
- 예외를 다시 던지기
- 또는 명시적으로 rollback-only 설정
@Transactional
public void updateProfile() {
try {
throw new IllegalStateException("boom");
} catch (Exception e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
throw e; // 보통은 재던짐이 더 명확
}
}
6) 전파(Propagation) 설정으로 인해 “트랜잭션이 없는 것처럼” 보임
증상
- 분명
@Transactional인데도 일부 작업이 롤백되지 않음 - 혹은 외부 트랜잭션과 분리되어 커밋/롤백 타이밍이 예상과 다름
원인
전파 옵션은 트랜잭션 경계를 크게 바꿉니다.
REQUIRES_NEW: 기존 트랜잭션을 suspend하고 새 트랜잭션을 독립적으로 시작NOT_SUPPORTED: 트랜잭션 없이 실행SUPPORTS: 있으면 참여, 없으면 비트랜잭션
재현 코드(롤백이 “안 되는 것처럼” 보이는 대표 케이스)
@Service
public class OuterService {
private final InnerService inner;
public OuterService(InnerService inner) {
this.inner = inner;
}
@Transactional
public void outer() {
inner.audit(); // REQUIRES_NEW로 이미 커밋
throw new RuntimeException("fail outer");
}
}
@Service
public class InnerService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void audit() {
// 감사 로그 insert -> outer가 실패해도 커밋됨
}
}
해결
- “같이 롤백되어야 하는 작업”이면
REQUIRED(기본값)로 맞추기 - 감사/아웃박스/알림처럼 독립 커밋이 필요한 경우에만
REQUIRES_NEW사용 NOT_SUPPORTED/SUPPORTS는 의도적으로 비트랜잭션이 필요할 때만 선택
7) 테스트/비동기 환경에서의 착시: @Transactional이 적용되었지만 기대와 다름
7-1) @SpringBootTest + 테스트 트랜잭션 롤백
증상
- 서비스에서는 커밋될 것 같은데 테스트에서는 데이터가 남지 않음
원인
스프링 테스트는 기본적으로 테스트 메서드에 @Transactional이 있으면 테스트 종료 시 롤백합니다. 서비스 레벨에서 커밋이 일어나도, 바깥 테스트 트랜잭션이 롤백하면 최종적으로 데이터가 사라집니다.
해결
- 커밋을 관찰하려면
@Commit또는@Rollback(false)
@SpringBootTest
class MemberServiceTest {
@Test
@Transactional
@Commit
void commit_for_real() {
// ...
}
}
7-2) @Async/다른 스레드로 넘어가며 트랜잭션 컨텍스트가 끊김
증상
- 호출자는
@Transactional인데 비동기 작업에서는 트랜잭션이 없는 것처럼 동작
원인
트랜잭션 컨텍스트는 보통 ThreadLocal 기반입니다. 스레드가 바뀌면 트랜잭션도 같이 전달되지 않습니다.
재현 코드
@Service
public class AsyncService {
@Async
public void asyncWork() {
// 여기서는 호출자의 트랜잭션이 이어지지 않음
}
}
@Service
public class FacadeService {
private final AsyncService asyncService;
public FacadeService(AsyncService asyncService) {
this.asyncService = asyncService;
}
@Transactional
public void doWork() {
asyncService.asyncWork();
}
}
해결
- 비동기 메서드 자체에 별도의
@Transactional을 부여(필요 시) - 혹은 이벤트/아웃박스 패턴으로 트랜잭션 커밋 이후 처리로 분리
빠른 점검 체크리스트
- 호출이 프록시를 경유하는가? (self-invocation 아님)
- 메서드가
public인스턴스 메서드인가? (private/final/static아님) - 객체가 스프링 빈인가? (
new로 만들지 않음) - 롤백이 필요한 예외가 RuntimeException인가? 아니라면
rollbackFor지정했는가? - 예외를
catch로 삼키지 않았는가? Propagation이 의도와 맞는가? (REQUIRES_NEW,NOT_SUPPORTED주의)- 테스트/비동기에서 “트랜잭션이 끊기는 지점”이 있는가?
트랜잭션 이슈를 운영에서 확인하는 방법(로그/관측)
org.springframework.transaction/org.hibernate.SQL로그 레벨을 일시적으로 올려 트랜잭션 begin/commit/rollback을 확인- 커넥션 풀(HikariCP) 메트릭에서 active/idle 변화와 함께 트랜잭션 경계 추적
- 장애 상황에서 애플리케이션 로그가 비거나 요청이 중간에서 끊긴다면, 트랜잭션 이전에 인프라에서 막히는 경우도 많습니다. 이때는 EKS ALB Ingress 404 고정 - 10분 규칙·TG 진단처럼 L7/L4부터 분리 진단하는 편이 빠릅니다.
마무리
Spring Boot 3.2에서 @Transactional이 “무시되는 것처럼 보이는” 대부분의 문제는, 실제로는 프록시 호출 조건 불충족(self-invocation/비빈 객체/메서드 시그니처) 또는 롤백 규칙 오해(checked exception, catch로 삼킴), 전파/스레드 경계(REQUIRES_NEW, @Async, 테스트 롤백)로 설명됩니다.
다음에 트랜잭션이 의심되면, 먼저 “이 호출이 프록시를 타는가?”와 “예외가 밖으로 나가는가?” 두 가지만 확인해도 원인의 절반 이상이 바로 좁혀집니다.