@Transactional을 분명히 붙였는데 트랜잭션이 안 걸린다면, 혹시 같은 클래스 안에서 그 메서드를 호출하고 있지는 않나요?

개념 정의

Self-invocation 은 같은 클래스 내부에서 자기 자신의 메서드를 호출하는 것을 말합니다. 스프링 AOP는 프록시 기반이므로, 내부 호출 시 프록시를 거치지 않아 AOP 어드바이스가 적용되지 않습니다.

왜 알아야 하는가

이 문제를 모르면 프로덕션에서 데이터 정합성이 깨지는 버그가 발생합니다.

JAVA
@Service
public class OrderService {

    public void processOrder(OrderRequest request) {
        // 여러 작업 수행
        validateOrder(request);
        createOrder(request);  // 내부 호출 → @Transactional이 안 먹음!
        sendNotification(request);
    }

    @Transactional
    public void createOrder(OrderRequest request) {
        orderRepository.save(new Order(request));
        paymentService.charge(request.getPayment());
        // 결제 실패 시 롤백되어야 하는데... 트랜잭션이 없음
    }
}

processOrder()에서 createOrder()를 호출하면, @Transactional이 붙어 있어도 트랜잭션이 시작되지 않습니다. 결제가 실패해도 주문이 롤백되지 않는 심각한 버그입니다.

내부 동작

프록시가 동작하는 과정

외부에서 호출할 때와 내부에서 호출할 때를 비교하면 원인이 명확해집니다.

PLAINTEXT
[외부 호출 — 정상 동작]
컨트롤러 → [프록시] → createOrder()

         트랜잭션 시작

         원본 createOrder() 실행

         트랜잭션 커밋/롤백

[내부 호출 — AOP 누락]
컨트롤러 → [프록시] → processOrder()

                     this.createOrder()  ← 프록시가 아닌 원본 객체의 this!

                     createOrder() 직접 실행 (AOP 없음)

왜 this는 프록시가 아닌가

  1. 스프링은 빈을 등록할 때 원본 객체를 CGLIB 프록시로 감쌉니다.
  2. 외부에서 주입받는 것은 프록시 객체이므로, 외부 호출은 프록시를 거칩니다.
  3. 그런데 메서드 내부의 this는 자바 언어 레벨에서 원본 객체 자신 을 가리킵니다.
  4. 따라서 this.createOrder()는 프록시를 우회하여 원본 메서드를 직접 호출합니다.
JAVA
@Service
public class OrderService {
    // 외부에서 주입받는 건 프록시
    // 하지만 내부에서 this는 원본 객체

    public void processOrder(OrderRequest request) {
        // this == OrderService (원본)
        // 프록시 == OrderService$$SpringCGLIB$$0

        this.createOrder(request);
        // → OrderService.createOrder() 직접 호출
        // → 프록시를 안 거침 → AOP 적용 안 됨
    }

    @Transactional
    public void createOrder(OrderRequest request) { ... }
}

코드 예제

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

가장 깔끔하고 권장되는 방법입니다.

JAVA
@Service
@RequiredArgsConstructor
public class OrderFacade {
    private final OrderService orderService;
    private final NotificationService notificationService;

    public void processOrder(OrderRequest request) {
        orderService.validateOrder(request);
        orderService.createOrder(request);  // 외부 호출 → 프록시 통과 → AOP 적용!
        notificationService.sendNotification(request);
    }
}

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

JAVA
@Service
public class OrderService {

    @Transactional
    public void createOrder(OrderRequest request) {
        orderRepository.save(new Order(request));
        paymentService.charge(request.getPayment());
        // 트랜잭션 정상 동작
    }

    public void validateOrder(OrderRequest request) { ... }
}

processOrder()에서 createOrder()를 호출할 때, 이제는 다른 빈(프록시) 을 통해 호출하므로 AOP가 정상 동작합니다. 이 방법은 단일 책임 원칙에도 부합합니다.

해결법 2: AopContext.currentProxy()

구조를 변경하기 어려울 때 사용할 수 있는 방법입니다.

JAVA
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true) // 필수 설정
public class Application { ... }
JAVA
@Service
public class OrderService {

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

        // 프록시를 통해 호출
        ((OrderService) AopContext.currentProxy()).createOrder(request);

        sendNotification(request);
    }

    @Transactional
    public void createOrder(OrderRequest request) { ... }
}

동작은 하지만 몇 가지 단점이 있습니다.

  • 스프링 AOP에 강하게 의존하는 코드가 됩니다
  • 캐스팅이 필요해서 가독성이 떨어집니다
  • exposeProxy = true 설정을 잊으면 런타임 에러가 발생합니다

