Published on

Spring Boot 3에서 @Transactional 무시되는 7가지 원인

Authors

서버에서 분명 @Transactional을 붙였는데도 롤백이 안 되거나, DB 커밋이 되어버리거나, 트랜잭션 로그가 아예 안 찍히는 상황을 한 번쯤 겪습니다. Spring Boot 3(Spring Framework 6)에서도 원리는 동일합니다. @Transactional은 “마법”이 아니라 AOP 프록시가 메서드 호출을 가로채서 트랜잭션 경계를 열고 닫는 구조이기 때문에, 프록시가 개입하지 못하는 호출 경로/설정이면 쉽게 무시됩니다.

이 글에서는 Spring Boot 3 기준으로, 현장에서 가장 자주 만나는 @Transactional이 무시되는 7가지 원인과 확인/해결 방법을 정리합니다. (트랜잭션이 제대로 동작해도 DB 락/데드락으로 장애가 날 수 있는데, 그 경우는 PostgreSQL RDS deadlock_detected(40P01) 원인·해결도 같이 참고하면 좋습니다.)


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

가장 흔한 케이스입니다. @Transactional은 보통 프록시 기반 AOP로 동작합니다. 즉, 스프링 컨테이너가 만든 프록시 객체를 통해 호출될 때만 트랜잭션이 시작됩니다.

하지만 같은 클래스 내부에서 this.someTxMethod()처럼 호출하면 프록시를 거치지 않고 실제 객체의 메서드를 직접 호출하므로 트랜잭션이 열리지 않습니다.

문제 코드

@Service
public class OrderService {

    public void placeOrder() {
        // 같은 클래스 내부 호출 -> 프록시 우회
        saveOrder();
    }

    @Transactional
    public void saveOrder() {
        // DB 저장
    }
}

해결 방법

  1. 트랜잭션 메서드를 다른 빈으로 분리
@Service
public class OrderTxService {
    @Transactional
    public void saveOrder() {
        // DB 저장
    }
}

@Service
public class OrderService {
    private final OrderTxService orderTxService;

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

    public void placeOrder() {
        orderTxService.saveOrder(); // 프록시 경유
    }
}
  1. (차선) AopContext.currentProxy() 사용: 설정/결합도가 올라가 권장도는 낮습니다.

2) private/final/static 메서드에 붙여서 프록시가 적용되지 않음

Spring의 프록시 기반 AOP는 기본적으로 오버라이딩 가능한 메서드를 가로챕니다. 따라서 다음은 위험합니다.

  • private 메서드: 외부 호출 자체가 불가 + 프록시가 개입할 지점이 없음
  • final 메서드/클래스: CGLIB이 상속 기반으로 프록시를 만들 때 오버라이딩 불가
  • static 메서드: 인스턴스 프록시로 가로채기 어려움

문제 코드

@Service
public class PaymentService {

    @Transactional
    private void payInternal() {
        // 트랜잭션 적용 기대 -> 실제론 거의 적용되지 않음
    }
}

해결 방법

  • public (또는 최소 protected)로 노출하고, 다른 빈에서 호출되도록 구조를 바꾸세요.
  • 클래스/메서드에 final을 붙였다면 제거하거나, 아키텍처를 재설계하세요.

3) 예외가 발생해도 롤백 조건에 걸리지 않음(Checked Exception)

@Transactional의 기본 롤백 규칙은 다음과 같습니다.

  • RuntimeException / Error: 롤백
  • Checked Exception(예: Exception, IOException): 기본적으로 롤백하지 않음

그래서 “예외는 터졌는데 커밋이 됐다”는 상황이 생깁니다.

문제 코드

@Transactional
public void register() throws Exception {
    // ...
    throw new Exception("checked exception");
}

해결 방법 1) rollbackFor 지정

@Transactional(rollbackFor = Exception.class)
public void register() throws Exception {
    throw new Exception("checked exception");
}

해결 방법 2) 예외를 런타임 예외로 래핑

@Transactional
public void register() {
    try {
        // ...
        throw new java.io.IOException("io");
    } catch (Exception e) {
        throw new IllegalStateException(e);
    }
}

> 주의: 예외를 잡고 로그만 찍고 삼켜버리면(rethrow 하지 않으면) 트랜잭션은 정상 종료로 판단하고 커밋합니다. 이는 “무시”라기보다 “정상 커밋”이므로 디버깅 시 특히 많이 헷갈립니다.


