Published on

Spring Boot 3에서 @Transactional 무효화 원인 7가지

Authors

서버에서 분명 @Transactional을 붙였는데도 커밋이 되어버리거나, 롤백이 기대대로 되지 않거나, 아예 트랜잭션이 시작되지 않는 것처럼 보일 때가 있습니다. Spring Boot 3에서는 기본 프록시 기반 AOP 구조는 그대로지만, Jakarta 패키지 전환과 구성 방식 변화로 인해 실수 포인트가 더 눈에 띄게 드러나기도 합니다.

이 글은 Spring Boot 3 기준으로 @Transactional이 “무효화”되거나 “무시되는 것처럼 보이는” 대표 원인 7가지를 재현 코드와 함께 정리합니다. (유사 주제로 더 짧은 체크리스트가 필요하면 Spring Boot 3에서 @Transactional 무시되는 7가지 원인도 참고하세요.)

1) 같은 클래스 내부 호출(self-invocation)로 프록시를 안 탐

Spring의 트랜잭션은 보통 프록시(AOP)로 적용됩니다. 즉, 외부에서 빈을 호출할 때 프록시가 가로채 트랜잭션을 열고 닫습니다. 그런데 같은 클래스 내부에서 메서드를 직접 호출하면 프록시를 거치지 않아서 @Transactional이 적용되지 않습니다.

재현 코드

@Service
public class OrderService {

    public void placeOrder() {
        // 같은 클래스 내부 호출: 프록시를 거치지 않음
        saveOrder();
    }

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

해결 방법

  • 트랜잭션이 필요한 메서드를 다른 빈으로 분리해서 외부 호출로 만들기
  • 또는 설계상 꼭 필요하다면 ApplicationContext로 자기 자신 프록시를 받아 호출하는 방식(권장도 낮음)
@Service
@RequiredArgsConstructor
public class OrderFacade {
    private final OrderService orderService;

    public void placeOrder() {
        orderService.saveOrder();
    }
}

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

2) final 클래스 또는 final 메서드라 프록시 적용이 제한됨

Spring AOP는 기본적으로 프록시 기반입니다.

  • JDK 동적 프록시: 인터페이스 기반
  • CGLIB 프록시: 클래스 상속 기반

CGLIB는 상속으로 프록시를 만들기 때문에 final 클래스나 final 메서드는 오버라이드가 불가능하여 AOP 적용이 깨질 수 있습니다. Kotlin 사용 시 기본이 final인 점도 자주 원인이 됩니다.

점검 포인트

  • 서비스 클래스가 final인지
  • 트랜잭션 메서드가 final인지
  • Kotlin이라면 kotlin-spring 플러그인(올오픈) 적용 여부

Kotlin 예시(문제)

@Service
class PaymentService {
    @Transactional
    fun pay() {
        // Kotlin 기본은 final
    }
}

Kotlin 해결

  • Gradle에 kotlin("plugin.spring") 적용
  • 또는 open 키워드로 클래스와 메서드를 열기
plugins {
    kotlin("plugin.spring")
}

3) private 또는 protected 메서드에 붙여서 프록시가 가로채지 못함

@Transactional은 보통 public 메서드에 적용하는 것이 안전합니다. 프록시가 외부 호출을 가로채는 구조이므로, private 메서드는 외부에서 호출될 수 없고 결과적으로 트랜잭션 경계로 쓰기 어렵습니다.

재현 코드

@Service
public class UserService {

    public void register() {
        createUser();
    }

    @Transactional
    private void createUser() {
        // 트랜잭션이 걸릴 것 같지만 실제로는 무시되는 경우가 많음
    }
}

권장

  • 트랜잭션 경계는 public 서비스 메서드에 둡니다.
  • 내부 로직은 private로 분리하되, 트랜잭션은 상위 public 메서드에 둡니다.

4) 예외가 밖으로 전파되지 않아 롤백 트리거가 안 걸림

