Spring

프록시와 내부 호출 문제 (@Transactional self-invocation)

경딩 2025. 9. 21. 15:07

 

스프링에서 @Transactional을 사용하다 보면 내부 호출(self) 때문에 트랜잭션이 적용되지 않는 문제가 발생한다.

 

"self-invocation does not lead to an actual transaction at runtime"
(자기 자신 안에서 자기 자신의 트랜잭션 메서드를 호출하면 실제 트랜잭션이 동작하지 않는다)

문제의 원인: Spring AOP의 프록시 방식

Spring은 프록시 방식의 AOP를 사용한다. 이는 다음과 같은 특징이 있다.

  • AOP를 적용하려면 항상 프록시를 통해서 대상 객체를 호출해야 한다.
  • 프록시에서 먼저 어드바이스를 호출하고, 이후에 대상 객체 호출
  • 프록시를 거치지 않고 대상 객체를 직접 호출하면 AOP가 적용되지 않음

 

  • 정상적인 경우
외부 호출 → 프록시 객체 → 어드바이스 실행 → 대상 객체 메서드 실행
  • 문제가 되는 경우 (Self-Invocation)
외부 호출 → 프록시 객체 → 대상 객체 메서드 → this.내부메서드() (프록시 우회)

실제 예제로 살펴보기

문제 상황 재현

package hello.aop.internalcall;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class CallServiceV0 {

    public void external() {
        log.info("call external");
        internal(); // 내부 메서드 호출 this.internal()
    }

    public void internal() {
        log.info("call internal");
    }
}

 

위 코드에서 external() 메서드 내부에서 internal() 메서드를 호출할 때, 자바에서는 암묵적으로 this.internal() 로 처리한다.

여기서 this는 프록시가 아닌 실제 대상 객체를 가리키므로 AOP가 적용되지 않는다.


AOP 설정

/**
 * 메서드 호출 시 AOP를 통해 호출 로그를 남기는 Aspect
 */
@Slf4j
@Aspect // 해당 클래스가 AOP의 Aspect(공통 관심사 모듈)임을 선언
public class CallLogAspect {

    /**
     * 대상: hello.aop.internalcall 패키지 및 하위 패키지의 모든 메서드 실행 시
     * 시점: @ Before -> 대상 메서드 실행 직전에 실행됨
     * @param joinPoint
     */
    @Before("execution(* hello.aop.internalcall..*.*(..))")
    public void doLog(JoinPoint joinPoint) {
        // JoinPoint : 현재 AOP가 걸린 메서드의 실행 정보를 담고 있는 객체
        // joinPoint.getSignature() : 호출된 메서드의 시그니처(메서드 이름, 파라미터 등)
        log.info("aop={}", joinPoint.getSignature());
    }
}

CallServiceV0 에 AOP 를 적용하기 위해서 간단한 Aspect 를 하나 만들자.

 


CallServiceV0을 실행할 수 있는 테스트 코드

package hello.aop.internalcall;

import hello.aop.CallLogAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

import static org.junit.jupiter.api.Assertions.*;

@Slf4j
@Import(CallLogAspect.class) // CallLogAspect (AOP 설정 클래스) 를 스프링 컨테이너에 등록해서 테스트에서 사용
@SpringBootTest // 스프링부트 테스트 환경에서 실행
class CallServiceV0Test {

    @Autowired CallServiceV0 callServiceV0;

    @Test
    void external() {
        // 실제 주입된 객체 타입 확인 (프록시 적용 여부 확인용)
        log.info("target={}", callServiceV0.getClass());
        // external() 메서드 실행 → 내부적으로 internal() 호출 포함
        // AOP 프록시가 적용되었는지 확인하는 테스트
        callServiceV0.external();
    }

    @Test
    void internal() {
        // internal() 메서드를 직접 호출
        // self-invocation 상황에서 AOP 적용 여부 비교
        callServiceV0.internal();
    }
}

 

@Import(CallLogAspect.class) : 앞서 만든 간단한 Aspect  를 스프링 빈으로 등록한다. 이렇게 해서 CallServiceV0 에 AOP 프록시를 적용한다.

@SpringBootTest : 내부에 컴포넌트 스캔을 포함하고 있다. CallServiceV0 에 @Component 가 붙어있으므로 스프링 빈 등록 대상이 된다.


테스트 실행 결과

  • external() 호출 시 
target=class hello.aop.internalcall.CallServiceV0$$SpringCGLIB$$0 // 프록시 객체
aop=void hello.aop.internalcall.CallServiceV0.external() // AOP 적용됨
call external
call internal // // AOP 적용안됨

 

CallServiceV0$$SpringCGLIB$$0 - CallService 프록시 객체가 주입된것을 확인 할 수 있다.

 

 

실행 결과를 보면 callServiceV0.external() 을 실행할 때는 프록시를 호출한다. 따라서 CallLogAspect 어드바이스가 호출된 것을 확인할 수 있다.

