- Published on
Spring Boot 3에서 @Transactional이 안먹는 6가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 데이터가 일부만 저장되거나, 예외가 났는데도 커밋이 되어버리거나, 반대로 분명 저장했는데 롤백되어 사라지는 상황을 겪으면 가장 먼저 @Transactional을 의심하게 됩니다. 하지만 Spring Boot 3에서도 트랜잭션은 여전히 프록시 기반 AOP로 동작하기 때문에, 특정 조건에서는 “어노테이션이 붙어 있는데도” 트랜잭션이 시작되지 않거나 롤백 규칙이 기대와 달라질 수 있습니다.
이 글에서는 Spring Boot 3에서 @Transactional이 안 먹는 것처럼 보이는 대표적인 6가지 케이스를, 재현 코드와 함께 원인과 해결책을 정리합니다.
참고로 트랜잭션 이슈는 DB 락/데드락 문제로 이어지는 경우가 많습니다. 증상이 락 대기 폭증으로 나타난다면 PostgreSQL 락 대기 폭증? deadlock 진단·해결도 함께 확인해보세요.
1) 자기 자신 메서드 호출(Self-invocation)로 프록시를 우회함
Spring의 선언적 트랜잭션은 기본적으로 “빈을 감싼 프록시”가 메서드 호출을 가로채며 시작됩니다. 그런데 같은 클래스 내부에서 this.someMethod() 형태로 호출하면 프록시를 거치지 않으므로 트랜잭션이 적용되지 않습니다.
재현 코드
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public void placeOrder() {
// 같은 클래스 내부 호출: 프록시를 안 타서 @Transactional이 무시될 수 있음
saveOrderTx();
}
@Transactional
public void saveOrderTx() {
orderRepository.save(new Order("A"));
throw new IllegalStateException("fail");
}
}
위 코드는 placeOrder()에서 saveOrderTx()를 호출하지만, 호출 경로가 프록시를 거치지 않으면 트랜잭션이 시작되지 않아 롤백이 기대대로 동작하지 않을 수 있습니다.
해결 방법
- 트랜잭션 경계를 “외부에서 호출되는 public 메서드”에 두기
- 트랜잭션 메서드를 다른 빈으로 분리하기
@Service
@RequiredArgsConstructor
public class OrderTxService {
private final OrderRepository orderRepository;
@Transactional
public void saveOrderTx() {
orderRepository.save(new Order("A"));
throw new IllegalStateException("fail");
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderTxService orderTxService;
public void placeOrder() {
orderTxService.saveOrderTx();
}
}
2) private/final 메서드에 붙여서 프록시가 적용되지 않음
Spring AOP 프록시는 기본적으로 메서드 호출을 가로채는 방식이라, 다음과 같은 경우 트랜잭션 적용이 제한될 수 있습니다.
private메서드final메서드- (클래스 기반 프록시일 때)
final클래스
재현 코드
@Service
public class PaymentService {
public void pay() {
payInternal();
}
@Transactional
private void payInternal() {
// private: 프록시가 가로채기 어려움
}
}
해결 방법
- 트랜잭션 메서드는
public으로 두고, 외부 빈 호출 경로를 확보하기 - 구조적으로 “트랜잭션이 필요한 유스케이스 단위”로 서비스 레이어를 구성하기
3) 롤백 규칙 오해: Checked Exception은 기본 롤백 대상이 아님
@Transactional은 기본적으로 RuntimeException과 Error에 대해서만 롤백합니다. 즉, Exception 같은 checked exception을 던지면 “예외가 났는데도 커밋”되는 것처럼 보일 수 있습니다.
재현 코드
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional
public void register() throws Exception {
memberRepository.save(new Member("kim"));
throw new Exception("checked exception");
}
}
해결 방법
rollbackFor를 명시
@Transactional(rollbackFor = Exception.class)
public void register() throws Exception {
memberRepository.save(new Member("kim"));
throw new Exception("checked exception");
}
- 혹은 도메인 정책상 롤백이 필요한 예외는
RuntimeException계열로 설계
추가로 try-catch로 예외를 잡아먹고 정상 종료하면 트랜잭션은 커밋됩니다. “로그만 찍고 삼켰는데 롤백이 안 됨”은 정상 동작입니다.
4) 전파 옵션 오해: REQUIRES_NEW/NESTED/NOT_SUPPORTED로 경계가 갈라짐
트랜잭션이 “안 먹는 것처럼” 보이는 흔한 이유는 실제로는 트랜잭션이 존재하지만, 전파(propagation) 때문에 예상과 다른 경계로 실행되기 때문입니다.
대표적인 함정
REQUIRES_NEW: 바깥 트랜잭션과 독립적으로 커밋/롤백NOT_SUPPORTED: 트랜잭션을 중단하고 비트랜잭션으로 실행NESTED: DB와 드라이버, JPA 설정에 따라 동작이 달라 체감이 다를 수 있음
재현 코드: REQUIRES_NEW로 일부만 커밋
@Service
@RequiredArgsConstructor
public class OrderService {
private final AuditService auditService;
private final OrderRepository orderRepository;
@Transactional
public void place() {
orderRepository.save(new Order("A"));
auditService.write("order placed"); // 별도 트랜잭션
throw new IllegalStateException("fail");
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void write(String msg) {
// 여기는 바깥이 실패해도 커밋될 수 있음
}
}
이 경우 “분명 전체가 롤백돼야 하는데 감사 로그만 남음” 같은 현상이 발생합니다. 의도였다면 괜찮지만, 의도하지 않았다면 전파 옵션을 재검토해야 합니다.
5) 비동기 실행(@Async, 스케줄러, 이벤트 리스너)에서 트랜잭션 컨텍스트가 끊김
트랜잭션은 기본적으로 스레드 바운드(Thread-bound) 입니다. 즉, 같은 스레드에서 실행되는 작업에만 동일한 트랜잭션 컨텍스트가 전파됩니다.
따라서 아래와 같은 경우 “트랜잭션이 분명히 걸렸는데” DB 작업은 다른 스레드에서 실행되어 트랜잭션 밖에서 동작할 수 있습니다.
@Async@Scheduled@TransactionalEventListener사용 시 AFTER_COMMIT 등 페이즈
재현 코드
@Service
@RequiredArgsConstructor
public class ReportService {
private final ReportRepository reportRepository;
@Transactional
public void generate() {
saveAsync();
throw new IllegalStateException("fail");
}
@Async
public void saveAsync() {
// 다른 스레드에서 실행: 위 트랜잭션과 무관
reportRepository.save(new Report("R1"));
}
}
해결 방법
- 비동기 메서드 자체에 별도의
@Transactional을 명시하고, “독립 트랜잭션”임을 명확히 설계 - 같은 트랜잭션이 필요하다면 비동기 처리를 피하고, 트랜잭션 경계 안에서 동기 처리
- 이벤트 기반이라면
@TransactionalEventListener의 phase를 의도에 맞게 선택
가상 스레드를 사용하는 경우에도 핵심은 동일합니다. 트랜잭션은 논리적으로 스레드 로컬에 매달리므로, 실행 모델을 바꿀 때 JDBC 지연/커넥션 사용 패턴도 함께 점검해야 합니다. 관련해서는 Spring Boot 3 가상 스레드에서 JDBC 지연 줄이기를 참고하면 좋습니다.
6) 테스트 환경에서의 착시: 테스트 트랜잭션 롤백과 플러시 타이밍
Spring 테스트에서는 다음 조합 때문에 “저장이 안 됐다”, “롤백이 안 됐다”처럼 보이는 착시가 자주 생깁니다.
@SpringBootTest또는@DataJpaTest에서 기본적으로 테스트 메서드가 트랜잭션으로 감싸지고, 테스트 종료 시 롤백- JPA는 커밋 전까지 SQL이 실제로 나가지 않을 수 있음(쓰기 지연).
flush()전에는 DB에서 조회가 안 되는 것처럼 보일 수 있음
재현 코드
@SpringBootTest
class MemberServiceTest {
@Autowired MemberRepository memberRepository;
@Test
void save_seems_not_persisted() {
memberRepository.save(new Member("kim"));
// 테스트 끝나면 롤백되므로 DB에 남지 않음
}
}
해결 방법
- 테스트에서 실제 커밋을 보고 싶다면
@Commit또는@Rollback(false)사용 - SQL이 언제 실행되는지 확인하려면
saveAndFlush()또는entityManager.flush()를 사용
@Test
@Rollback(false)
void save_and_commit_for_debug() {
memberRepository.saveAndFlush(new Member("kim"));
}
또한 “락이 걸린 것 같다” 같은 증상이 테스트에서만 보인다면, 같은 DB를 여러 테스트가 공유하면서 트랜잭션이 겹쳐 락 대기가 발생하는 경우도 많습니다. 이때는 앞서 언급한 락/데드락 진단 글이 도움이 됩니다.
디버깅 체크리스트: 진짜로 트랜잭션이 시작됐는지 확인
원인을 좁히려면 먼저 “트랜잭션이 열렸는지”를 관측해야 합니다.
1) 로그로 트랜잭션 경계 확인
application.yml 예시입니다.
logging:
level:
org.springframework.transaction: TRACE
org.springframework.orm.jpa: DEBUG
org.hibernate.SQL: DEBUG
2) 코드로 현재 트랜잭션 활성 여부 확인
import org.springframework.transaction.support.TransactionSynchronizationManager;
boolean active = TransactionSynchronizationManager.isActualTransactionActive();
이 값이 false라면 프록시 우회(자기 호출), 접근 제어자 문제, 빈 등록 문제 등 “AOP 자체가 안 탄” 케이스를 의심하는 게 빠릅니다.
마무리: “안 먹는다”는 대부분 경계/호출/예외의 문제
Spring Boot 3에서 @Transactional이 안 먹는 것처럼 보이는 상황은 대체로 다음 중 하나로 귀결됩니다.
- 프록시를 안 타는 호출 경로(자기 호출,
private,final) - 롤백 규칙(checked exception, 예외를 삼킴)
- 전파 옵션으로 트랜잭션 경계가 분리됨
- 비동기/스케줄링으로 스레드가 바뀌며 컨텍스트가 끊김
- 테스트 트랜잭션과 플러시 타이밍으로 착시 발생
위 6가지를 순서대로 점검하면, “왜 커밋됐지” 혹은 “왜 롤백 안 됐지” 같은 문제를 대부분 빠르게 재현하고 해결할 수 있습니다.