Published on

Spring Boot 3에서 @Transactional 무시되는 5가지

Authors

서버가 잘 돌아가고 로그도 멀쩡한데, 데이터는 일부만 저장되거나(원자성 깨짐) 예외가 났는데도 커밋되는 상황을 겪으면 대부분 @Transactional이 “적용되지 않은 것”에 가깝습니다. Spring Boot 3(= Spring Framework 6, Jakarta EE 10 기반)에서도 원리는 동일합니다. @TransactionalAOP 프록시로 동작하며, 프록시를 거치지 않는 호출·설정·예외 흐름에서는 트랜잭션이 시작되지 않거나 롤백 규칙이 기대와 다르게 적용됩니다.

이 글에서는 현장에서 가장 자주 만나는 @Transactional 무시(또는 무시된 것처럼 보이는) 5가지 케이스를 “왜 그런지(원인) → 어떻게 확인하는지(진단) → 어떻게 고치는지(해결)” 순서로 정리합니다.

관련해서 성능/동시성 이슈까지 함께 보는 경우가 많아, 가상 스레드 환경에서의 진단 글도 참고로 남깁니다: Spring Boot 3 가상스레드 적용 후 지연·데드락 진단

1) 같은 클래스 내부 호출(Self-invocation)로 프록시를 우회하는 경우

증상

  • serviceA.outer()@Transactional이 있는데, 내부에서 this.inner()를 호출하면 inner()@Transactional이 무시됨
  • 전파(propagation)나 readOnly 등 설정이 기대대로 적용되지 않음

원인

Spring의 선언적 트랜잭션은 프록시 객체를 통해 메서드 호출을 가로채는 방식입니다. 같은 클래스 내부에서 this.inner()처럼 호출하면 프록시를 거치지 않고 대상 객체(target) 메서드가 직접 실행되어 AOP가 동작하지 않습니다.

재현 코드

@Service
public class OrderService {

    @Transactional
    public void outer() {
        // ...
        inner(); // 같은 클래스 내부 호출 -> 프록시 우회
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() {
        // 여기서 새 트랜잭션이 열릴 거라 기대하지만, 실제로는 적용 안 될 수 있음
    }
}

해결

  1. 메서드를 다른 빈으로 분리해서 프록시 경유 호출로 바꾸는 것이 가장 안전합니다.
@Service
public class OrderService {
    private final OrderTxService orderTxService;

    public OrderService(OrderTxService orderTxService) {
        this.orderTxService = orderTxService;
    }

    @Transactional
    public void outer() {
        orderTxService.inner(); // 프록시 경유
    }
}

@Service
public class OrderTxService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() {
        // 의도대로 새 트랜잭션
    }
}
  1. (권장도 낮음) AopContext.currentProxy()를 쓰는 방법도 있지만, 설정이 필요하고 결합도가 높아집니다.
@EnableAspectJAutoProxy(exposeProxy = true)
@Configuration
class AopConfig {}

@Service
public class OrderService {
    @Transactional
    public void outer() {
        ((OrderService) AopContext.currentProxy()).inner();
    }

    @Transactional
    public void inner() {}
}

진단 팁

  • 디버깅에서 this.getClass()OrderService 그대로면 프록시 우회 가능성이 큽니다.
  • 빈 주입 타입이 인터페이스 기반 프록시인지(CGLIB인지)도 확인합니다.

2) public이 아닌 메서드 / final 클래스·메서드 등 프록시 적용 불가

증상

  • @Transactional을 붙였는데 아무 효과가 없음
  • 특히 private, protected, package-private 메서드에 붙였을 때 자주 발생

원인

기본적인 프록시 기반 AOP에서 @Transactional은 보통 public 메서드에만 안정적으로 적용됩니다. 또한 CGLIB 프록시를 쓰는 경우에도 final 클래스/메서드는 오버라이드가 불가해 프록시가 끼어들 수 없습니다.

문제 코드

@Service
public class PaymentService {

    @Transactional
    void pay() { // package-private
        // 트랜잭션 기대하지만 적용 안 될 수 있음
    }

    @Transactional
    private void logPayment() {
        // 거의 확실히 적용되지 않음
    }
}

