@Transactional을 분명히 붙였는데 트랜잭션이 동작하지 않는다면, 어디를 의심해야 할까요?

Spring에서 가장 흔하게 발생하는 트랜잭션 관련 버그 중 하나가 바로 self-invocation(자기 호출) 문제입니다. 이 문제를 이해하려면 Spring AOP의 프록시 동작 방식을 알아야 합니다.

문제 상황

JAVA
@Service
public class OrderService {

    public void processOrder(OrderRequest request) {
        // 검증 로직
        validateOrder(request);

        // 트랜잭션이 필요한 메서드를 내부 호출
        saveOrder(request);  // @Transactional이 동작하지 않음!
    }

    @Transactional
    public void saveOrder(OrderRequest request) {
        orderRepository.save(new Order(request));
        paymentRepository.save(new Payment(request));
        // 예외 발생 시 롤백이 되어야 하는데... 안 됨!
    }
}

saveOrder()@Transactional이 붙어있지만, processOrder()에서 내부 호출하면 트랜잭션이 적용되지 않습니다.

왜 이런 일이 발생하는가

프록시 동작 구조

PLAINTEXT
외부 클라이언트


OrderService$Proxy (프록시)

    ├── processOrder() 호출
    │   └── this.saveOrder() ← 프록시가 아닌 실제 객체의 메서드 호출!

    └── saveOrder() 직접 호출 ← 프록시를 통과하므로 트랜잭션 적용
        └── 트랜잭션 시작 → 실제 메서드 실행 → 커밋/롤백

핵심은 this입니다. 같은 클래스 내에서 this.saveOrder()를 호출하면 ** 프록시 객체가 아닌 실제 객체 **를 통해 호출됩니다. 프록시를 거치지 않으므로 트랜잭션 AOP가 동작하지 않습니다.

외부 호출 vs 내부 호출

JAVA
// 외부 호출 — 트랜잭션 적용됨
@Controller
public class OrderController {
    @Autowired OrderService orderService; // 프록시 주입

    public void create() {
        orderService.saveOrder(request);  // 프록시.saveOrder() → 트랜잭션 O
    }
}

// 내부 호출 — 트랜잭션 미적용
@Service
public class OrderService {
    public void processOrder() {
        this.saveOrder(request);  // this(실제객체).saveOrder() → 트랜잭션 X
    }
}

해결법 1: 클래스 분리 (가장 권장)

트랜잭션이 필요한 메서드를 별도 클래스로 분리합니다.

JAVA
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderTransactionService txService;

    public void processOrder(OrderRequest request) {
        validateOrder(request);
        txService.saveOrder(request);  // 외부 호출 → 프록시 통과 → 트랜잭션 O
    }
}

이어서 트랜잭션이 적용된 메서드를 정의합니다.

JAVA
@Service
public class OrderTransactionService {

    @Transactional
    public void saveOrder(OrderRequest request) {
        orderRepository.save(new Order(request));
        paymentRepository.save(new Payment(request));
    }
}

이 방법이 ** 가장 깔끔하고 권장 **됩니다. 단일 책임 원칙에도 부합합니다.

해결법 2: TransactionTemplate (프로그래밍 방식)

JAVA
@Service
@RequiredArgsConstructor
public class OrderService {

    private final TransactionTemplate transactionTemplate;

    public void processOrder(OrderRequest request) {
        validateOrder(request);

        // 프록시 없이 직접 트랜잭션 관리
        transactionTemplate.execute(status -> {
            orderRepository.save(new Order(request));
            paymentRepository.save(new Payment(request));
            return null;
        });
    }
}

TransactionTemplate은 프록시에 의존하지 않으므로 self-invocation 문제가 발생하지 않습니다.

TransactionTemplate 설정

JAVA
// 읽기 전용 TransactionTemplate
TransactionTemplate readOnlyTemplate = new TransactionTemplate(transactionManager);
readOnlyTemplate.setReadOnly(true);

// 새 트랜잭션 TransactionTemplate
TransactionTemplate newTxTemplate = new TransactionTemplate(transactionManager);
newTxTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);

해결법 3: 자기 자신 주입 (비권장)

JAVA
@Service
public class OrderService {

    @Autowired
    private OrderService self;  // 프록시가 주입됨

