Published on

Spring Boot 3에서 @Transactional이 안먹는 6가지

Authors

서버에서 데이터가 일부만 저장되거나, 예외가 났는데도 커밋이 되어버리거나, 반대로 분명 저장했는데 롤백되어 사라지는 상황을 겪으면 가장 먼저 @Transactional을 의심하게 됩니다. 하지만 Spring Boot 3에서도 트랜잭션은 여전히 프록시 기반 AOP로 동작하기 때문에, 특정 조건에서는 “어노테이션이 붙어 있는데도” 트랜잭션이 시작되지 않거나 롤백 규칙이 기대와 달라질 수 있습니다.

이 글에서는 Spring Boot 3에서 @Transactional이 안 먹는 것처럼 보이는 대표적인 6가지 케이스를, 재현 코드와 함께 원인과 해결책을 정리합니다.

참고로 트랜잭션 이슈는 DB 락/데드락 문제로 이어지는 경우가 많습니다. 증상이 락 대기 폭증으로 나타난다면 PostgreSQL 락 대기 폭증? deadlock 진단·해결도 함께 확인해보세요.

1) 자기 자신 메서드 호출(Self-invocation)로 프록시를 우회함

Spring의 선언적 트랜잭션은 기본적으로 “빈을 감싼 프록시”가 메서드 호출을 가로채며 시작됩니다. 그런데 같은 클래스 내부에서 this.someMethod() 형태로 호출하면 프록시를 거치지 않으므로 트랜잭션이 적용되지 않습니다.

재현 코드

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public void placeOrder() {
        // 같은 클래스 내부 호출: 프록시를 안 타서 @Transactional이 무시될 수 있음
        saveOrderTx();
    }

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

위 코드는 placeOrder()에서 saveOrderTx()를 호출하지만, 호출 경로가 프록시를 거치지 않으면 트랜잭션이 시작되지 않아 롤백이 기대대로 동작하지 않을 수 있습니다.

해결 방법

  • 트랜잭션 경계를 “외부에서 호출되는 public 메서드”에 두기
  • 트랜잭션 메서드를 다른 빈으로 분리하기
@Service
@RequiredArgsConstructor
public class OrderTxService {
    private final OrderRepository orderRepository;

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

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderTxService orderTxService;

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

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

Spring AOP 프록시는 기본적으로 메서드 호출을 가로채는 방식이라, 다음과 같은 경우 트랜잭션 적용이 제한될 수 있습니다.

  • private 메서드
  • final 메서드
  • (클래스 기반 프록시일 때) final 클래스

재현 코드

@Service
public class PaymentService {

    public void pay() {
        payInternal();
    }

    @Transactional
    private void payInternal() {
        // private: 프록시가 가로채기 어려움
    }
}

해결 방법

  • 트랜잭션 메서드는 public으로 두고, 외부 빈 호출 경로를 확보하기
  • 구조적으로 “트랜잭션이 필요한 유스케이스 단위”로 서비스 레이어를 구성하기

3) 롤백 규칙 오해: Checked Exception은 기본 롤백 대상이 아님

@Transactional은 기본적으로 RuntimeExceptionError에 대해서만 롤백합니다. 즉, Exception 같은 checked exception을 던지면 “예외가 났는데도 커밋”되는 것처럼 보일 수 있습니다.

재현 코드

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional
    public void register() throws Exception {
        memberRepository.save(new Member("kim"));
        throw new Exception("checked exception");
    }
}

해결 방법

  • rollbackFor를 명시
@Transactional(rollbackFor = Exception.class)
public void register() throws Exception {
    memberRepository.save(new Member("kim"));
    throw new Exception("checked exception");
}
  • 혹은 도메인 정책상 롤백이 필요한 예외는 RuntimeException 계열로 설계

추가로 try-catch로 예외를 잡아먹고 정상 종료하면 트랜잭션은 커밋됩니다. “로그만 찍고 삼켰는데 롤백이 안 됨”은 정상 동작입니다.

4) 전파 옵션 오해: REQUIRES_NEW/NESTED/NOT_SUPPORTED로 경계가 갈라짐

트랜잭션이 “안 먹는 것처럼” 보이는 흔한 이유는 실제로는 트랜잭션이 존재하지만, 전파(propagation) 때문에 예상과 다른 경계로 실행되기 때문입니다.

대표적인 함정

  • REQUIRES_NEW: 바깥 트랜잭션과 독립적으로 커밋/롤백
  • NOT_SUPPORTED: 트랜잭션을 중단하고 비트랜잭션으로 실행
  • NESTED: DB와 드라이버, JPA 설정에 따라 동작이 달라 체감이 다를 수 있음

재현 코드: REQUIRES_NEW로 일부만 커밋

@Service
@RequiredArgsConstructor
public class OrderService {

    private final AuditService auditService;
    private final OrderRepository orderRepository;

    @Transactional
    public void place() {
        orderRepository.save(new Order("A"));
        auditService.write("order placed"); // 별도 트랜잭션
        throw new IllegalStateException("fail");
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void write(String msg) {
        // 여기는 바깥이 실패해도 커밋될 수 있음
    }
}

이 경우 “분명 전체가 롤백돼야 하는데 감사 로그만 남음” 같은 현상이 발생합니다. 의도였다면 괜찮지만, 의도하지 않았다면 전파 옵션을 재검토해야 합니다.

5) 비동기 실행(@Async, 스케줄러, 이벤트 리스너)에서 트랜잭션 컨텍스트가 끊김

