Published on

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

Authors

서버 장애처럼 보이는 데이터 정합성 문제의 상당수는 “트랜잭션이 당연히 걸렸을 것”이라는 가정에서 시작합니다. Spring Boot 3(Spring Framework 6)에서도 @Transactional은 여전히 강력하지만, 프록시 기반 AOP라는 전제 때문에 특정 조건에서 조용히 무시되거나(정확히는 트랜잭션 경계가 생성되지 않거나) 롤백이 기대와 다르게 동작할 수 있습니다.

이 글에서는 실무에서 가장 자주 만나고, Spring Boot 3로 오면서(또는 Jakarta 전환 이후) 더 헷갈리기 쉬운 7가지 패턴을 재현 코드와 함께 정리합니다. 운영에서 문제가 터졌다면 애플리케이션 로그뿐 아니라 DB/인프라 지표도 같이 봐야 합니다. 예를 들어 DB 커넥션 고갈이나 타임아웃이 겹치면 트랜잭션 문제로 오인하기 쉽습니다. (네트워크 타임아웃 관점은 Python httpx ReadTimeout·ConnectError 재시도 설계도 참고)

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

@Transactional은 기본적으로 프록시가 메서드 호출을 가로채며 트랜잭션을 시작합니다. 그런데 같은 클래스 내부에서 this.someTxMethod()처럼 호출하면 프록시를 거치지 않습니다. 결과적으로 트랜잭션이 시작되지 않아 “무시된 것처럼” 보입니다.

재현 코드

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

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

    @Transactional
    public void saveOrderTx() {
        orderRepository.save(new Order(...));
        throw new RuntimeException("fail");
    }
}

위 코드는 placeOrder()에서 saveOrderTx()를 호출하지만, 실제로는 트랜잭션이 걸리지 않을 수 있습니다(프록시 밖에서 내부 메서드 호출).

해결 방법

  • 트랜잭션 메서드를 다른 빈으로 분리해서 호출
  • 또는 ApplicationContext로 프록시 빈을 주입받아 그 빈을 통해 호출(권장도 낮음)
@Service
public class OrderFacade {
    private final OrderTxService orderTxService;

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

    public void placeOrder() {
        orderTxService.saveOrderTx();
    }
}

@Service
class OrderTxService {
    private final OrderRepository repo;

    OrderTxService(OrderRepository repo) { this.repo = repo; }

    @Transactional
    public void saveOrderTx() {
        repo.save(new Order(...));
        throw new RuntimeException("fail");
    }
}

2) private/final 메서드에 붙여서 AOP 적용이 안 됨

프록시 기반 AOP는 기본적으로 오버라이드 가능한(public/protected) 메서드를 가로채는 방식입니다. 따라서 다음은 트랜잭션이 적용되지 않을 가능성이 큽니다.

  • private 메서드에 @Transactional
  • final 메서드(또는 final 클래스 + CGLIB 제약)

재현 코드

@Service
public class PaymentService {

    @Transactional
    private void payInternal() {
        // 트랜잭션이 적용되지 않을 수 있음
    }

    public void pay() {
        payInternal();
    }
}

해결 방법

  • 트랜잭션 경계는 public 메서드에 선언
  • 내부 로직은 private로 두되, 트랜잭션 시작은 public 메서드에서
@Service
public class PaymentService {

    @Transactional
    public void pay() {
        payInternal();
    }

    private void payInternal() {
        // 실제 로직
    }
}

3) 예외를 잡아먹어서 롤백이 안 됨(try-catch)

@Transactional이 “무시”된 것처럼 보이는 가장 흔한 이유는 롤백이 안 되기 때문입니다. 트랜잭션은 시작됐지만, 예외를 catch로 삼켜서 정상 종료로 간주되면 커밋됩니다.

재현 코드

@Transactional
public void register() {
    userRepository.save(new User(...));

    try {
        externalCall();
        throw new RuntimeException("boom");
    } catch (Exception e) {
        // 예외를 삼키면 트랜잭션은 정상 종료 -> 커밋될 수 있음
        log.warn("ignored", e);
    }
}

