Published on

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

Authors

서버에서 분명 @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(클래스 기반)입니다. 일반적으로 @Transactionalpublic 메서드에 붙이는 것을 전제로 하며, 다음과 같은 경우 적용이 기대대로 되지 않을 수 있습니다.

  • private 메서드에 @Transactional
  • final 메서드/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. 트랜잭션 메서드를 같은 클래스에서 내부 호출하고 있나? → 1번
  2. private/final 메서드, Kotlin final 기본값, 프록시가 못 붙는 구조인가? → 2번
  3. 예외를 catch해서 삼키거나 checked exception을 던지나? → 3번
  4. @Async/새 스레드/CompletableFuture로 트랜잭션 밖에서 실행되나? → 4번
  5. REQUIRES_NEW/NOT_SUPPORTED 등 전파 설정을 썼나? → 5번
  6. 테스트에서 빈을 new로 만들었거나, 멀티 TM에서 다른 매니저가 잡히나? → 6번
  7. 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분 내로 원인을 좁힐 수 있고, 설계(빈 분리/전파 정책/비동기 분리)까지 정리하면 재발도 크게 줄어듭니다.