4) @Transactional이 붙은 메서드를 인터페이스로 호출하는데 프록시 방식이 맞지 않음

Spring은 프록시를 만들 때 크게 두 가지를 씁니다.

  • JDK Dynamic Proxy: 인터페이스 기반
  • CGLIB Proxy: 클래스 상속 기반

일반적으로 Boot는 상황에 맞게 선택하지만, 설정/구조에 따라 인터페이스에 선언되지 않은 구현체 메서드를 호출하거나, 프록시 타입을 잘못 가정하면 트랜잭션이 기대와 다르게 동작할 수 있습니다.

문제 상황 예시

  • 빈을 인터페이스 타입으로 주입받아 쓰는데, @Transactional이 구현체의 특정 메서드에만 있고 인터페이스에는 선언이 없거나
  • 프록시가 JDK 방식인데 구현체 타입으로 캐스팅해서 호출하는 등

권장 해결책

  • 서비스 빈은 인터페이스/구현체 설계를 일관되게 가져가고
  • 트랜잭션 경계는 보통 public 서비스 메서드(인터페이스에 노출되는 API)에 두세요.

필요 시 강제로 CGLIB를 쓰려면:

spring.aop.proxy-target-class=true

> 다만 “무조건 CGLIB”가 정답은 아닙니다. 핵심은 프록시를 경유하는 호출 경로를 보장하는 것입니다.


5) @Async, 스케줄러, 이벤트 리스너 등 다른 스레드/다른 실행 컨텍스트

트랜잭션은 기본적으로 스레드 바인딩(Thread-bound) 입니다. 즉, 트랜잭션 컨텍스트는 ThreadLocal에 묶여 있고 다른 스레드로 넘어가면 이어지지 않습니다.

그래서 다음 조합에서 “트랜잭션이 무시된 것처럼” 보입니다.

  • @Transactional 메서드 내부에서 @Async 호출
  • @Scheduled에서 호출
  • @EventListener / @TransactionalEventListener의 단계 오해

문제 코드(비동기 호출)

@Service
public class ReportService {

    private final MailSender mailSender;

    public ReportService(MailSender mailSender) {
        this.mailSender = mailSender;
    }

    @Transactional
    public void generate() {
        // DB 저장
        mailSender.sendAsync(); // 다른 스레드 -> 현재 트랜잭션과 무관
    }
}

@Service
class MailSender {
    @Async
    public void sendAsync() {
        // 여기서 DB를 만지면 별도 트랜잭션/오토커밋으로 동작 가능
    }
}

해결 방법

  • 비동기 작업이 DB를 변경한다면, 비동기 메서드 자체에 @Transactional을 별도로 두고 독립 트랜잭션으로 설계하세요.
  • “커밋 이후에만 실행”이 필요하면 @TransactionalEventListener(phase = AFTER_COMMIT) 같은 패턴을 고려하세요.

분산 환경에서 “한 번만 실행되어야 하는 작업”까지 얽히면 중복 방지 전략이 필요해집니다. 이 경우는 Saga 패턴 보상 트랜잭션 중복 실행 방지법도 함께 읽으면 설계에 도움이 됩니다.


6) 전파(Propagation) 설정으로 인해 기대한 트랜잭션 경계가 아님

@Transactional은 전파 옵션에 따라 “새 트랜잭션을 만들지/기존 것을 참여할지/아예 트랜잭션 없이 실행할지”가 달라집니다.

특히 아래 두 개는 실수 빈도가 높습니다.

  • REQUIRES_NEW: 기존 트랜잭션을 일시 중단하고 새 트랜잭션 시작
  • NOT_SUPPORTED: 트랜잭션을 중단하고 비트랜잭션으로 실행

문제 코드(롤백 기대가 어긋남)

@Service
public class UserService {

    private final AuditService auditService;

    public UserService(AuditService auditService) {
        this.auditService = auditService;
    }

    @Transactional
    public void signup() {
        // 1) 사용자 저장
        // 2) 감사로그도 같이 롤백되길 기대
        auditService.write();

        throw new RuntimeException("fail");
    }
}

@Service
class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void write() {
        // 감사로그 저장 (별도 트랜잭션)
    }
}

위 코드는 signup()이 롤백되어도 write()는 이미 별도 트랜잭션으로 커밋될 수 있습니다. 이걸 보고 “@Transactional이 무시됐다”고 오해하는 경우가 많습니다.