해결 방법

  • 예외를 다시 던지기
  • 또는 명시적으로 rollback-only 마킹
@Transactional
public void register() {
    userRepository.save(new User(...));

    try {
        externalCall();
        throw new RuntimeException("boom");
    } catch (Exception e) {
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        throw e; // 또는 비즈니스 예외로 래핑
    }
}

4) 체크 예외(checked exception)는 기본 롤백 대상이 아님

Spring의 기본 규칙은:

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

그래서 “예외가 났는데도 데이터가 남는다” → @Transactional이 무시된 것처럼 보입니다.

재현 코드

@Transactional
public void importFile() throws IOException {
    repository.save(new Item(...));
    throw new IOException("read failed");
}

해결 방법

  • rollbackFor 지정
@Transactional(rollbackFor = IOException.class)
public void importFile() throws IOException {
    repository.save(new Item(...));
    throw new IOException("read failed");
}
  • 또는 체크 예외를 런타임 예외로 변환(도메인 정책에 따라)

5) 전파(Propagation) 설정 때문에 바깥 트랜잭션에 합류/분리됨

@Transactional이 “안 먹는다”는 말은 사실 내가 기대한 경계가 아니다라는 뜻인 경우가 많습니다. 특히 REQUIRES_NEW, NOT_SUPPORTED, SUPPORTS 같은 전파 옵션이 섞이면 롤백/커밋 단위가 달라집니다.

대표 함정: REQUIRES_NEW가 내부에서 커밋해버림

@Service
public class OuterService {
    private final InnerService innerService;
    private final Repo repo;

    public OuterService(InnerService innerService, Repo repo) {
        this.innerService = innerService;
        this.repo = repo;
    }

    @Transactional
    public void outer() {
        repo.save(new Entity("outer"));
        innerService.inner(); // 여기서 별도 트랜잭션으로 커밋될 수 있음
        throw new RuntimeException("outer fail");
    }
}

@Service
class InnerService {
    private final Repo repo;
    InnerService(Repo repo) { this.repo = repo; }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() {
        repo.save(new Entity("inner"));
    }
}

outer()가 실패해도 inner()는 이미 별도 트랜잭션으로 커밋되어 데이터가 남습니다.

해결 방법

  • 전파 옵션을 의도에 맞게 통일
  • “부분 커밋”이 필요하다면 명확히 설계(보상 트랜잭션/사가)

6) @Async / 다른 스레드로 넘어가며 트랜잭션 컨텍스트가 끊김

Spring 트랜잭션은 기본적으로 ThreadLocal 기반입니다. 즉, 트랜잭션 컨텍스트는 스레드에 묶입니다. @Async, CompletableFuture, 별도 스레드풀로 작업을 넘기면 같은 트랜잭션으로 묶이지 않습니다.

재현 코드

@Service
public class AsyncService {

    private final Repo repo;

    public AsyncService(Repo repo) {
        this.repo = repo;
    }

    @Transactional
    public void doWork() {
        repo.save(new Entity("before"));
        runAsync();
        throw new RuntimeException("fail");
    }

    @Async
    public void runAsync() {
        // 별도 스레드: doWork의 트랜잭션과 무관
        repo.save(new Entity("async"));
    }
}

결과:

  • doWork()는 롤백될 수 있지만
  • runAsync()는 별도 트랜잭션/auto-commit로 저장되어 남을 수 있음

해결 방법

  • 비동기 작업을 트랜잭션 밖으로 분리하고, 이벤트/메시지로 일관성 설계
  • 정말 필요하다면 비동기 메서드에도 트랜잭션을 명시(단, 같은 트랜잭션 공유는 아님)

7) 트랜잭션 매니저/데이터소스가 다르거나, 읽기 전용(readOnly) 최적화에 발목

7-1) 여러 DataSource에서 다른 TransactionManager를 타는 경우

