프록시 패러독스: Spring @Transactional이 사라지는 이유

발행: (2025년 12월 24일 오후 03:30 GMT+9)
5 min read
원문: Dev.to

Source: Dev.to

문제

중요한 메서드에 @Transactional을 붙이고 테스트를 실행했을 때는 모든 것이 정상적으로 보입니다.
하지만 트랜잭션 로그를 확인하면 트랜잭션이 전혀 없습니다: 커넥션이 등록되지 않고, 타임아웃도 없으며, 오류 발생 시 롤백도 일어나지 않습니다. 메서드는 실행됐지만 트랜잭션 밖에서 실행된 것입니다.

이것이 프록시 패러독스 – Spring의 AOP 프록시가 같은 빈 내부에서 메서드가 서로 호출될 때 트랜잭션을 숨기는 현상입니다.

Spring AOP 작동 방식

Spring은 로깅, 보안, 트랜잭션 등과 같은 횡단 관심사를 적용하기 위해 **Aspect‑Oriented Programming (AOP)**을 사용합니다.
컨테이너가 시작될 때, Spring은 빈들을 스캔하여 @Transactional, @Async, @Cacheable 등과 같은 어노테이션을 찾습니다.
어노테이션이 발견되면 Spring은 프록시(JDK 동적 프록시 또는 CGLIB 서브클래스)로 해당 빈을 감쌉니다.

프록시는 외부 메서드 호출을 가로채어 인터셉터 체인(예: TransactionInterceptor)을 실행한 뒤, 원본 빈에 위임합니다.

자기 호출(Self‑Invocation) 문제

메서드가 같은 빈 내부의 다른 메서드를 호출하면 인터셉터 체인이 우회됩니다.

@Service
class WalletService {

    // Entry point
    public void pay(BigDecimal amount) {
        // Internal call – bypasses proxy
        withdrawMoney(amount);
    }

    @Transactional
    public void withdrawMoney(BigDecimal amount) {
        // ... database writes ...
    }
}
  • WalletService 외부에서 withdrawMoney를 호출 → 프록시를 통과 → 트랜잭션 시작.
  • pay 메서드 안에서 withdrawMoney를 호출 → this 직접 호출 → 프록시가 건너뛰어 → 트랜잭션 없음.

AspectJ가 다르게 동작하는 이유

AspectJ 로드‑타임 위빙을 활성화하면:

@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)

AspectJ는 트랜잭션 로직을 바이트코드에 직접 삽입하므로 자기 호출도 정상적으로 동작합니다.

  • 장점: 자기 호출이 바로 작동한다.
  • 단점: Java 에이전트가 필요하고, 빌드 복잡도가 증가하며, 시작 시간이 늘어나고, 일반적인 웹 애플리케이션에는 과도한 경우가 많다.

해결 방안

1. 트랜잭션 메서드를 다른 빈으로 이동

@Service
class PaymentService {
    private final WalletService walletService; // injected

    public void pay(BigDecimal amount) {
        walletService.withdrawMoney(amount); // external call
    }
}

깨끗하고 SOLID‑친화적이며 테스트하기 쉽다.

2. 프록시를 자기 자신에게 주입 (필드 인젝션)

@Service
class WalletService {
    @Lazy
    @Autowired
    private WalletService self; // proxy injected

    public void pay(BigDecimal amount) {
        self.withdrawMoney(amount); // goes through proxy
    }
}

동작하지만 필드 인젝션에 의존하고, 생성자 인젝션을 사용할 경우 순환 참조 문제가 발생할 수 있다.

3. TransactionTemplate을 명시적으로 사용

@Autowired
private TransactionTemplate transactionTemplate;

public void pay(BigDecimal amount) {
    transactionTemplate.execute(status -> {
        withdrawMoney(amount);
        return null;
    });
}

조금의 보일러플레이트가 추가되지만 트랜잭션 경계가 명확해진다.

4. AOP 프록시를 직접 호출

import org.springframework.aop.framework.AopContext;

public void pay(BigDecimal amount) {
    ((WalletService) AopContext.currentProxy()).withdrawMoney(amount);
}

추상화가 새는 방법 – 비즈니스 코드가 Spring AOP 내부에 의존하게 된다. 필요할 때만 사용한다.

5. AspectJ 위빙으로 전환

@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)

많은 빈에 대해 효과적이지만 복잡도가 크게 증가한다; 몇몇 경우에만 고려한다.

요약

  • 외부 호출 → 프록시 → 어스펙트 실행 → 비즈니스 로직.
  • 내부 호출 → 순수 this 객체 → 비즈니스 로직 (어스펙트 미실행).

패러독스를 피하려면 코드를 재구성하거나 필요에 따라 프록시를 주입하고, 혹은 TransactionTemplate을 사용한다. 자기 호출 메서드에 @Transactional에만 의존하지 말아야 한다.

Back to Blog

관련 글

더 보기 »