Spring은 기본적으로 RuntimeExceptionError에 대해서만 롤백합니다. 또한 예외를 잡아서 삼켜버리면(로그만 찍고 정상 리턴) 트랜잭션 매니저는 “정상 종료”로 간주하고 커밋합니다.

재현 코드: 예외를 잡아먹는 경우

@Transactional
public void importData() {
    try {
        repository.save(entity);
        throw new IllegalStateException("fail");
    } catch (Exception e) {
        // 예외를 삼키면 커밋될 수 있음
        log.warn("ignored", e);
    }
}

해결 방법

  • 예외를 다시 던지기
  • 또는 체크 예외까지 롤백하려면 rollbackFor 지정
@Transactional(rollbackFor = Exception.class)
public void importData() throws Exception {
    repository.save(entity);
    throw new Exception("checked exception");
}

또는 “예외는 잡되 롤백만은 시키고 싶다”면 setRollbackOnly를 명시합니다.

@Transactional
public void importData() {
    try {
        repository.save(entity);
        throw new RuntimeException("boom");
    } catch (RuntimeException e) {
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        throw e;
    }
}

5) 전파(propagation) 설정으로 인해 “새 트랜잭션”이 생기거나, 반대로 트랜잭션이 없어짐

전파 옵션은 트랜잭션 무효화처럼 보이는 대표 원인입니다.

  • REQUIRES_NEW: 기존 트랜잭션을 잠시 중단하고 새 트랜잭션을 시작
  • NOT_SUPPORTED: 트랜잭션 없이 실행
  • MANDATORY: 트랜잭션이 없으면 예외

흔한 함정: 내부 작업만 커밋되어버림

@Service
@RequiredArgsConstructor
public class BatchService {
    private final LogService logService;

    @Transactional
    public void run() {
        // ... 메인 작업
        logService.writeLog(); // REQUIRES_NEW면 로그는 별도 커밋
        throw new RuntimeException("fail");
    }
}

@Service
public class LogService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void writeLog() {
        // 로그 저장은 별도 트랜잭션으로 커밋됨
    }
}

위 코드는 “전체가 롤백될 줄 알았는데 로그만 남았다” 같은 현상을 만듭니다. 이는 무효화가 아니라 전파 설정의 결과입니다.

점검 체크

  • REQUIRES_NEW를 정말 의도했는지
  • NOT_SUPPORTED가 섞여 있지 않은지
  • 배치, 이벤트 핸들러, 로깅 컴포넌트에 전파 옵션이 과하게 붙지 않았는지

6) @Async, 스레드 경계, 이벤트 리스너로 트랜잭션 컨텍스트가 끊김

트랜잭션은 스레드 로컬 기반으로 바인딩됩니다. 따라서 다른 스레드에서 실행되면 같은 트랜잭션 컨텍스트를 공유하지 않습니다.

@Async 재현

@Service
@RequiredArgsConstructor
public class MailService {

    @Async
    public void sendMail() {
        // 여기에는 호출한 쪽 트랜잭션이 전달되지 않음
    }
}

@Service
@RequiredArgsConstructor
public class UserService {
    private final MailService mailService;

    @Transactional
    public void register() {
        // 사용자 저장
        mailService.sendMail();
        // 예외가 나도 sendMail은 이미 별도 스레드에서 실행 중
    }
}

이벤트 리스너도 유사

  • @EventListener는 기본적으로 즉시 실행되며, 트랜잭션 경계와 맞물리면 “언제 커밋되었는지”가 헷갈릴 수 있습니다.
  • 커밋 이후에 실행해야 한다면 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 고려합니다.
@Component
public class UserEventHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onUserCreated(UserCreatedEvent event) {
        // 커밋 이후 실행
    }
}

7) 트랜잭션 매니저/데이터소스가 다르거나, 읽기 전용과 flush 타이밍 때문에 “안 된 것처럼” 보임

