Published on

Spring Boot 3에서 @Transactional 롤백이 안될 때

Authors

서버 운영 중 “예외가 났는데 DB에 데이터가 남아있다”를 마주하면 대부분 @Transactional이 무시되었거나, 롤백 조건이 충족되지 않았거나, 혹은 “커밋된 것처럼 보이는 착시”가 발생한 케이스입니다. Spring Boot 3(Spring Framework 6)에서도 원리는 동일하지만, 비동기/가상 스레드 도입, AOP 프록시 경계에 대한 오해, 예외 처리 습관 때문에 문제가 더 자주 드러납니다.

이 글은 Spring Boot 3에서 @Transactional 롤백이 안 되는 상황을 재현 가능한 코드로 분류하고, 빠르게 원인을 좁히는 체크리스트를 제공합니다.

먼저 결론: 롤백이 안 되는 5대 원인

  1. 예외 타입 문제: 기본은 RuntimeException/Error만 롤백. 체크 예외는 기본적으로 커밋.
  2. 프록시 미적용: 같은 클래스 내부 호출(셀프 인보케이션), final/private 메서드, 빈이 아닌 객체 호출.
  3. 예외를 잡아먹음: try-catch로 삼켜서 트랜잭션 매니저가 “정상 종료”로 판단.
  4. 전파/트랜잭션 경계 오해: REQUIRES_NEW, 다른 트랜잭션에서 이미 커밋됨.
  5. 스레드 경계가 바뀜: @Async, 별도 스레드, 가상 스레드/스케줄러에서 트랜잭션 컨텍스트가 전달되지 않음.

아래부터는 각 케이스를 “왜 그런지”와 “어떻게 고치는지”를 코드로 설명합니다.

1) 체크 예외(Checked Exception)라서 롤백이 안 됨

Spring의 기본 룰은 간단합니다.

  • RuntimeException 또는 Error 발생 시 롤백
  • 체크 예외(Exception)는 기본적으로 커밋

즉, 다음 코드는 예외가 발생해도 커밋될 수 있습니다.

@Service
public class OrderService {

  private final OrderRepository orderRepository;

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

  @Transactional
  public void placeOrder() throws Exception {
    orderRepository.save(new Order("A-100"));
    throw new Exception("checked exception");
  }
}

해결: rollbackFor 지정

@Transactional(rollbackFor = Exception.class)
public void placeOrder() throws Exception {
  orderRepository.save(new Order("A-100"));
  throw new Exception("checked exception");
}

팁: 커스텀 예외는 런타임으로 설계

비즈니스 예외를 체크 예외로 만들면 트랜잭션 정책이 흔들립니다. 보통은 다음처럼 런타임 예외로 두고, 컨트롤러 어드바이스에서 매핑하는 편이 운영에 안전합니다.

public class BusinessException extends RuntimeException {
  public BusinessException(String message) {
    super(message);
  }
}

2) try-catch로 예외를 먹어버려서 커밋됨

다음은 실무에서 가장 흔한 “롤백 안 됨” 패턴입니다.

@Transactional
public void createUser() {
  userRepository.save(new User("alice"));

  try {
    externalCall();
  } catch (Exception e) {
    // 로그만 남기고 정상 흐름으로 종료
    log.warn("external failed", e);
  }
}

트랜잭션은 메서드가 정상 리턴하면 커밋합니다. 예외를 잡아먹으면 트랜잭션 매니저는 실패를 모릅니다.

해결 A: 예외를 다시 던지기

@Transactional
public void createUser() {
  userRepository.save(new User("alice"));

  try {
    externalCall();
  } catch (Exception e) {
    throw new BusinessException("external failed");
  }
}

해결 B: 명시적으로 롤백 마킹

정말로 예외를 밖으로 던질 수 없는 구조라면(예: 배치에서 계속 진행해야 함) 현재 트랜잭션을 롤백 전용으로 표시할 수 있습니다.

import org.springframework.transaction.interceptor.TransactionAspectSupport;

@Transactional
public void createUser() {
  userRepository.save(new User("alice"));

  try {
    externalCall();
  } catch (Exception e) {
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
  }
}

다만 이 방식은 호출자가 성공으로 오해하기 쉽기 때문에 반환 타입/상태 설계를 함께 정리하는 것이 좋습니다.

3) 같은 클래스 내부 호출(셀프 인보케이션)로 프록시가 안 타는 경우