멀티 데이터소스 환경에서 @Transactional이 기본 매니저를 사용해 다른 DB에만 적용되거나, 실제로 쓰는 리포지토리의 매니저와 불일치하면 트랜잭션이 기대와 다르게 보입니다.

  • 어떤 PlatformTransactionManager가 기본인지
  • @Transactional(transactionManager = "...")를 지정했는지
  • JPA 리포지토리가 어떤 EntityManagerFactory에 묶였는지

를 함께 점검해야 합니다.

@Transactional(transactionManager = "orderTxManager")
public void createOrder() {
    orderRepo.save(...);
}

7-2) readOnly=true가 쓰기를 막거나 플러시 타이밍을 바꿈

@Transactional(readOnly = true)는 DB에 따라 강제는 아니지만, JPA/Hibernate 쪽에서 flush 전략 최적화가 걸려 쓰기/변경 감지가 기대대로 되지 않는 상황을 만들 수 있습니다.

@Transactional(readOnly = true)
public void updateUserName(Long id, String name) {
    User u = userRepo.findById(id).orElseThrow();
    u.setName(name); // 변경해도 flush가 안 되거나 예외/무시처럼 보일 수 있음
}

해결은 단순합니다.

  • 조회 전용 메서드와 갱신 메서드를 분리
  • 갱신 메서드는 readOnly=false(기본값)로 유지

운영에서 “정말 트랜잭션이 시작됐는지” 확인하는 방법

원인 추정을 코드만으로 끝내면 위험합니다. 아래처럼 로그/설정으로 트랜잭션 경계를 눈으로 확인하면 빠르게 좁힐 수 있습니다.

1) 트랜잭션 디버그 로그

application.yml 예시:

logging:
  level:
    org.springframework.transaction: TRACE
    org.springframework.orm.jpa: DEBUG
    org.hibernate.SQL: DEBUG
  • Creating new transaction with name ...
  • Participating in existing transaction
  • Initiating transaction rollback

같은 로그가 실제로 찍히는지 확인합니다.

2) 커넥션/타임아웃 이슈도 함께 배제

트랜잭션이 “무시”된 게 아니라, 커넥션 풀 고갈/DB 응답 지연으로 중간에 실패하면서 애매한 상태가 남는 경우도 많습니다. 컨테이너 환경이라면 OOM/리소스 제한으로 애플리케이션이 재시작되며 미완료 작업이 반복될 수도 있습니다. 이런 경우는 트랜잭션 자체보다 런타임 안정성 점검이 먼저입니다. (리소스 관점은 EKS Pod OOMKilled 반복 원인과 메모리·GC·Limit 튜닝 참고)

체크리스트: @Transactional이 무시되는지 30초 점검

  • 트랜잭션 메서드가 public이고 프록시를 통해 호출되는가? (자가호출 아님)
  • private/final에 붙이지 않았는가?
  • 예외를 catch로 삼키지 않았는가?
  • 체크 예외라면 rollbackFor가 필요한가?
  • 전파 옵션(REQUIRES_NEW 등)이 의도와 맞는가?
  • @Async/다른 스레드로 넘어가며 경계가 끊기지 않는가?
  • 올바른 TransactionManager/DataSource를 쓰는가? readOnly는 적절한가?

마무리

Spring Boot 3에서 @Transactional이 “무시”되는 대부분의 사례는 프레임워크 버그가 아니라 프록시 기반 AOP의 호출 경로, 예외/전파 규칙, 스레드 경계에서 발생합니다. 위 7가지를 재현 코드로 하나씩 제거해 나가면, 트랜잭션이 실제로 시작됐는지/왜 롤백이 안 됐는지를 빠르게 특정할 수 있습니다.

다음 단계로는 @DataJpaTest 또는 통합 테스트에서 롤백/커밋 결과를 검증하는 테스트를 추가해, 리팩터링이나 비동기 도입 시에도 트랜잭션 경계가 깨지지 않도록 안전망을 만드는 것을 권장합니다.