해결 방법

  • 정말 같은 운명을 가져야 한다면 REQUIRED(기본값)로 참여시키세요.
  • 감사/아웃박스처럼 “메인 실패에도 남겨야 하는 기록”이면 REQUIRES_NEW가 맞습니다. 중요한 건 의도를 명확히 하고 테스트로 보장하는 것입니다.

7) 트랜잭션 매니저/데이터소스가 다르거나, JPA가 아닌 곳에서 작업함

Spring Boot 3에서 @Transactional은 기본적으로 적절한 PlatformTransactionManager를 찾아 트랜잭션을 엽니다. 그런데 다음 상황이면 트랜잭션이 “열렸는데도 효과가 없는 것처럼” 보일 수 있습니다.

  • DataSource가 여러 개인데, 내가 사용하는 Repository/Template이 다른 DataSource를 바라봄
  • JdbcTemplate과 JPA(EntityManager)가 서로 다른 트랜잭션 매니저에 묶임
  • JPA가 아닌 외부 시스템(예: 메시지 브로커, 외부 API)은 DB 트랜잭션과 원자적으로 묶이지 않음

문제 상황 예시(멀티 트랜잭션 매니저)

@Configuration
public class TxConfig {

    @Bean
    public PlatformTransactionManager txManager1(EntityManagerFactory emf1) {
        return new JpaTransactionManager(emf1);
    }

    @Bean
    public PlatformTransactionManager txManager2(DataSource ds2) {
        return new DataSourceTransactionManager(ds2);
    }
}

@Service
public class MixedService {

    private final JdbcTemplate jdbcTemplate2;

    public MixedService(@Qualifier("jdbcTemplate2") JdbcTemplate jdbcTemplate2) {
        this.jdbcTemplate2 = jdbcTemplate2;
    }

    @Transactional // 기본 txManager1이 잡히면, jdbcTemplate2는 밖에서 실행될 수 있음
    public void work() {
        jdbcTemplate2.update("update ...");
        throw new RuntimeException("rollback 기대");
    }
}

해결 방법

  • 어떤 트랜잭션 매니저를 쓸지 명시하세요.
@Transactional(transactionManager = "txManager2")
public void work() {
    // ds2에 대한 트랜잭션으로 묶임
}
  • 멀티 데이터소스 환경이면 “이 Repository는 어떤 EMF/DS를 쓰는가”를 패키지 스캔/빈 구성까지 포함해 점검하세요.

또한 DB 락/대기 때문에 “트랜잭션이 안 먹는 것처럼” 느껴질 때가 있습니다. 실제로는 트랜잭션이 정상인데, 커밋/롤백 타이밍에 락 경합이 생긴 것일 수 있습니다. 이 경우 RDS PostgreSQL too many connections 원인·해결 같은 운영 이슈도 함께 확인하는 것이 좋습니다.


디버깅 체크리스트(빠르게 원인 좁히기)

아래 5가지만 확인해도 원인의 80%는 잡힙니다.

  1. 호출 경로: 프록시를 경유했나? (같은 클래스 내부 호출은 아닌가)
  2. 메서드 시그니처: public인가? final/private/static은 아닌가
  3. 예외 처리: 예외를 잡아서 삼키지 않았나? Checked Exception이면 rollbackFor가 필요한가
  4. 전파 옵션: REQUIRES_NEW, NOT_SUPPORTED로 의도치 않은 경계가 생기지 않았나
  5. 트랜잭션 매니저: 내가 쓰는 DataSource/Repository와 같은 매니저가 잡히나

트랜잭션 로그를 보고 싶다면 다음 설정도 도움이 됩니다.

logging.level.org.springframework.transaction=TRACE
logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.hibernate.SQL=DEBUG

마무리

Spring Boot 3에서 @Transactional이 “무시”되는 대부분의 원인은 프록시를 못 타거나(호출 구조 문제), 롤백 규칙/전파/스레드 경계/트랜잭션 매니저 선택이 기대와 다른 경우입니다.

특히 Self-invocation, 예외 삼키기, @Async 조합은 재현도 쉽고 장애로 이어지기 쉬우니 우선순위로 점검하세요. 트랜잭션은 애노테이션 하나로 끝나지 않고, 호출 구조와 실행 컨텍스트까지 포함한 설계 문제라는 점이 핵심입니다.