    public void processOrder(OrderRequest request) {
        validateOrder(request);
        self.saveOrder(request);  // 프록시를 통해 호출 → 트랜잭션 O
    }

    @Transactional
    public void saveOrder(OrderRequest request) {
        orderRepository.save(new Order(request));
    }
}

동작은 하지만 ** 순환 참조 **처럼 보이고 코드가 부자연스럽습니다. 실무에서는 지양하는 것이 좋습니다.

해결법 4: AspectJ 모드

JAVA
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
@Configuration
public class TransactionConfig { }

AspectJ는 프록시가 아닌 ** 바이트코드 위빙 **으로 동작하므로 self-invocation 문제가 발생하지 않습니다. 하지만 설정이 복잡하고 빌드 과정에 AspectJ 컴파일러가 필요합니다.

@Transactional이 동작하지 않는 다른 경우들

1. private 메서드

JAVA
@Transactional
private void saveOrder() { }  // 프록시가 오버라이드할 수 없음 → 동작 안 함

Spring AOP 프록시는 public 메서드에서만 동작합니다.

2. final 클래스/메서드

JAVA
@Service
public final class OrderService {  // CGLIB 상속 불가 → 프록시 생성 실패
    @Transactional
    public void save() { }
}

CGLIB 프록시는 클래스를 상속하므로 final 클래스나 final 메서드에는 프록시를 생성할 수 없습니다.

3. Spring 빈이 아닌 객체

JAVA
OrderService service = new OrderService();  // Spring 빈이 아님
service.saveOrder(request);  // 프록시가 아니므로 트랜잭션 미적용

@Transactional은 Spring이 관리하는 빈에서만 동작합니다.

프록시 동작 확인 방법

JAVA
@Service
@RequiredArgsConstructor
public class DebugService {

    private final OrderService orderService;

    public void checkProxy() {
        System.out.println(orderService.getClass());
        // com.example.OrderService$$SpringCGLIB$$0 ← CGLIB 프록시
        System.out.println(AopUtils.isAopProxy(orderService)); // true
    }
}

주의할 점

1. 테스트에서는 문제가 드러나지 않을 수 있다

@SpringBootTest에서 서비스를 직접 주입받아 호출하면 프록시를 통해 호출되므로 트랜잭션이 정상 동작합니다. 하지만 실제 운영 코드에서 같은 클래스 내부의 메서드를 호출하면 self-invocation이 발생합니다. 단위 테스트만으로는 이 문제를 발견하기 어려우므로, 코드 리뷰 시 @Transactional 메서드의 호출 경로를 반드시 확인해야 합니다.

2. 자기 자신 주입(self-injection) 방식은 순환 참조 탐지에 걸릴 수 있다

Spring Boot 2.6부터 순환 참조가 기본적으로 금지되었습니다. @Autowired private OrderService self; 같은 self-injection은 spring.main.allow-circular-references=true 설정 없이는 애플리케이션 시작 자체가 실패합니다. 근본적으로 클래스를 분리하는 것이 더 안전합니다.

3. @Transactional이 붙은 protected, package-private 메서드는 Spring Framework 버전에 따라 동작이 다르다

Spring Framework 5.x까지는 public 메서드에서만 @Transactional이 동작했지만, 6.0부터는 protected 메서드에서도 동작합니다. 프로젝트의 Spring 버전을 확인하지 않고 protected 메서드에 @Transactional을 붙이면 "분명 어노테이션을 붙였는데 트랜잭션이 안 걸린다"는 문제에 빠질 수 있습니다.

정리

  • self-invocation 문제 는 같은 클래스 내에서 this로 호출하면 프록시를 우회하기 때문에 발생합니다.
  • 클래스 분리 가 가장 깔끔한 해결법이며, 단일 책임 원칙에도 부합합니다.
  • TransactionTemplate 은 프록시 없이 프로그래밍 방식으로 트랜잭션을 관리합니다.
  • @Transactional은 **public 메서드 **, **Spring 빈 **, final이 아닌 클래스 에서만 정상 동작합니다.
  • AspectJ 모드는 self-invocation을 해결하지만 설정이 복잡하여 실무에서는 잘 사용하지 않습니다.
댓글 로딩 중...