해결

  • 트랜잭션 경계는 **public 메서드(외부에서 호출되는 진입점)**에 두세요.
  • Kotlin을 사용한다면 기본이 final이므로 kotlin-spring 플러그인(all-open) 적용 여부를 확인하세요.
plugins {
    kotlin("jvm")
    kotlin("plugin.spring") // @Service, @Transactional 등을 open 처리
}

진단 팁

  • 트랜잭션이 열렸는지 로그로 확인: logging.level.org.springframework.transaction=TRACE
  • 실행 시점에 TransactionSynchronizationManager.isActualTransactionActive()로 체크
boolean active = TransactionSynchronizationManager.isActualTransactionActive();

3) 예외는 났는데 롤백이 안 된다: 롤백 규칙(Checked Exception, catch 후 삼킴)

증상

  • 예외가 발생했는데도 DB에 데이터가 커밋됨
  • 혹은 일부만 저장되고 나머지 로직에서 예외가 나도 롤백되지 않음

원인 A: Checked Exception은 기본 롤백 대상이 아님

Spring의 기본 규칙은 다음과 같습니다.

  • RuntimeException/Error 발생 시 롤백
  • Checked Exception(예: Exception, IOException)은 기본적으로 커밋
@Transactional
public void placeOrder() throws Exception {
    repository.save(...);
    throw new Exception("checked"); // 기본 규칙상 롤백 안 될 수 있음
}

해결

  • rollbackFor를 명시합니다.
@Transactional(rollbackFor = Exception.class)
public void placeOrder() throws Exception {
    repository.save(...);
    throw new Exception("checked");
}

원인 B: 예외를 catch하고 정상 종료하면 커밋됨

아래는 흔한 실수입니다.

@Transactional
public void placeOrder() {
    try {
        repository.save(...);
        remoteCall();
    } catch (Exception e) {
        // 로그만 남기고 삼키면 메서드는 정상 종료 -> 커밋
        log.warn("failed", e);
    }
}

해결

  • 예외를 다시 던지거나
  • 롤백 마크를 직접 설정합니다.
@Transactional
public void placeOrder() {
    try {
        repository.save(...);
        remoteCall();
    } catch (Exception e) {
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        throw e; // 또는 비즈니스 예외로 래핑
    }
}

진단 팁

  • “예외는 났는데 커밋됨”은 대부분 catch 후 삼킴 또는 checked exception입니다.
  • 트랜잭션 로그(TRACE)에서 Completing transaction 시점과 예외 흐름을 함께 보세요.

4) @Async / 스레드 경계(가상 스레드 포함)로 트랜잭션 컨텍스트가 끊어짐

증상

  • @Transactional 메서드 내부에서 @Async로 넘긴 작업이 같은 트랜잭션에 묶일 거라 기대했지만 분리됨
  • 혹은 이벤트 리스너/비동기 처리에서 LazyInitializationException, flush 타이밍 이상, 부분 커밋처럼 보이는 현상

원인

Spring의 트랜잭션은 기본적으로 ThreadLocal 기반 컨텍스트입니다. 즉, 트랜잭션은 “스레드에 묶여” 전파됩니다.

  • @Async는 다른 스레드(또는 다른 실행기)에서 실행
  • 따라서 원래 트랜잭션 컨텍스트가 전달되지 않습니다.
@Service
public class MailService {

    @Async
    public void sendAsyncMail(Long orderId) {
        // 이 메서드는 호출한 트랜잭션과 무관하게 실행됨
    }
}

@Service
public class OrderService {
    private final MailService mailService;

    @Transactional
    public void placeOrder() {
        repository.save(...);
        mailService.sendAsyncMail(1L); // 같은 TX로 묶이지 않음
        // 여기서 롤백돼도 메일 발송은 이미 진행될 수 있음
    }
}

가상 스레드(virtual thread)를 쓰더라도, “트랜잭션이 특정 실행 흐름에 자동으로 전파될 것”이라고 기대하면 위험합니다. 가상 스레드는 스레드 모델을 바꾸지만, 트랜잭션 경계 설계(동기/비동기 분리, 아웃박스 패턴 등)를 대신해주지 않습니다.

