Published on

Spring Boot 3에서 @Transactional 먹통 원인 7가지

Authors

서버에서 분명 @Transactional을 붙였는데도 데이터가 커밋되거나(롤백이 안 됨), 반대로 기대한 격리/락이 전혀 걸리지 않는 것처럼 보이는 순간이 있습니다. Spring Boot 3는 기본적으로 프록시 기반 AOP로 트랜잭션을 적용하므로, 프록시가 끼어들지 못하는 호출 경로가 생기거나 예외/전파 설정이 의도와 다르면 쉽게 “먹통”처럼 보입니다.

이 글은 Spring Boot 3 기준으로 @Transactional이 적용되지 않는 대표 원인 7가지를 재현 가능한 코드와 함께 정리합니다. 운영 장애로 번지기 쉬운 패턴 위주로 다룹니다.

0. 먼저 확인할 기본 전제 3가지

아래 3가지는 원인 7가지로 들어가기 전에 “진짜로 트랜잭션이 시작됐는지”를 빠르게 가늠하는 체크리스트입니다.

  1. 트랜잭션 매니저가 존재하는가
  • JPA면 보통 JpaTransactionManager가 자동 구성됩니다.
  • JDBC면 DataSourceTransactionManager가 필요합니다.
  1. 로그로 트랜잭션 시작/종료가 찍히는가
# application.properties
logging.level.org.springframework.transaction=TRACE
logging.level.org.springframework.orm.jpa=DEBUG
  1. 실제 DB 커넥션이 같은지(동일 트랜잭션인지)
  • 같은 트랜잭션이면 같은 커넥션을 재사용하는 경우가 많습니다.
  • JPA에서는 EntityManager의 flush 시점/영속성 컨텍스트 때문에 “커밋된 것처럼 보이는” 착시가 생길 수 있어, SQL 로그와 함께 봐야 합니다.

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

가장 흔한 원인입니다. Spring의 @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 OrderEntity("A"));
        throw new RuntimeException("fail");
    }
}

위 코드는 saveOrderTx()@Transactional이 있어도 롤백이 안 되는 것처럼 보일 수 있습니다(실제로는 트랜잭션이 시작되지 않았을 가능성이 큼).

해결

  • 트랜잭션 메서드를 별도 빈으로 분리해서 “다른 빈 호출”로 만들기
  • 또는 트랜잭션이 필요한 메서드를 외부에서 직접 호출되도록 설계
@Service
public class OrderTxService {
    private final OrderRepository orderRepository;

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

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

@Service
public class OrderService {
    private final OrderTxService orderTxService;

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

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

2) final 클래스/메서드, private 메서드에 붙여서 프록시 적용이 안 됨

프록시 방식은 “가로채기”가 가능해야 합니다.

  • private 메서드: 외부에서 호출될 수 없으니 프록시가 개입할 지점이 없음
  • final 메서드/클래스: CGLIB 기반 프록시가 오버라이드할 수 없어 적용이 제한될 수 있음

흔한 실수

@Service
public class PaymentService {

    @Transactional
    private void payInternal() {
        // 트랜잭션 기대하지만 적용되지 않음
    }
}

해결

  • @Transactional은 보통 public 메서드에 붙이세요.
  • Kotlin을 쓰면 기본이 final이라 더 자주 터집니다. Kotlin에서는 kotlin-spring 플러그인(올오픈) 적용이 사실상 필수입니다.
plugins {
    kotlin("jvm")
    kotlin("plugin.spring")
}

3) 예외 타입 때문에 롤백이 안 됨(Checked Exception)

Spring의 기본 롤백 규칙은 다음과 같습니다.

  • RuntimeExceptionError는 롤백
  • Checked Exception은 기본적으로 롤백하지 않음

즉, 비즈니스 예외를 Checked Exception으로 던지면 “왜 커밋됐지”가 됩니다.

재현 코드

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

해결

  • Checked Exception에도 롤백하려면 rollbackFor를 지정합니다.
