Published on

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

Authors

서버 로그에는 분명 @Transactional이 찍히는데, DB에는 커밋이 되어 있거나(롤백이 안 됨), 아예 트랜잭션이 열리지 않는 경우가 있습니다. Spring Boot 3.2(=Spring Framework 6.x)에서도 원리는 동일합니다. 스프링의 선언적 트랜잭션은 “AOP 프록시를 통한 메서드 호출”이 성립할 때만 적용됩니다.

이 글에서는 Spring Boot 3.2에서 @Transactional이 무시(또는 기대와 다르게 동작)되는 대표적인 7가지 케이스를, 원인 → 재현 코드 → 해결 순서로 정리합니다.

> 운영 장애를 추적할 때는 “애플리케이션 레벨 원인”과 “인프라 레벨 원인”을 분리하는 게 중요합니다. 인프라 관점의 트러블슈팅은 예를 들어 EKS Ingress 502인데 Pod 로그가 비면? ALB/NLB 헬스체크부터 같은 체크리스트가 도움이 됩니다.

1) 같은 클래스 내부 호출(Self-invocation)

증상

  • service.outer() 안에서 this.inner()를 호출했는데 inner()@Transactional이 적용되지 않음
  • 로그로 보면 inner()가 실행되지만 트랜잭션 경계가 생기지 않음

원인

스프링 트랜잭션은 프록시가 가로채는 방식입니다. 같은 객체 내부에서 this로 호출하면 프록시를 거치지 않기 때문에 AOP가 동작하지 않습니다.

재현 코드

@Service
public class OrderService {

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

    @Transactional
    public void inner() {
        // 기대: 트랜잭션 시작
    }
}

해결

  • 트랜잭션 메서드를 별도 빈으로 분리해서 프록시 호출이 되게 하거나
  • (권장도 낮고 복잡도 상승) AopContext.currentProxy() 사용 + exposeProxy=true 설정

권장 패턴(분리):

@Service
public class OrderService {
    private final TxOrderWorker worker;

    public OrderService(TxOrderWorker worker) {
        this.worker = worker;
    }

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

@Service
public class TxOrderWorker {
    @Transactional
    public void inner() {
    }
}

2) final/private/static 메서드에 붙임(프록시가 못 가로챔)

증상

  • 메서드에 @Transactional을 붙였는데 전혀 적용되지 않음

원인

기본 프록시 전략이 JDK 동적 프록시(인터페이스 기반) 또는 CGLIB(클래스 기반) 인데, 어떤 방식이든 공통적으로 가로채기(intercept)가 불가능한 형태가 있습니다.

  • private 메서드: 외부에서 호출 자체가 안 되고 프록시가 끼어들 여지가 없음
  • final 메서드(CGLIB): 오버라이드가 불가해 프록시가 메서드 인터셉트 불가
  • static 메서드: 인스턴스 프록시로 감쌀 대상이 아님

재현 코드

@Service
public class PaymentService {

    @Transactional
    private void payInternal() { // 적용 안 됨
    }

    @Transactional
    public final void payFinal() { // CGLIB에서도 적용 어려움
    }

    @Transactional
    public static void payStatic() { // 적용 안 됨
    }
}

해결

  • public(또는 최소 protected) 인스턴스 메서드로 노출
  • 트랜잭션 경계는 “외부에서 호출되는 서비스 계층 public 메서드”에 두는 것이 정석

3) Bean이 아닌 객체에서 호출(스프링 컨테이너 밖)

증상

  • new SomeService()로 만든 객체에서 @Transactional이 동작하지 않음
  • 유틸/헬퍼 클래스로 분리했는데 트랜잭션이 사라짐

원인

@Transactional은 스프링 컨테이너가 만든 빈에 프록시를 씌워서 동작합니다. 즉, 스프링이 관리하지 않는 객체는 AOP 대상이 아닙니다.

재현 코드

public class ManualCaller {
    public void run() {
        OrderService service = new OrderService(); // 스프링 빈 아님
        service.placeOrder(); // @Transactional 무시
    }
}

해결

  • 호출 주체도 스프링 빈으로 만들고 DI로 주입받기
  • 배치/CLI/스케줄러라면 @Component + 주입 구조로 변경

4) 롤백 조건 오해: Checked Exception은 기본 롤백이 아님

증상

  • 예외가 발생했는데도 DB가 커밋됨
  • 특히 IOException, 커스텀 checked exception에서 자주 발생

원인

스프링의 기본 정책은 다음과 같습니다.

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

재현 코드

@Service
public class MemberService {

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

해결

rollbackFor를 명시하거나 예외를 런타임으로 감싸기:

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

또는 도메인 정책상 롤백이 필요한 예외는 RuntimeException 계열로 설계하는 것이 일반적입니다.

5) 예외를 잡아먹고 정상 종료(커밋됨)

증상

  • 내부에서 에러가 났는데 메서드가 정상 리턴
  • 결과적으로 트랜잭션이 커밋됨

원인

트랜잭션 롤백은 보통 “메서드 밖으로 예외가 전파”될 때 트리거됩니다. try/catch로 예외를 삼키면 스프링은 정상 종료로 판단하고 커밋합니다.

재현 코드

@Transactional
public void updateProfile() {
    try {
        // update...
        throw new IllegalStateException("boom");
    } catch (Exception e) {
        // 로깅만 하고 끝내면 -> 커밋
    }
}

해결

  • 예외를 다시 던지기
  • 또는 명시적으로 rollback-only 설정