그리고 AOP Proxy는 target.external()을 호출한다. 

그런데 여기서 문제는  `callServiceV0.external()` 안에서 `internal()` 호출할 발생한다. 

이때는 CallLogAspect 어드바이스가 호출되지 않는다.

 

자바 언어에서는 메서드앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킨다.

결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal()이 되는데, 여기서 this 는 실제 대상 객체 

(target)의 인스턴스를 뜻한다. 결과적으로 이러한 내부 호출은 프록시 객체를 거치지 않는다. 따라서 어드바이스도 적용할 수 없다.

 

  • 외부에서 internal()을 직접 호출 시:
aop=void hello.aop.internalcall.CallServiceV0.internal() //AOP 적용됨

call internal

 

외부에서 호출하는 경우 프록시를 거치기 때문에 internal()도 CallLogAspect 어드바이스가 적용된 것을 확인 할 수 있다.

 

참고

실제 코드에서 AOP 를 직접 적용하는 AspectJ를  사용하면 이러한 문제가 발생하지 않는다.

프록시를 통하는 것이 아니라 해당 코드에 직접 AOP 적용 코드가 붙어 있기 때문에 내부 호출과 무관하게 AOP를 적용할 수 있다.

하지만 로드 타임 위빙들을 사용해야 하는데, 설정이 복잡하고 JVM 옵션을 주어야하는 부담이 있다.

그리고 프록시 방식의 AOP 에서 내부 호출에 대응할 수 있는 대안들도 있다.

이러한 이유로 AspectJ를 직접 사용하는 방법은 실무에서는 거의 사용하지 않는다.

 


해결 방안

1. 자기 자신 주입 (Self Injection)

가장 간단한 방법은 자기 자신을 의존관계 주입 받는 것이다.

package hello.aop.internalcall;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * CallServiceV1: 자기 자신 메서드 호출 시 AOP(트랜잭션, 로깅 등)가 적용되지 않는
 * self-invocation 문제를 회피하기 위한 예제
 *
 * 참고: 생성자 주입으로 자기 자신을 주입하면 순환 의존이 발생하여 스프랭 컨테이너 초기화 실패
 */
@Slf4j
@Component
public class CallServiceV1 {

    /**
     * 자기 자신을 빈으로 주입
     * 프록시가 적용된 빈을 호출하면 self-invocation 문제 해결 가능
     */
    private CallServiceV1 callServiceV1;

    @Autowired
    public void setCallServiceV1(CallServiceV1 callServiceV1) {
        log.info("callServiceV1 setter={}", callServiceV1.getClass()); // 프록시 클래스 확인 가능
        this.callServiceV1 = callServiceV1;
    }

    /**
     * 외부에서 호출되는 메서드
     * 자기 자신 프록시를 통해  internal() 호출 -> AOP 적용 가능
     */
    public void external() {
        log.info("call external");
        callServiceV1.internal(); // 프록시를 통해 호출
    }

    /**
     * 내부 메서드
     * self-invocation 시 AOP가 적용되지 않지만,
     * 외부 프록시를 통해 호출하면 AOP가 적용됨
     */
    public void internal() {
        log.info("call internal");
    }
}

 

 

callServiceV1 를 수정자를 통해서 주입받는 것을 확인할 수 있다. 스프링에서 AOP가 적용된 대상을 의존관계 주입 받으면

주입받은 대상은 실제 자신이 아니라 프록시 객체이다.

external()을 호출하면 callServiceV1.internal()를 호출하게 된다. 주입받은 callServiceV1  은 프록시이다.

따라서 프록시를 통해서 AOP를 적용할 수 있다.

 

주의사항: Spring Boot 2.6부터는 순환 참조가 기본적으로 금지되므로 application.properties에 다음 설정이 필요:

properties
spring.main.allow-circular-references=true

 

@Slf4j
@Import(CallLogAspect.class) // CallLogAspect (AOP 설정 클래스) 를 스프링 컨테이너에 등록해서 테스트에서 사용
@SpringBootTest // 스프링부트 테스트 환경에서 실행
class CallServiceV1Test {

    @Autowired CallServiceV1 callServiceV1;

    @Test
    void external() {
        // 실제 주입된 객체 타입 확인 (프록시 적용 여부 확인용)
        log.info("target={}", callServiceV1.getClass());
        // external() 메서드 실행 → 내부적으로 internal() 호출 포함
        // AOP 프록시가 적용되었는지 확인하는 테스트
        callServiceV1.external();
    }

}
hello.aop.CallLogAspect                  : aop=void hello.aop.internalcall.CallServiceV1.external()
hello.aop.internalcall.CallServiceV1     : call external
hello.aop.CallLogAspect                  : aop=void hello.aop.internalcall.CallServiceV1.internal()
hello.aop.internalcall.CallServiceV1     : call internal

 

 

 

 