@Transactional(rollbackFor = Exception.class)
public void updateProfile() throws Exception {
    throw new Exception("checked exception");
}
  • 반대로 특정 런타임 예외는 롤백하지 않게 하려면 noRollbackFor를 사용합니다.
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void doSomething() {
    throw new IllegalArgumentException("no rollback");
}

4) 전파(propagation) 설정으로 “트랜잭션이 없는 것처럼” 보임

전파 옵션을 잘못 쓰면 트랜잭션 경계가 예상과 달라집니다.

REQUIRES_NEW 남발로 바깥 롤백과 분리됨

바깥 트랜잭션이 롤백되어도, 내부 REQUIRES_NEW는 이미 커밋되어 데이터가 남습니다. 이걸 보고 @Transactional이 먹통이라고 오해하기 쉽습니다.

@Service
public class OuterService {

    private final InnerService innerService;

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

    @Transactional
    public void outer() {
        innerService.innerRequiresNew();
        throw new RuntimeException("outer failed");
    }
}

@Service
public class InnerService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void innerRequiresNew() {
        // 여기는 별도 트랜잭션으로 커밋됨
    }
}

NOT_SUPPORTEDNEVER로 트랜잭션을 끊어버림

  • NOT_SUPPORTED: 현재 트랜잭션이 있으면 중단하고 비트랜잭션으로 실행
  • NEVER: 트랜잭션이 있으면 예외

특히 공통 유틸/로깅/감사(Audit) 코드에 이런 옵션이 붙어 있으면 예상치 못한 경계가 생깁니다.

해결

  • 전파 옵션은 “기술적 이유”가 명확할 때만 사용하고, 기본은 REQUIRED로 두는 게 안전합니다.
  • “부분 커밋”이 필요하다면, 왜 필요한지(예: 감사 로그는 반드시 남겨야 함)를 문서화하고 테스트로 고정하세요.

보상 트랜잭션이 필요한 케이스라면 DB 트랜잭션만으로 해결하려다 더 꼬일 수 있습니다. 분산/비동기 플로우라면 Saga 보상트랜잭션 설계 - 중복·순서꼬임 해결를 참고해 설계를 분리하는 편이 낫습니다.

5) 비동기/스레드 경계(@Async, 스케줄러, 이벤트)로 트랜잭션 컨텍스트가 전달되지 않음

Spring 트랜잭션은 기본적으로 스레드 로컬 기반입니다. 즉, 다른 스레드로 넘어가면 같은 트랜잭션이 이어지지 않습니다.

@Async 재현

@Service
public class EmailService {

    @Async
    public void sendAsync() {
        // 여기에는 호출자 트랜잭션이 없음
    }
}

@Service
public class UserService {

    private final EmailService emailService;

    public UserService(EmailService emailService) {
        this.emailService = emailService;
    }

    @Transactional
    public void register() {
        // DB 저장
        emailService.sendAsync();
        // 여기서 예외가 나서 롤백돼도, async는 별개로 실행될 수 있음
    }
}

이벤트 리스너도 마찬가지

  • @EventListener는 기본적으로 같은 스레드에서 실행될 수 있지만, 트랜잭션 커밋 전/후 타이밍이 중요합니다.
  • 커밋 이후에만 실행되어야 하면 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 고려하세요.
@Component
public class UserEventHandler {

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

해결

  • “같은 트랜잭션에서 처리돼야 하는 일”은 비동기로 보내지 말고 동기 흐름에 두기
  • 비동기가 필요하면, 트랜잭션 경계 밖에서 메시지 발행(Outbox 패턴 등)으로 일관성을 설계하기

6) 테스트 환경에서 롤백/커밋 동작을 착각함(@SpringBootTest, @DataJpaTest)

테스트에서는 프레임워크가 기본 롤백을 걸어두는 경우가 많습니다.

  • @DataJpaTest는 기본적으로 테스트 종료 시 롤백
  • @Transactional이 테스트 메서드/클래스에 붙어 있으면 테스트도 트랜잭션으로 감싸짐

이때 “서비스의 @Transactional이 안 먹는다”가 아니라 “테스트 트랜잭션이 바깥에서 감싸고 있어서 전파가 다르게 보이는” 상황이 흔합니다.

