@Transactional 동작 실패 케이스

한 줄 정의

@Transactional은 AOP 프록시 기반이므로, 프록시를 우회하거나 프록시가 생성되지 않는 조건에서는 트랜잭션이 적용되지 않는다.

실무에서 왜 문제 되는가

  • 데이터 정합성이 깨졌는데 @Transactional을 붙여놨기 때문에 원인을 못 찾는다.
  • 코드 리뷰에서 걸러내기 어렵다 (컴파일 에러가 아니라 런타임에서만 드러남).
  • 테스트 환경에서는 동작하지만 운영에서 데이터 불일치가 발생하는 원인이 된다.

미적용 원인 진단 체크리스트

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

@Service
public class OrderService {
 
    public void process() {
        this.createOrder(); // ❌ 프록시를 거치지 않음
    }
 
    @Transactional
    public void createOrder() {
        // 트랜잭션 미적용
    }
}

원인: this는 프록시가 아닌 실제 객체를 가리킨다.

해결:

  • 메서드를 별도 클래스로 분리 (가장 권장)
  • ApplicationContext에서 빈을 다시 가져와 호출 (비권장)

2. private 메서드

@Transactional
private void internalProcess() { // ❌ CGLIB이 오버라이드 불가
    // 트랜잭션 미적용
}

원인: CGLIB 프록시는 상속 기반이라 private을 오버라이드할 수 없다.

해결: public 또는 protected로 변경.

3. final 클래스 또는 메서드

@Service
public final class OrderService { // ❌ CGLIB이 상속 불가
    @Transactional
    public void createOrder() { }
}

원인: CGLIB은 클래스를 상속해서 프록시를 만드므로 final이면 불가.

4. checked 예외에서 롤백 안 됨

@Transactional
public void process() throws IOException {
    throw new IOException(); // ❌ 기본 설정에서 롤백되지 않음
}

원인: Spring 기본 정책은 RuntimeExceptionError만 롤백.

해결: @Transactional(rollbackFor = Exception.class) 명시.

5. try-catch로 예외를 삼킴

@Transactional
public void process() {
    try {
        riskyOperation();
    } catch (RuntimeException e) {
        log.error("에러 발생", e); // ❌ 예외가 프록시까지 전파되지 않음
    }
}

원인: 프록시는 메서드에서 예외가 던져져야 롤백을 판단한다. catch하면 프록시는 정상 완료로 인식해서 커밋한다.

해결:

  • catch 후 throw로 다시 던진다
  • TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() 호출

6. REQUIRES_NEW를 내부 호출로 사용

@Service
public class OrderService {
 
    @Transactional
    public void createOrder() {
        this.saveLog(); // ❌ 내부 호출 → REQUIRES_NEW 무시
    }
 
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog() { }
}

원인: 내부 호출이므로 프록시를 거치지 않아 전파 설정이 무시된다.

해결: saveLog()를 별도 빈으로 분리.

빠른 진단 플로우

@Transactional이 안 먹힌다
  ├─ 호출 경로가 외부(다른 빈)에서 오는가?
  │   └─ 아니오 → 내부 호출 문제 (self-invocation)
  ├─ 메서드가 public인가?
  │   └─ 아니오 → private/final 문제
  ├─ 예외가 프록시까지 전파되는가?
  │   └─ 아니오 → try-catch로 삼키는 문제
  ├─ 예외가 RuntimeException인가?
  │   └─ 아니오 → checked 예외 롤백 정책 문제
  └─ 클래스가 Spring 빈으로 등록되어 있는가?
      └─ 아니오 → 프록시 자체가 생성되지 않음

자주 나는 실수

  • 코드 리팩토링으로 메서드를 같은 클래스로 합쳤더니 트랜잭션이 깨진다.
  • @Transactional이 붙어 있으니 당연히 동작한다고 가정한다.
  • 테스트 환경에서는 동작하지만 운영에서 데이터 불일치가 발생한다 (catch 문제).
  • Kotlin의 기본 open 클래스에서는 괜찮지만 final 메서드에서 문제가 된다.

핵심 요약

@Transactional이 동작하지 않는 원인은 대부분 프록시를 우회하는 호출 구조에 있습니다. 가장 흔한 원인은 같은 클래스 내부 호출(self-invocation)이고, 해결 방법은 메서드를 별도 빈으로 분리하는 것입니다.

checked 예외의 기본 미롤백과 try-catch로 예외를 삼키는 패턴도 실무에서 자주 발생합니다. 질문을 받으면 “원인 → 프록시 구조 → 해결 방법”을 연결해서 설명하면 됩니다.

꼬리 질문

점검 퀴즈

아래 문항은 개념을 실제로 설명할 수 있는지 점검하기 위한 것이다. 선택지를 누르면 정답 여부와 이유가 표시된다.

객관식@Transactional이 적용되지 않는 대표적인 내부 호출 문제는 무엇인가?

OXprivate 메서드에 @Transactional을 붙이면 외부 프록시 호출 대상이 아니므로 기대대로 동작하지 않을 수 있다.

객관식@Transactional 동작 여부를 테스트에서 확인하는 방법으로 가장 적절한 것은?

관련 문서