실행 결과를 보면 이제는  internal()을 호출할 때 자기 자신의 인스턴스를 호출하는 것이 아니라 프록시 인스턴스를 통해서 호출하는 것을 확인할 수 있다. 당연히  AOP도 잘 적용된다.


2. 지연 조회 (Lazy Lookup)

ObjectProvider나 ApplicationContext를 사용하여 지연 조회하는 방법이다.

/**
 * 자기 자신을 직접 호출하면 프록시를 거치지 않아 AOP 적용이 안 되는 문제를 해결하기 위해
 * ObjectProvider(Provider), ApplicationContext를 사용해서 지연(LAZY)  조회
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV2 {

//    private final ApplicationContext applicationContext;
    private final ObjectProvider<CallServiceV2> callServiceProvider; // ObjectProvider로 자기 자신을 지연 조회


    /**
     */
    public void external() {
        log.info("call external");

//        CallServiceV2 callServiceV2 = applicationContext.getBean(CallServiceV2.class);
        CallServiceV2 callServiceV2 = callServiceProvider.getObject(); // ObjectProvider 를 통해 프록시를 꺼냄
        callServiceV2.internal(); // 프록시를 거쳐 호출 - AOP적용 가능
    }

    /**
     * 내부 호출 메서드
     */
    public void internal() {
        log.info("call internal");
    }
}

 

 

`ApplicationContext` 는 너무 많은 기능을 제공한다.

`ObjectProvider` 는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아닌 실제 객체를 사용하는 시점에 빈을 조회하므로 참조문제가 발생하지 않는다.

package hello.aop.internalcall;

import hello.aop.CallLogAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Slf4j
@Import(CallLogAspect.class) // CallLogAspect (AOP 설정 클래스) 를 스프링 컨테이너에 등록해서 테스트에서 사용
@SpringBootTest // 스프링부트 테스트 환경에서 실행
class CallServiceV2Test {

    @Autowired CallServiceV2 callServiceV2;

    @Test
    void external() {
        // 실제 주입된 객체 타입 확인 (프록시 적용 여부 확인용)
        log.info("target={}", callServiceV2.getClass());
        // external() 메서드 실행 → 내부적으로 internal() 호출 포함
        // AOP 프록시가 적용되었는지 확인하는 테스트
        callServiceV2.external();
    }

}

 

실행 결과

hello.aop.CallLogAspect                  : aop=void hello.aop.internalcall.CallServiceV2.external()
hello.aop.internalcall.CallServiceV2     : call external
hello.aop.CallLogAspect                  : aop=void hello.aop.internalcall.CallServiceV2.internal()
hello.aop.internalcall.CallServiceV2     : call internal

 


3. 구조 변경 (가장 권장되는 방식)

내부 호출이 발생하지 않도록 구조를 변경하는 것이 가장 깔끔한 해결책이다.

/**
 * 구조를 변경 (분리)
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {

    private final InternalService internalService;

    public void external() {
        log.info("call external");
        internalService.internal(); // 외부 메서드 호출
    }
}

내부 호출을 `InternalService` 라는 별도의 클래스로 분리했다.

package hello.aop.internalcall;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class InternalService {

    public void internal() {
        log.info("call internal");
    }

}

 

 

package hello.aop.internalcall;

import hello.aop.CallLogAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Slf4j
@Import(CallLogAspect.class) // CallLogAspect (AOP 설정 클래스) 를 스프링 컨테이너에 등록해서 테스트에서 사용
@SpringBootTest // 스프링부트 테스트 환경에서 실행
class CallServiceV3Test {

    @Autowired CallServiceV3 callServiceV3;

    @Test
    void external() {
        // 실제 주입된 객체 타입 확인 (프록시 적용 여부 확인용)
        log.info("target={}", callServiceV3.getClass());
        // external() 메서드 실행 → 내부적으로 internal() 호출 포함
        // AOP 프록시가 적용되었는지 확인하는 테스트
        callServiceV3.external();
    }
}

 

실행 결과

hello.aop.CallLogAspect                  : aop=void hello.aop.internalcall.CallServiceV3.external
hello.aop.internalcall.CallServiceV3     : call external
hello.aop.CallLogAspect                  : aop=void hello.aop.internalcall.InternalService.internal()
hello.aop.internalcall.InternalService   : call internal

 

내부 호출 자체가 사라지고 `callService` -> internalService를 호출하는 구조로 변경되었다. 덕분에 자연스럽게 AOP 가 적용되었다.

이 방식은 다음과 같은 장점이 있다.

  • 자연스러운 의존성 구조
  • 단일 책임 원칙 준수
  • 테스트하기 쉬운 구조

실무 팁

  • AOP  는 주로 public 메서드에만 적용
  • private 메서드를 위해 구조를 변경하는 일은 거의 없음
  • AOP가 잘 적용되지 않으면 내부 호출을 의심해보자.

이 문제는 실무에서 꼭 한 번은 마주치는 문제이므로, 프록시 기반 AOP의 동작 원리를 이해하고 적절한 해결방안을 선택하는 것이 중요하다.