재현 포인트

  • 테스트에서 REQUIRES_NEW를 호출하면 실제로 커밋이 일어나 DB에 남는지
  • @Commit 혹은 @Rollback(false)를 붙였을 때 결과가 달라지는지
@SpringBootTest
@Transactional
class UserServiceTest {

    @Test
    void testSomething() {
        // 이 테스트 자체가 트랜잭션
    }
}

해결

  • 트랜잭션 경계를 검증하는 테스트는 @Transactional을 테스트에 붙이지 말고, 필요한 경우에만 명시
  • 커밋 여부를 확인해야 하면 @Commit을 사용
@Test
@Commit
void shouldCommitForVerification() {
    // 커밋 확인용
}

7) 트랜잭션은 시작됐지만 “쓰기 지연/flush 타이밍” 때문에 먹통처럼 보임(JPA)

JPA는 변경 감지와 쓰기 지연으로 인해 SQL이 즉시 나가지 않을 수 있습니다. 이때 다음과 같은 착시가 생깁니다.

  • “예외가 났는데도 DB에 반영된 것처럼 보인다”
  • “락이 걸렸어야 하는데 안 걸린 것 같다”

실제로는

  • 같은 트랜잭션 안에서 영속성 컨텍스트에만 반영됐고, 커밋 시점에 flush되며 SQL이 실행
  • 혹은 조회가 1차 캐시에서 나가 DB를 다시 보지 않음

재현 예시: flush 시점 확인

@Transactional
public void updateAndFail(EntityManager em) {
    User user = em.find(User.class, 1L);
    user.setName("changed");

    // 여기서 DB에 SQL이 안 나갈 수 있음

    // 강제로 flush하면 여기서 SQL 실행
    em.flush();

    throw new RuntimeException("fail");
}

위 코드는 flush()로 인해 SQL이 실행되지만, 트랜잭션이 롤백되면 DB 최종 상태는 원복되어야 정상입니다. 다만 로그/모니터링에서 SQL이 찍히는 걸 보고 “커밋됐다”고 오해할 수 있습니다.

해결

  • “DB에 실제로 반영됐는지”는 커밋 이후 다른 트랜잭션/다른 커넥션에서 재조회로 확인
  • 운영에서 혼선을 줄이려면 SQL 로그뿐 아니라 트랜잭션 커밋/롤백 로그도 함께 보세요.

빠른 진단 체크리스트(운영/로컬 공통)

  1. 호출 경로가 프록시를 타는가
  • 컨트롤러 -> 서비스 -> 리포지토리처럼 “빈 간 호출”인지 확인(화살표는 ->처럼 인라인 코드로 표기)
  • 같은 클래스 내부 호출이면 1번 원인 가능성 큼
  1. 메서드 시그니처가 프록시 친화적인가
  • public인지
  • Kotlin/final 이슈 없는지
  1. 예외가 런타임 예외인지, rollbackFor가 필요한지

  2. 전파 옵션이 기본(REQUIRED)인지

  • REQUIRES_NEW로 부분 커밋 의도가 있는지 재점검
  1. 스레드 경계를 넘는지
  • @Async, 스케줄러, 메시지 리스너, 이벤트 리스너
  1. 테스트가 바깥에서 트랜잭션을 감싸고 있는지

  2. JPA flush/캐시 착시를 배제했는지

마무리: “먹통”은 대부분 경계 문제다

Spring Boot 3에서 @Transactional이 동작하지 않는 것처럼 보일 때, 실제로는

  • 프록시를 우회했거나
  • 트랜잭션 전파/예외 규칙을 잘못 이해했거나
  • 스레드/이벤트로 경계를 넘었거나
  • JPA의 flush/캐시로 관찰이 왜곡된 경우

가 대부분입니다.

장애 상황에서는 트랜잭션 로그를 켜고, “어떤 메서드 호출이 프록시를 탔는지”와 “커밋/롤백이 실제로 언제 일어났는지”를 먼저 확정하세요. 그 다음 위 7가지를 위에서부터 체크하면 원인에 빠르게 수렴합니다.