Spring Boot 3에서 멀티 데이터소스, JPA와 JDBC 혼용, 혹은 모듈 분리로 인해 다음 문제가 자주 생깁니다.

  • @Transactional이 기대한 PlatformTransactionManager가 아니라 다른 매니저를 사용
  • JPA는 기본적으로 커밋 시점에 flush 되므로, 중간에 쿼리로 확인하면 아직 반영이 안 된 것처럼 보임
  • readOnly = true에서 쓰기가 실패하거나 flush가 억제되어 동작이 이상해 보임(구현체와 설정에 따라 다름)

멀티 트랜잭션 매니저 예시

@Configuration
public class TxConfig {

    @Bean
    public PlatformTransactionManager primaryTxManager(EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }

    @Bean
    public PlatformTransactionManager otherTxManager(DataSource otherDs) {
        return new DataSourceTransactionManager(otherDs);
    }
}

@Service
public class ReportService {

    // 어떤 매니저를 쓸지 명시하지 않으면 의도와 다르게 묶일 수 있음
    @Transactional("otherTxManager")
    public void writeReport() {
        // otherDs에 대한 트랜잭션
    }
}

flush 타이밍 점검

JPA에서 “저장했는데 DB에서 바로 조회가 안 된다”면 다음을 확인합니다.

  • 같은 트랜잭션 안에서 조회하는지(격리 수준과 1차 캐시 영향)
  • 커밋 전에 DB에서 다른 커넥션으로 조회하고 있는지
  • 필요 시 entityManager.flush()로 강제 flush
@Transactional
public void saveAndCheck(Entity e) {
    repository.save(e);
    entityManager.flush();
}

빠른 진단 체크리스트

아래 순서로 보면 대부분 빠르게 좁혀집니다.

  1. 호출 경로가 프록시를 타는가: 같은 클래스 내부 호출인지 확인
  2. 메서드 시그니처: public인지, final인지, Kotlin 올오픈 적용 여부
  3. 예외 처리: 예외를 잡아먹지 않는지, 체크 예외 롤백 정책이 맞는지
  4. 전파 옵션: REQUIRES_NEW, NOT_SUPPORTED가 섞였는지
  5. 스레드 경계: @Async, 이벤트 리스너, 스케줄러로 분리되지 않았는지
  6. 트랜잭션 매니저: 멀티 데이터소스라면 어떤 매니저가 선택되는지
  7. “안 된 것처럼 보이는” 착시: flush 타이밍, readOnly, 다른 커넥션 조회 여부

로그로 확실히 확인하는 방법

트랜잭션이 실제로 시작되고 커밋 또는 롤백되는지 로그를 켜면 가장 빠릅니다.

logging:
  level:
    org.springframework.transaction: DEBUG
    org.springframework.orm.jpa: DEBUG
    org.hibernate.SQL: DEBUG

로그에서 Creating new transaction 또는 Participating in existing transaction 같은 메시지가 보이는지, 종료 시 Initiating transaction rollback이 찍히는지 확인하세요.

마무리

@Transactional이 무효화되는 대부분의 케이스는 “프록시를 안 탐”, “예외/전파/스레드 경계로 인해 기대와 다르게 동작”으로 수렴합니다. 특히 Spring Boot 3 환경에서 모듈화, 비동기 처리, 멀티 데이터소스가 섞이면 원인이 복합적으로 나타나므로, 위 7가지를 체크리스트처럼 위에서부터 제거해 나가면 진단 시간이 크게 줄어듭니다.

추가로 트랜잭션과 직접 관련은 없지만, 운영 환경에서 데이터 정합성 점검 중 DB가 비정상적으로 느려져 트랜잭션 문제로 오해되는 경우도 있습니다. PostgreSQL을 쓴다면 PostgreSQL VACUUM 안됨? bloat·wraparound 7분 해결처럼 유지보수 이슈도 함께 점검해 두면 좋습니다.