트랜잭션은 기본적으로 스레드 바운드(Thread-bound) 입니다. 즉, 같은 스레드에서 실행되는 작업에만 동일한 트랜잭션 컨텍스트가 전파됩니다.

따라서 아래와 같은 경우 “트랜잭션이 분명히 걸렸는데” DB 작업은 다른 스레드에서 실행되어 트랜잭션 밖에서 동작할 수 있습니다.

  • @Async
  • @Scheduled
  • @TransactionalEventListener 사용 시 AFTER_COMMIT 등 페이즈

재현 코드

@Service
@RequiredArgsConstructor
public class ReportService {

    private final ReportRepository reportRepository;

    @Transactional
    public void generate() {
        saveAsync();
        throw new IllegalStateException("fail");
    }

    @Async
    public void saveAsync() {
        // 다른 스레드에서 실행: 위 트랜잭션과 무관
        reportRepository.save(new Report("R1"));
    }
}

해결 방법

  • 비동기 메서드 자체에 별도의 @Transactional을 명시하고, “독립 트랜잭션”임을 명확히 설계
  • 같은 트랜잭션이 필요하다면 비동기 처리를 피하고, 트랜잭션 경계 안에서 동기 처리
  • 이벤트 기반이라면 @TransactionalEventListener의 phase를 의도에 맞게 선택

가상 스레드를 사용하는 경우에도 핵심은 동일합니다. 트랜잭션은 논리적으로 스레드 로컬에 매달리므로, 실행 모델을 바꿀 때 JDBC 지연/커넥션 사용 패턴도 함께 점검해야 합니다. 관련해서는 Spring Boot 3 가상 스레드에서 JDBC 지연 줄이기를 참고하면 좋습니다.

6) 테스트 환경에서의 착시: 테스트 트랜잭션 롤백과 플러시 타이밍

Spring 테스트에서는 다음 조합 때문에 “저장이 안 됐다”, “롤백이 안 됐다”처럼 보이는 착시가 자주 생깁니다.

  • @SpringBootTest 또는 @DataJpaTest에서 기본적으로 테스트 메서드가 트랜잭션으로 감싸지고, 테스트 종료 시 롤백
  • JPA는 커밋 전까지 SQL이 실제로 나가지 않을 수 있음(쓰기 지연). flush() 전에는 DB에서 조회가 안 되는 것처럼 보일 수 있음

재현 코드

@SpringBootTest
class MemberServiceTest {

    @Autowired MemberRepository memberRepository;

    @Test
    void save_seems_not_persisted() {
        memberRepository.save(new Member("kim"));
        // 테스트 끝나면 롤백되므로 DB에 남지 않음
    }
}

해결 방법

  • 테스트에서 실제 커밋을 보고 싶다면 @Commit 또는 @Rollback(false) 사용
  • SQL이 언제 실행되는지 확인하려면 saveAndFlush() 또는 entityManager.flush()를 사용
@Test
@Rollback(false)
void save_and_commit_for_debug() {
    memberRepository.saveAndFlush(new Member("kim"));
}

또한 “락이 걸린 것 같다” 같은 증상이 테스트에서만 보인다면, 같은 DB를 여러 테스트가 공유하면서 트랜잭션이 겹쳐 락 대기가 발생하는 경우도 많습니다. 이때는 앞서 언급한 락/데드락 진단 글이 도움이 됩니다.

디버깅 체크리스트: 진짜로 트랜잭션이 시작됐는지 확인

원인을 좁히려면 먼저 “트랜잭션이 열렸는지”를 관측해야 합니다.

1) 로그로 트랜잭션 경계 확인

application.yml 예시입니다.

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

2) 코드로 현재 트랜잭션 활성 여부 확인

import org.springframework.transaction.support.TransactionSynchronizationManager;

boolean active = TransactionSynchronizationManager.isActualTransactionActive();

이 값이 false라면 프록시 우회(자기 호출), 접근 제어자 문제, 빈 등록 문제 등 “AOP 자체가 안 탄” 케이스를 의심하는 게 빠릅니다.

마무리: “안 먹는다”는 대부분 경계/호출/예외의 문제

Spring Boot 3에서 @Transactional이 안 먹는 것처럼 보이는 상황은 대체로 다음 중 하나로 귀결됩니다.

  • 프록시를 안 타는 호출 경로(자기 호출, private, final)
  • 롤백 규칙(checked exception, 예외를 삼킴)
  • 전파 옵션으로 트랜잭션 경계가 분리됨
  • 비동기/스케줄링으로 스레드가 바뀌며 컨텍스트가 끊김
  • 테스트 트랜잭션과 플러시 타이밍으로 착시 발생

위 6가지를 순서대로 점검하면, “왜 커밋됐지” 혹은 “왜 롤백 안 됐지” 같은 문제를 대부분 빠르게 재현하고 해결할 수 있습니다.