해결

  • 비동기 작업은 트랜잭션 밖에서 실행되도록 설계(커밋 이후 실행)
  • @TransactionalEventListener(phase = AFTER_COMMIT)로 커밋 후 이벤트 처리
public record OrderPlacedEvent(Long orderId) {}

@Service
public class OrderService {
    private final ApplicationEventPublisher publisher;

    @Transactional
    public void placeOrder() {
        var order = repository.save(new Order());
        publisher.publishEvent(new OrderPlacedEvent(order.getId()));
    }
}

@Component
public class OrderEventHandler {

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void afterCommit(OrderPlacedEvent event) {
        // 커밋이 보장된 뒤 비동기로 실행
    }
}

진단 팁

  • “같은 트랜잭션일 거라 생각한” 코드에 @Async, 메시지 큐, 스케줄러, 이벤트 리스너가 섞여 있는지 먼저 확인하세요.

5) 테스트/설정 환경에서 트랜잭션이 다르게 동작: 슬라이스 테스트, 프록시 미생성, 잘못된 TxManager

증상

  • 운영에서는 되는데 테스트에서만 @Transactional이 먹히지 않음(또는 반대)
  • 다중 데이터소스에서 특정 리포지토리만 트랜잭션이 안 잡힘

원인 A: 테스트 슬라이스가 필요한 빈/AOP를 로딩하지 않음

@WebMvcTest 같은 슬라이스 테스트는 컨텍스트를 최소로 띄웁니다. 서비스/리포지토리/트랜잭션 설정이 제외되면 @Transactional을 붙여도 기대와 다르게 보일 수 있습니다.

해결

  • 트랜잭션을 검증해야 하는 테스트는 @SpringBootTest 또는 적절한 구성 임포트
  • 또는 리포지토리 레벨은 @DataJpaTest를 사용
@SpringBootTest
class OrderServiceIT {
    @Autowired OrderService orderService;

    @Test
    void tx_works() {
        orderService.placeOrder();
    }
}

원인 B: 트랜잭션 매니저가 다름(다중 DataSource/JPA + JDBC 혼용)

Spring Boot는 보통 PlatformTransactionManager를 자동 구성하지만,

  • 데이터소스가 2개 이상
  • JPA 트랜잭션 매니저와 JDBC 트랜잭션 매니저를 동시에 같은 경우, 의도한 매니저가 선택되지 않아 특정 작업이 트랜잭션 밖에서 실행될 수 있습니다.

해결

  • @Transactional(transactionManager = "...")로 명시
  • 혹은 @Primary 지정, 빈 이름 정리
@Transactional(transactionManager = "orderTransactionManager")
public void placeOrder() {
    // 특정 DS/JPA에 대해 트랜잭션 보장
}

진단 팁

  • 로그에서 어떤 트랜잭션 매니저가 선택되는지 확인
  • JpaTransactionManager vs DataSourceTransactionManager 혼재 여부 점검

빠른 체크리스트(현장용)

  • 호출 경로가 프록시를 타는가? (같은 클래스 내부 호출이면 거의 실패)
  • @Transactionalpublic 메서드에 붙어 있는가? (Kotlin final 여부 포함)
  • 예외가 RuntimeException인가? catch 후 삼키지 않았는가? checked exception이면 rollbackFor 필요
  • @Async/이벤트/스케줄러/메시징 등으로 스레드 경계가 바뀌지 않았는가?
  • 테스트/다중 데이터소스에서 올바른 TransactionManager가 선택되는가?

마무리

@Transactional이 무시되는 대부분의 사례는 “Spring이 버그”라기보다, 프록시 기반 AOP의 한계롤백 규칙/스레드 경계를 놓친 설계에서 시작합니다. 위 5가지는 재현도 쉽고 발생 빈도도 높으니, 트랜잭션이 의심될 때는 먼저 이 순서대로 체크하면 진단 시간이 크게 줄어듭니다.

부하가 커져 동시성 모델(예: 가상 스레드)까지 손대는 상황이라면, 트랜잭션 경계/락/커넥션 풀 병목이 함께 드러나기도 합니다. 그런 케이스는 위에서 링크한 가상 스레드 진단 글도 같이 보면 문제를 더 빨리 좁힐 수 있습니다.