해결법 3: 자기 자신 주입

JAVA
@Service
public class OrderService {
    @Lazy
    @Autowired
    private OrderService self; // 자기 자신의 프록시를 주입

    public void processOrder(OrderRequest request) {
        validateOrder(request);
        self.createOrder(request); // 프록시를 통해 호출
        sendNotification(request);
    }

    @Transactional
    public void createOrder(OrderRequest request) { ... }
}

@Lazy를 붙이지 않으면 순환 참조 에러가 발생합니다. @Lazy를 통해 프록시를 먼저 주입받고, 실제 사용 시점에 프록시가 원본 빈을 참조합니다. 동작은 하지만, 코드만 보고 의도를 파악하기 어려워 팀원이 혼란스러워할 수 있습니다.

해결법 비교

방법장점단점
클래스 분리깔끔, 구조적 해결클래스가 늘어남
AopContext기존 구조 유지스프링 API에 의존, 가독성 저하
자기 자신 주입간단한 수정의도 파악 어려움, @Lazy 필수

이 문제가 발생하는 대표적인 케이스

JAVA
// 1. @Transactional self-invocation
@Service
public class UserService {
    public void register(User user) {
        saveUser(user);           // AOP 미적용
        sendWelcomeEmail(user);
    }

    @Transactional
    public void saveUser(User user) { ... }
}

// 2. @Cacheable self-invocation
@Service
public class ProductService {
    public ProductDetail getDetail(Long id) {
        Product product = getProduct(id); // AOP 미적용 → 캐시 안 됨
        return new ProductDetail(product);
    }

이어서 비동기 처리 메서드를 정의합니다.

JAVA
    @Cacheable("products")
    public Product getProduct(Long id) { ... }
}

// 3. @Async self-invocation
@Service
public class ReportService {
    public void generateAllReports() {
        generateReport("daily");   // AOP 미적용 → 동기 실행
        generateReport("weekly");
    }

    @Async
    public void generateReport(String type) { ... }
}

모두 같은 원인입니다. 내부 호출은 프록시를 거치지 않습니다.

확인 방법

JAVA
@Service
public class OrderService {
    public void checkProxy() {
        boolean isProxy = AopUtils.isAopProxy(this);
        System.out.println("this는 프록시인가? " + isProxy); // false

        // 실제 프록시 확인
        System.out.println("this 클래스: " + this.getClass());
        // com.example.OrderService (원본)
    }
}

주의할 점

1. @Cacheable, @Async도 self-invocation에 똑같이 취약하다

self-invocation 문제는 @Transactional만의 문제가 아닙니다. @Cacheable을 내부 호출하면 캐시를 거치지 않아 매번 DB를 조회하고, @Async를 내부 호출하면 비동기가 아닌 동기로 실행됩니다. 모든 AOP 기반 어노테이션이 같은 문제를 공유하므로, 해당 어노테이션이 붙은 메서드는 반드시 외부 빈에서 호출되는지 확인해야 합니다.

2. AopContext.currentProxy()는 exposeProxy 설정을 빠뜨리면 런타임 에러가 난다

AopContext.currentProxy()를 사용하려면 @EnableAspectJAutoProxy(exposeProxy = true) 설정이 필수입니다. 이 설정 없이 AopContext.currentProxy()를 호출하면 IllegalStateException: Cannot find current proxy가 런타임에 발생합니다. 테스트에서는 동작하다가 프로덕션에서 설정이 누락되어 장애로 이어지는 경우가 있습니다.

3. 자기 자신 주입 시 @Lazy를 빠뜨리면 순환 참조 에러가 발생한다

self-invocation을 해결하려고 자기 자신을 @Autowired로 주입할 때 @Lazy를 붙이지 않으면 BeanCurrentlyInCreationException이 발생합니다. Spring Boot 2.6+에서는 기본적으로 순환 참조가 금지되어 있어 애플리케이션 자체가 시작되지 않습니다.

정리

항목설명
원인this는 프록시가 아닌 원본 객체를 참조하여 AOP를 우회
영향받는 어노테이션@Transactional, @Cacheable, @Async 등 모든 AOP 기반 기능
권장 해결법로직을 별도 클래스로 분리 (구조적 해결)
대안 1AopContext.currentProxy() (스프링 API 의존)
대안 2@Lazy + 자기 자신 주입 (의도 파악 어려움)
댓글 로딩 중...