- Published on
Spring Boot 3에서 @Transactional 무시 원인 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 분명 @Transactional을 붙였는데도 롤백이 안 되거나, 쿼리가 즉시 커밋되어 버리거나, lazy 로딩이 터지고, 로그를 보면 트랜잭션이 시작조차 안 된 것처럼 보일 때가 있습니다. Spring Boot 3(= Spring Framework 6)에서도 핵심 메커니즘은 동일합니다. @Transactional은 프록시 기반 AOP로 동작하며, 이 프록시 경계(메서드 호출 경로/빈 등록/예외 전파/스레드 경계)를 벗어나는 순간 “무시된 것처럼” 보입니다.
아래는 현장에서 가장 자주 만나는 @Transactional 무시 원인 7가지와 재현/해결 포인트입니다.
> 참고: DB 커넥션/트랜잭션이 의심될 때는 커넥션 풀 관점도 같이 보세요. 트랜잭션이 열렸는데 반환이 늦어 누수처럼 보이는 케이스도 많습니다. 관련해서는 Spring Boot 3.2 HikariCP 커넥션 누수 경고 추적법도 함께 확인하면 진단 속도가 빨라집니다.
1) 같은 클래스 내부 호출(Self-invocation)로 프록시를 우회함
@Transactional은 호출 대상 빈의 프록시가 가로채서 트랜잭션을 열어줍니다. 그런데 같은 클래스 내부에서 this.someTxMethod()처럼 호출하면 프록시를 거치지 않아 AOP가 적용되지 않습니다.
재현 코드
@Service
public class OrderService {
private final OrderRepository repo;
public OrderService(OrderRepository repo) {
this.repo = repo;
}
public void placeOrder() {
// 내부 호출: 프록시를 우회 -> @Transactional 무시처럼 보임
saveOrderTx();
}
@Transactional
public void saveOrderTx() {
repo.save(new Order());
throw new RuntimeException("force rollback");
}
}
해결책
- 트랜잭션 메서드를 다른 빈으로 분리해서 프록시 경유 호출
- 또는
ApplicationContext에서 자기 자신의 프록시를 주입받아 호출(비추천)
@Service
public class OrderTxService {
private final OrderRepository repo;
public OrderTxService(OrderRepository repo) { this.repo = repo; }
@Transactional
public void saveOrderTx() {
repo.save(new Order());
throw new RuntimeException("force rollback");
}
}
@Service
public class OrderService {
private final OrderTxService tx;
public OrderService(OrderTxService tx) { this.tx = tx; }
public void placeOrder() {
tx.saveOrderTx(); // 프록시 경유
}
}
2) private/final 메서드, final 클래스 등 프록시 적용 불가/제한
Spring의 기본 프록시 전략은 JDK 동적 프록시(인터페이스 기반) 또는 CGLIB(클래스 기반)입니다. 일반적으로 @Transactional은 public 메서드에 붙이는 것을 전제로 하며, 다음과 같은 경우 적용이 기대대로 되지 않을 수 있습니다.
private메서드에@Transactionalfinal메서드/final클래스(CGLIB 오버라이드 불가)@Transactional을 붙였지만 실제 호출되는 메서드는 다른 오버로드/다른 빈
재현 코드
@Service
public class MemberService {
@Transactional
private void updateInternal() {
// 프록시가 가로채지 못해 트랜잭션이 안 열릴 수 있음
}
public void update() {
updateInternal();
}
}
해결책
- 트랜잭션 경계는 public 메서드로 만들고, 외부 빈에서 호출되게 구성
- Kotlin 사용 시 특히 주의: 클래스/메서드가 기본
final이므로kotlin-spring(all-open) 플러그인 적용 필요
3) 예외 타입 때문에 롤백이 안 됨(Checked Exception)
@Transactional 기본 정책은 RuntimeException/Error에만 롤백합니다. 즉, 비즈니스 예외를 checked exception으로 던지면 “트랜잭션이 무시된 것처럼” 커밋되어 버릴 수 있습니다.
재현 코드
@Transactional
public void pay() throws Exception {
// ...
throw new Exception("checked exception"); // 기본은 롤백 안 함
}
해결책
rollbackFor지정- 또는 예외를 RuntimeException으로 래핑
@Transactional(rollbackFor = Exception.class)
public void pay() throws Exception {
throw new Exception("checked exception");
}
추가로 주의할 점:
- 예외를 catch해서 삼켜버리면(로그만 찍고 정상 반환) 당연히 커밋됩니다.
@Transactional
public void pay() {
try {
// ...
throw new IllegalStateException("fail");
} catch (Exception e) {
// 여기서 삼키면 트랜잭션은 정상 종료 -> 커밋
// 해결: 다시 던지거나 setRollbackOnly 처리
}
}
4) @Async / 스레드 경계: 트랜잭션 컨텍스트는 ThreadLocal
Spring의 트랜잭션은 기본적으로 ThreadLocal에 바인딩됩니다. 즉, @Async, 새로운 스레드, CompletableFuture.supplyAsync 등으로 실행되면 기존 트랜잭션이 전파되지 않습니다.
재현 코드
@Service
public class ReportService {
@Async
@Transactional
public void generateAsync() {
// 별도 스레드에서 새로운 트랜잭션이 열리거나(설정에 따라)
// 호출자가 기대한 트랜잭션과는 무관하게 동작
}
@Transactional
public void request() {
generateAsync();
// 여기의 트랜잭션과 async 내부는 동일하지 않음
}
}
해결책
- “하나의 트랜잭션으로 묶어야 하는 작업”은 비동기로 분리하지 말고 동기 처리
- 비동기가 필요하면 작업 단위를 분리하고, 각 작업의 트랜잭션 경계를 명시적으로 설계
- 이벤트 기반이면
@TransactionalEventListener(phase = AFTER_COMMIT)로 커밋 이후 비동기 처리 등 패턴 적용
5) 전파(Propagation) 설정으로 인해 기대한 범위에서 동작하지 않음
@Transactional이 “무시”된 게 아니라, 전파 규칙 때문에 트랜잭션 경계가 기대와 다르게 잡히는 경우가 많습니다.
대표적으로:
REQUIRES_NEW: 기존 트랜잭션을 잠시 중단하고 새 트랜잭션을 시작 → 외부 롤백과 무관하게 내부 커밋 가능NOT_SUPPORTED: 트랜잭션 없이 실행 → 내부 작업이 즉시 커밋될 수 있음SUPPORTS: 있으면 참여, 없으면 비트랜잭션 → 호출 경로에 따라 결과가 달라짐
재현 코드 (REQUIRES_NEW로 내부 커밋)
@Service
public class OuterService {
private final InnerService inner;
public OuterService(InnerService inner) { this.inner = inner; }
@Transactional
public void outer() {
inner.audit(); // 별도 트랜잭션으로 커밋
throw new RuntimeException("outer fails");
}
}
@Service
public class InnerService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void audit() {
// 감사 로그 저장은 커밋됨
}
}
해결책
- “같이 롤백되어야 하는 작업”은
REQUIRES_NEW를 피하고 기본(REQUIRED) 전파 사용 - 감사/로그처럼 반드시 남겨야 하는 데이터만
REQUIRES_NEW로 분리
6) 테스트/실행 환경에서 트랜잭션 매니저/프록시가 제대로 구성되지 않음
Spring Boot 3에서 JPA를 쓰면 보통 PlatformTransactionManager가 자동 구성되지만, 아래 상황에서는 @Transactional이 기대대로 동작하지 않을 수 있습니다.
@SpringBootTest없이 슬라이스 테스트를 하면서(예:@WebMvcTest) 서비스 빈을 직접 new로 생성- 테스트에서
@Transactional은 적용되지만, 실제로는 테스트 트랜잭션이 끝나며 롤백되어 결과가 혼동됨 - 멀티 데이터소스에서 원하는 트랜잭션 매니저가 아닌 다른 매니저가 선택됨
체크 포인트
- 서비스/리포지토리가 반드시 스프링 빈으로 등록되어 프록시가 생성되는지
- 멀티 트랜잭션 매니저라면 명시적으로 지정하는지
@Transactional(transactionManager = "orderTxManager")
public void placeOrder() {
// ...
}
7) readOnly/Flush 타이밍/OSIV 설정으로 “커밋된 것처럼” 보이는 착시
엄밀히 말해 @Transactional이 무시된 게 아니라, 다음 요소 때문에 결과가 다르게 관측될 수 있습니다.
@Transactional(readOnly = true)에서 쓰기 쿼리가 flush되지 않거나, provider가 최적화하여 쓰기를 억제flush()가 호출되면 트랜잭션 커밋 전이라도 DB에 SQL이 날아감(단, 커밋 전이면 롤백 가능)- OSIV(Open Session In View) 설정에 따라 영속성 컨텍스트/세션이 요청 범위로 열려 있어 lazy 로딩이 “되는 것처럼” 보이거나, 반대로 서비스 계층에서 트랜잭션이 없으면 지연 로딩이 실패
재현 코드 (flush 착시)
@Transactional
public void createAndFlush(EntityManager em) {
em.persist(new Order());
em.flush(); // 여기서 INSERT SQL이 실행됨
throw new RuntimeException("rollback");
}
로그에 INSERT가 찍히면 “커밋됐다”로 오해하기 쉽지만, 같은 트랜잭션이면 롤백 시 실제 커밋은 되지 않습니다(격리수준/관측 방식에 따라 다른 세션에서 보이지 않아야 정상).
해결책
- readOnly 트랜잭션에 쓰기 로직이 섞이지 않게 분리
- SQL 로그만 보지 말고 커밋/롤백 로그와 실제 데이터 상태를 함께 확인
- 커넥션/트랜잭션 경계가 의심되면 풀 레벨에서 대기/반납을 추적(예: HikariCP leak detection)
진단 체크리스트(빠르게 원인 좁히기)
아래 질문에 “예”가 나오면 해당 섹션이 유력합니다.
- 트랜잭션 메서드를 같은 클래스에서 내부 호출하고 있나? → 1번
private/final메서드, Kotlin final 기본값, 프록시가 못 붙는 구조인가? → 2번- 예외를 catch해서 삼키거나 checked exception을 던지나? → 3번
@Async/새 스레드/CompletableFuture로 트랜잭션 밖에서 실행되나? → 4번REQUIRES_NEW/NOT_SUPPORTED등 전파 설정을 썼나? → 5번- 테스트에서 빈을 new로 만들었거나, 멀티 TM에서 다른 매니저가 잡히나? → 6번
- readOnly/flush/OSIV 때문에 관측이 꼬였나? → 7번
트랜잭션이 실제로 열렸는지 로그로 확인하기
가장 확실한 방법은 트랜잭션 인터셉터 로그를 켜서 “시작/커밋/롤백”을 보는 것입니다.
# application.yml
logging:
level:
org.springframework.transaction: TRACE
org.springframework.orm.jpa: DEBUG
JPA를 쓴다면 SQL 로그뿐 아니라 커넥션 반납 지연/누수 경고도 같이 보면 원인 분리가 빨라집니다. 트랜잭션이 열려 있는데 외부 API 호출 등으로 오래 붙잡고 있어 풀 고갈이 나는 경우는 Spring Boot 3.2 HikariCP 커넥션 누수 경고 추적법에서 소개한 방식으로 스택 트레이스를 확보하면 즉시 잡히는 편입니다.
마무리
Spring Boot 3에서 @Transactional이 “무시”되는 현상은 대부분 프록시를 안 거쳤거나(호출 경로 문제), 예외/전파/스레드 경계 때문에 기대한 롤백 조건이 성립하지 않는 경우입니다. 위 7가지를 순서대로 점검하면 대개 10분 내로 원인을 좁힐 수 있고, 설계(빈 분리/전파 정책/비동기 분리)까지 정리하면 재발도 크게 줄어듭니다.