@Transactional은 보통 AOP 프록시로 동작합니다. 즉, “프록시를 통해 호출될 때만” 트랜잭션이 시작됩니다.

다음 코드는 겉보기엔 트랜잭션이 걸릴 것 같지만, 실제로는 this.inner() 호출이 프록시를 거치지 않아 @Transactional이 적용되지 않습니다.

@Service
public class PaymentService {

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

  @Transactional
  public void inner() {
    paymentRepository.save(new Payment("P-1"));
    throw new RuntimeException("fail");
  }
}

해결 A: 트랜잭션 메서드를 다른 빈으로 분리

@Service
public class PaymentTxService {

  @Transactional
  public void inner() {
    paymentRepository.save(new Payment("P-1"));
    throw new RuntimeException("fail");
  }
}

@Service
public class PaymentService {

  private final PaymentTxService paymentTxService;

  public PaymentService(PaymentTxService paymentTxService) {
    this.paymentTxService = paymentTxService;
  }

  public void outer() {
    paymentTxService.inner();
  }
}

해결 B: 자기 자신 프록시를 주입(권장도 낮음)

순환 참조/구조 복잡도를 올릴 수 있어 보통은 분리를 권장합니다.

4) REQUIRES_NEW 때문에 “일부는 커밋”되는 경우

롤백이 안 된 게 아니라, 이미 다른 트랜잭션에서 커밋된 데이터가 남는 상황입니다.

@Service
public class AuditService {

  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void writeAudit(String msg) {
    auditRepository.save(new AuditLog(msg));
  }
}

@Service
public class OrderService {

  private final AuditService auditService;

  public OrderService(AuditService auditService) {
    this.auditService = auditService;
  }

  @Transactional
  public void placeOrder() {
    orderRepository.save(new Order("O-1"));
    auditService.writeAudit("order placed"); // 별도 트랜잭션으로 커밋
    throw new RuntimeException("fail");
  }
}

이 경우 Order는 롤백되지만 AuditLog는 남습니다. 의도라면 괜찮지만, 의도가 아니라면 전파 속성을 재검토해야 합니다.

체크 포인트

  • “롤백이 안 됐다”가 아니라 “다른 트랜잭션이 커밋했다”일 수 있음
  • 로깅/감사/아웃박스 패턴 등에서 REQUIRES_NEW를 무심코 쓰면 이런 현상이 자주 발생

MSA에서 이런 문제는 더 복잡해집니다. 단일 DB 트랜잭션으로 해결이 안 되는 흐름은 보상 트랜잭션을 고려해야 합니다. 관련해서는 MSA에서 Saga 보상트랜잭션 설계 7패턴 글도 함께 참고하면 좋습니다.

5) @Async/다른 스레드로 넘어가 트랜잭션이 끊김

트랜잭션 컨텍스트는 기본적으로 스레드 로컬에 묶입니다. 따라서 스레드가 바뀌면 같은 트랜잭션이 아닙니다.

@Service
public class EmailService {

  @Async
  public void sendEmailAsync(String to) {
    emailLogRepository.save(new EmailLog(to));
    throw new RuntimeException("async fail");
  }
}

@Service
public class UserService {

  private final EmailService emailService;

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

  @Transactional
  public void signup() {
    userRepository.save(new User("alice"));
    emailService.sendEmailAsync("a@a.com");
    // 여기서 예외가 없어 정상 커밋될 수 있음
  }
}

여기서 기대를 “이메일 실패하면 회원가입도 롤백”으로 잡으면 설계가 어긋납니다. 비동기 작업은 분산/비동기 실패 모델을 갖기 때문입니다.

해결 방향

  • 비동기 작업 실패가 핵심 비즈니스 실패라면 비동기 자체를 재검토
  • 또는 Outbox 패턴/재시도 큐로 전환
  • 가상 스레드를 섞어 쓸 때 지연/교착까지 함께 보이면, 트랜잭션 경계와 실행 모델을 같이 점검해야 합니다. 관련해서는 Spring Boot 3 가상스레드 적용 후 지연·데드락 진단도 참고할 만합니다.

6) flush 때문에 “이미 반영된 것처럼 보이는 착시”

JPA/Hibernate는 트랜잭션 중간에 flush가 발생할 수 있습니다(명시적 flush(), 쿼리 실행 전 flush, ID 생성 전략 등). flush는 DB에 SQL을 날리지만, 커밋이 아닙니다.