@Transactional
public void updateProfile() {
    try {
        throw new IllegalStateException("boom");
    } catch (Exception e) {
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        throw e; // 보통은 재던짐이 더 명확
    }
}

6) 전파(Propagation) 설정으로 인해 “트랜잭션이 없는 것처럼” 보임

증상

  • 분명 @Transactional인데도 일부 작업이 롤백되지 않음
  • 혹은 외부 트랜잭션과 분리되어 커밋/롤백 타이밍이 예상과 다름

원인

전파 옵션은 트랜잭션 경계를 크게 바꿉니다.

  • REQUIRES_NEW: 기존 트랜잭션을 suspend하고 새 트랜잭션을 독립적으로 시작
  • NOT_SUPPORTED: 트랜잭션 없이 실행
  • SUPPORTS: 있으면 참여, 없으면 비트랜잭션

재현 코드(롤백이 “안 되는 것처럼” 보이는 대표 케이스)

@Service
public class OuterService {
    private final InnerService inner;

    public OuterService(InnerService inner) {
        this.inner = inner;
    }

    @Transactional
    public void outer() {
        inner.audit(); // REQUIRES_NEW로 이미 커밋
        throw new RuntimeException("fail outer");
    }
}

@Service
public class InnerService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void audit() {
        // 감사 로그 insert -> outer가 실패해도 커밋됨
    }
}

해결

  • “같이 롤백되어야 하는 작업”이면 REQUIRED(기본값)로 맞추기
  • 감사/아웃박스/알림처럼 독립 커밋이 필요한 경우에만 REQUIRES_NEW 사용
  • NOT_SUPPORTED/SUPPORTS는 의도적으로 비트랜잭션이 필요할 때만 선택

7) 테스트/비동기 환경에서의 착시: @Transactional이 적용되었지만 기대와 다름

7-1) @SpringBootTest + 테스트 트랜잭션 롤백

증상

  • 서비스에서는 커밋될 것 같은데 테스트에서는 데이터가 남지 않음

원인

스프링 테스트는 기본적으로 테스트 메서드에 @Transactional이 있으면 테스트 종료 시 롤백합니다. 서비스 레벨에서 커밋이 일어나도, 바깥 테스트 트랜잭션이 롤백하면 최종적으로 데이터가 사라집니다.

해결

  • 커밋을 관찰하려면 @Commit 또는 @Rollback(false)
@SpringBootTest
class MemberServiceTest {

    @Test
    @Transactional
    @Commit
    void commit_for_real() {
        // ...
    }
}

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

증상

  • 호출자는 @Transactional인데 비동기 작업에서는 트랜잭션이 없는 것처럼 동작

원인

트랜잭션 컨텍스트는 보통 ThreadLocal 기반입니다. 스레드가 바뀌면 트랜잭션도 같이 전달되지 않습니다.

재현 코드

@Service
public class AsyncService {

    @Async
    public void asyncWork() {
        // 여기서는 호출자의 트랜잭션이 이어지지 않음
    }
}

@Service
public class FacadeService {
    private final AsyncService asyncService;

    public FacadeService(AsyncService asyncService) {
        this.asyncService = asyncService;
    }

    @Transactional
    public void doWork() {
        asyncService.asyncWork();
    }
}

해결

  • 비동기 메서드 자체에 별도의 @Transactional을 부여(필요 시)
  • 혹은 이벤트/아웃박스 패턴으로 트랜잭션 커밋 이후 처리로 분리

빠른 점검 체크리스트

  • 호출이 프록시를 경유하는가? (self-invocation 아님)
  • 메서드가 public 인스턴스 메서드인가? (private/final/static 아님)
  • 객체가 스프링 빈인가? (new로 만들지 않음)
  • 롤백이 필요한 예외가 RuntimeException인가? 아니라면 rollbackFor 지정했는가?
  • 예외를 catch로 삼키지 않았는가?
  • Propagation이 의도와 맞는가? (REQUIRES_NEW, NOT_SUPPORTED 주의)
  • 테스트/비동기에서 “트랜잭션이 끊기는 지점”이 있는가?

트랜잭션 이슈를 운영에서 확인하는 방법(로그/관측)

  • org.springframework.transaction / org.hibernate.SQL 로그 레벨을 일시적으로 올려 트랜잭션 begin/commit/rollback을 확인
  • 커넥션 풀(HikariCP) 메트릭에서 active/idle 변화와 함께 트랜잭션 경계 추적
  • 장애 상황에서 애플리케이션 로그가 비거나 요청이 중간에서 끊긴다면, 트랜잭션 이전에 인프라에서 막히는 경우도 많습니다. 이때는 EKS ALB Ingress 404 고정 - 10분 규칙·TG 진단처럼 L7/L4부터 분리 진단하는 편이 빠릅니다.

마무리

Spring Boot 3.2에서 @Transactional이 “무시되는 것처럼 보이는” 대부분의 문제는, 실제로는 프록시 호출 조건 불충족(self-invocation/비빈 객체/메서드 시그니처) 또는 롤백 규칙 오해(checked exception, catch로 삼킴), 전파/스레드 경계(REQUIRES_NEW, @Async, 테스트 롤백)로 설명됩니다.

다음에 트랜잭션이 의심되면, 먼저 “이 호출이 프록시를 타는가?”와 “예외가 밖으로 나가는가?” 두 가지만 확인해도 원인의 절반 이상이 바로 좁혀집니다.