다만 다음 조건이 겹치면 “롤백 안 됐다”처럼 보일 수 있습니다.

  • 같은 트랜잭션/같은 커넥션에서 조회해서 변경이 보임
  • 격리 수준/읽기 방식에 따라 다른 세션에서 관측이 달라짐
  • 로그/모니터링에서 SQL이 실행된 것만 보고 커밋으로 오해

재현 예시

@Transactional
public void demoFlush(EntityManager em) {
  userRepository.save(new User("alice"));
  em.flush(); // INSERT SQL 실행
  throw new RuntimeException("fail"); // 최종적으로 롤백
}

이 경우에도 정상이라면 롤백되어야 합니다. 정말로 데이터가 남는다면 위에서 설명한 프록시 미적용, 다른 트랜잭션 커밋, 예외 삼킴 등을 의심해야 합니다.

7) 테스트 코드에서 롤백이 안 되는 것처럼 보이는 경우

스프링 테스트는 기본적으로 테스트 메서드에 트랜잭션을 걸고 롤백하는 경우가 많습니다. 그런데 다음 요인으로 결과가 꼬일 수 있습니다.

  • 테스트에서 @Commit 또는 @Rollback(false) 사용
  • 서비스 메서드가 REQUIRES_NEW로 별도 커밋
  • 테스트가 트랜잭션 없이 실행됨

테스트에서 트랜잭션 경계를 명확히 하려면, 의도적으로 @Transactional을 테스트에 붙이고 전파 속성을 통제하는 것이 좋습니다.

빠른 진단 체크리스트(운영에서 바로 쓰는 순서)

1) 로그로 트랜잭션 시작/커밋/롤백을 확인

application.yml에 아래를 추가해 트랜잭션 로그를 먼저 봅니다.

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

“예외가 났는데 커밋 로그가 찍힌다”면 예외가 밖으로 전파되지 않았거나 롤백 대상이 아닌 예외일 확률이 큽니다.

2) 예외 타입 확인

  • 체크 예외면 rollbackFor 필요
  • 컨트롤러/서비스에서 예외를 잡아 return하고 있지 않은지 확인

3) 호출 경로가 프록시를 타는지 확인

  • 같은 클래스 내부 호출인지
  • private/final 메서드에 붙인 건 아닌지
  • 빈으로 등록된 객체를 통해 호출되는지

4) 전파 속성 확인

  • REQUIRES_NEW로 일부가 커밋되는지
  • 이벤트 리스너, 감사 로깅, 알림 발송이 별도 트랜잭션인지

5) 스레드 경계 확인

  • @Async, 스케줄러, 메시지 리스너, 별도 executor 사용 여부

실전 권장 패턴: 트랜잭션은 “쓰기 유스케이스”에만 얇게

롤백 문제를 줄이는 가장 좋은 방법은 트랜잭션 경계를 단순하게 유지하는 것입니다.

  • 트랜잭션 메서드는 “DB 쓰기 유스케이스” 단위로 짧게
  • 외부 API 호출/파일 IO/긴 연산은 트랜잭션 밖으로 분리
  • 비동기/분산 흐름은 로컬 트랜잭션 롤백으로 해결하려 하지 말고, 필요 시 Saga/보상 트랜잭션을 고려

특히 MSA에서는 “한 서비스에서 롤백되면 다른 서비스도 롤백될 것”이라는 기대가 사고로 이어집니다. 이런 경우는 MSA에서 Saga 보상트랜잭션 설계 7패턴처럼 애초에 실패 모델을 설계하는 편이 안전합니다.

마무리

Spring Boot 3에서 @Transactional 롤백이 안 되는 대부분의 케이스는 프레임워크 버그가 아니라, 다음 중 하나로 귀결됩니다.

  • 롤백 조건이 아닌 예외를 던졌다
  • 예외를 잡아먹었다
  • 프록시가 적용되지 않았다(셀프 인보케이션)
  • 이미 다른 트랜잭션에서 커밋됐다(REQUIRES_NEW)
  • 스레드가 바뀌어 트랜잭션 컨텍스트가 끊겼다

위의 재현 코드와 체크리스트 순서대로 확인하면, “왜 데이터가 남았는지”를 로그와 호출 경계로 빠르게 설명할 수 있고, 근본적으로는 트랜잭션을 짧고 명확하게 설계하는 쪽으로 구조를 개선할 수 있습니다.