본문으로 건너뛰기
Forward Engineering
Go back

[JPA + Spring Mastery 07] Spring AOP self-invocation — @Transactional 이 작동하지 않는 진짜 이유, TransactionInterceptor.invoke 6단계까지 분해

- views

Table of contents

Open Table of contents

들어가며

크레딧 차감 락 4종을 비교하던 중이었습니다. 잔액 100 인 계정에 100 worker 가 동시에 1씩 차감하는 시나리오. 비관락 / GET_LOCK / Redisson 까지 차례로 측정하다 낙관락 차례에서 멈췄습니다.

Strategy: 낙관락 (@Version)
totalMs: 549
successes: 100   ← 100% 성공
fails: 0         ← 실패 0
finalBalance: 100  ← ?? 잔액 그대로

successes=100 인데 finalBalance=100. 100 worker 가 모두 성공했다고 보고하는데 잔액이 그대로. 코드 logic 을 다시 봐도 acc.deduct(amount) 는 명백히 차감하고 있었습니다. JPA dirty checking 으로 commit 시 UPDATE 가 나갈 줄 알았는데 — DB 에는 한 줄도 안 갔습니다.

원인은 @Transactional 이 발동하지 않은 것. 그리고 그 이유는 같은 클래스 내부 호출이 Spring AOP 프록시를 우회한 것이었습니다. 교과서에 나오는 그 self-invocation 함정 — 알고 있다고 생각했는데 측정값의 모순으로 발견할 줄은 몰랐습니다.

이 함정을 뼈대까지 풀어봤습니다. @Transactional 이 어떻게 작동하는지 — Spring AOP 프록시가 어떻게 끼어드는지, TransactionInterceptor#invoke 가 어떤 6단계를 거치는지, AOP Alliance 의 MethodInvocation.proceed() 가 왜 raw target 을 호출하는지. 그리고 같은 함정에 걸리는 어노테이션이 6개나 더 있다는 것까지.

이 글은 그 분해의 기록입니다.

  1. 사건successes=100 / finalBalance=100 측정값으로 self-invocation 발견
  2. Spring AOP 프록시 메커니즘 — JDK Dynamic Proxy vs CGLIB / ProxyFactory 호출 그래프
  3. TransactionInterceptor#invoke 6단계 — Spring 6 소스 라인 단위
  4. self-invocation 이 우회되는가MethodInvocation.proceed() 의 raw target 호출 위치
  5. 같은 함정에 걸리는 6 어노테이션@Async / @Cacheable / @Validated / @Retryable / @PreAuthorize
  6. 4 가지 워크어라운드 — 분리 빈 / getBean(self) / AopContext.currentProxy / AspectJ weaving
  7. 운영 진단법 — 측정값의 모순 으로 발견하는 패턴
  8. 우아한 이게 왜 롤백돼? 사례와의 결합 — REQUIRED rollback-only 의 함정

결론부터 말하면:

머릿속의 “같은 클래스 내부 호출은 프록시 우회된다”가 그런지, 그리고 그게 어떻게 측정값으로 드러나는지 라인 단위로 풀어봅니다.


1. 사건 — successes=100 인데 잔액이 그대로

1.1 측정 시나리오

크레딧 차감 도메인의 락 4종 비교 측정 중이었습니다. 같은 시나리오 (잔액 100 / 100 worker / 1씩 차감) 를 4 락 (낙관 / 비관 / MySQL GET_LOCK / Redisson) 으로 보호해서 처리량 + 정확성을 비교하는 [실측] 입니다.

비관락은 깔끔히 끝났습니다.

Strategy: 비관락 (FOR UPDATE)
totalMs: 180          ⭐
successes: 100
fails: 0
finalBalance: 0       ✅ 정확

GET_LOCK 도, Redisson 도 결과가 나왔습니다. 그런데 낙관락 차례에서 측정값이 이상했습니다.

Strategy: 낙관락 (@Version)
totalMs: 549
successes: 100         ← 100% 성공 보고
fails: 0
finalBalance: 100      ← ?? 잔액 그대로

100 worker 가 모두 성공 했다고 보고하는데 잔액이 한 푼도 안 줄었습니다. 비관락 결과 (finalBalance: 0) 와 비교하면 명백한 모순.

1.2 1차 의심 — 코드 logic

먼저 코드를 의심했습니다.

@Service
public class CreditDeductionService {

    public void deductOptimistic(long accountId, long amount) {
        boolean done = false;
        int retry = 0;
        while (!done && retry < 50) {
            try {
                deductOptimisticOnce(accountId, amount);  // ← 핵심
                done = true;
            } catch (ObjectOptimisticLockingFailureException e) {
                retry++;
            }
        }
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deductOptimisticOnce(long accountId, long amount) {
        AccountBalance acc = repo.findByAccountId(accountId);
        if (acc.getBalance() < amount) {
            throw new InsufficientBalanceException();
        }
        acc.deduct(amount);  // entity 의 setter — balance -= amount
        // dirty checking 으로 commit 시 UPDATE 발행 예상
    }
}

acc.deduct(amount) 는 명백히 잔액을 감소시킵니다. JPA 의 dirty checking 메커니즘으로 — @Transactional 메서드가 끝나면 EntityManager 가 변경된 entity 를 감지해서 UPDATE 쿼리를 발행해야 합니다.

그런데 측정값은 0 deductions. 의심이 들어 Hibernate SQL 로그를 켰습니다.

1.3 SQL 로그 — UPDATE 가 발행되지 않았다

Hibernate: select ab.id, ab.account_id, ab.balance, ab.version, ab.updated_at
           from account_balance ab where ab.account_id = ?
Hibernate: select ab.id, ab.account_id, ab.balance, ab.version, ab.updated_at
           from account_balance ab where ab.account_id = ?
... (100번 반복)

SELECT 만 100번 / UPDATE 0번. 100 worker 가 모두 SELECT 만 하고 끝났습니다. JPA 의 dirty checking 이 작동하지 않은 것.

여기서 의심의 방향이 바뀌었습니다. acc.deduct(amount)Java 객체 안에서만 변경되고 있었고, @Transactional 이 발동하지 않아 commit 시 flush 가 일어나지 않은 것. 즉 EntityManager 자체가 없는 상태 에서 호출된 것이라는 뜻.

1.4 발견 — same-class call

deductOptimistic 안에서 deductOptimisticOnce 를 호출하는 그 위치 가 문제였습니다.

@Service
public class CreditDeductionService {

    public void deductOptimistic(...) {
        // ↓ 여기 — 같은 클래스 내부 호출
        deductOptimisticOnce(...);  // = this.deductOptimisticOnce(...)
    }

    @Transactional(propagation = REQUIRES_NEW)
    public void deductOptimisticOnce(...) { ... }
}

Java 에서 deductOptimisticOnce(...) 는 컴파일러가 this.deductOptimisticOnce(...) 로 해석합니다. 그리고 thisSpring AOP 프록시가 아닌 raw target 객체 — 이 호출은 프록시 chain 을 거치지 않고 직접 raw method 로 jump 합니다.

@Transactional 어노테이션이 붙어 있어도 — 그 어노테이션을 실행하는 TransactionInterceptor호출되지 않는다. 그래서 트랜잭션이 시작되지 않고, EntityManager 가 열리지 않고, dirty checking 도 작동하지 않고, UPDATE 가 발행되지 않습니다.

이것이 self-invocation 함정. 알고는 있었지만, 측정값의 모순 (successes=100 + 0 effect) 으로 발견 한 건 처음이었습니다.

이제 이 함정을 왜 그런지 부터 어떻게 진단하는지 까지 풀어봅니다.


2. Spring AOP — 프록시로 어떻게 advice 가 끼어드는가

2.1 두 종류의 프록시 — JDK Dynamic Proxy vs CGLIB

Spring AOP 가 사용하는 프록시는 두 종류입니다.

(1) JDK Dynamic Proxyjava.lang.reflect.Proxy 기반. 인터페이스 를 구현한 프록시 객체를 런타임에 생성. InvocationHandler#invoke(proxy, method, args) 가 모든 호출을 가로채는 단일 진입점.

// 인터페이스 필요
interface AccountService {
    void deduct(long id, long amount);
}

// Spring 이 런타임에 생성하는 프록시
AccountService proxy = (AccountService) Proxy.newProxyInstance(
    classLoader,
    new Class[] { AccountService.class },
    new TransactionInterceptor()  // InvocationHandler
);

(2) CGLIBorg.springframework.cglib.proxy.Enhancer 기반. 구체 클래스 를 상속받는 서브클래스를 런타임에 생성. MethodInterceptor#intercept(obj, method, args, methodProxy) 가 모든 호출을 가로챔.

// 인터페이스 불필요 — 클래스 자체를 상속
public class CreditDeductionService { ... }  // raw

// Spring 이 런타임에 생성하는 프록시 (CGLIB)
public class CreditDeductionService$$EnhancerByCGLIB extends CreditDeductionService {
    @Override
    public void deductOptimistic(...) {
        // intercept() 호출 → TransactionInterceptor 실행
    }
}

2.2 Spring 6 의 기본값 변경 — proxyTargetClass=true

Spring Framework 5 까지는 기본값이 proxyTargetClass=false — 인터페이스가 있으면 JDK Dynamic Proxy, 없으면 CGLIB. Spring Boot 2.x 부터 기본값을 proxyTargetClass=true 로 강제 — 항상 CGLIB 사용.

이 변경은 Spring Boot 2.0 release notes 에 명시되어 있습니다. 이유는 AOP 동작의 일관성. JDK Dynamic Proxy 는 인터페이스에 정의된 메서드만 가로챌 수 있고, 인터페이스에 없는 public 메서드는 가로채지 못해서 놀라운 행동 을 만듭니다.

본 측정 환경 (Spring Boot 3.4) 에서는 CGLIB 가 사용됩니다. 즉 CreditDeductionService 의 서브클래스가 런타임에 생성되어 있고, 그 서브클래스가 모든 외부 호출을 가로채서 TransactionInterceptor 로 위임합니다.

(심도) 왜 Spring 이 *런타임* 에 프록시를 만드는가 (펼치기)

Spring 의 AOP 가 런타임 위빙 (runtime weaving) 을 선택한 이유는 비침습성. 코드 작성 시점에 어떤 advice 가 붙을지 알 수 없어도, Bean 등록 시점BeanPostProcessor (AnnotationAwareAspectJAutoProxyCreator) 가 모든 빈을 검사해서 — 어노테이션이 매칭되는 빈만 프록시로 감쌉니다.

Spring ApplicationContext 부팅:
  1. BeanFactory 가 모든 BeanDefinition 등록
  2. BeanFactoryPostProcessor 들이 정의 변경
  3. Bean 인스턴스화 (constructor 호출)
  4. BeanPostProcessor#postProcessAfterInitialization 호출
       └─ AnnotationAwareAspectJAutoProxyCreator 가 빈 검사
       └─ @Transactional / @Async / @Cacheable 어노테이션 매칭
       └─ 매칭되면 ProxyFactory 로 프록시 생성 → 빈 교체
  5. 컨테이너가 *프록시* 를 의존성 주입에 사용

대안은 컴파일 타임 위빙 (AspectJ Compile-Time Weaving) 이나 로드 타임 위빙 (Load-Time Weaving). 그 차이는 §6 에서 다룹니다.

2.3 ProxyFactory 호출 그래프

Spring 이 프록시를 만들 때 호출하는 클래스 그래프는 다음과 같습니다.

ProxyFactory
  └─ DefaultAopProxyFactory#createAopProxy(AdvisedSupport)
       ├─ if (interface 있고 proxyTargetClass=false)
       │    └─ JdkDynamicAopProxy
       └─ else
            └─ ObjenesisCglibAopProxy
                 └─ Enhancer.create() → 서브클래스 생성

이 호출 그래프는 Spring source DefaultAopProxyFactory.java 에 정의되어 있습니다. 핵심 로직은 35줄 정도로 짧지만, 이 결정이 본 측정의 결과를 좌우합니다.

// DefaultAopProxyFactory.java (간략화)
public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException {
    if (config.isOptimize() || config.isProxyTargetClass()
            || hasNoUserSuppliedProxyInterfaces(config)) {
        Class<?> targetClass = config.getTargetClass();
        if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) {
            return new JdkDynamicAopProxy(config);
        }
        return new ObjenesisCglibAopProxy(config);  // ← 본 측정에서는 이 경로
    }
    return new JdkDynamicAopProxy(config);
}

본 측정에서 CreditDeductionService 는 인터페이스가 없는 구체 클래스 + Spring Boot 의 proxyTargetClass=true 기본값 — CGLIB 경로.

2.4 프록시 객체의 호출 흐름

런타임에 생성된 CGLIB 프록시 (CreditDeductionService$$EnhancerByCGLIB) 가 메서드를 호출받으면 다음 흐름이 실행됩니다.

sequenceDiagram
    participant Caller as 외부 호출자
    participant Proxy as CGLIB 프록시
    participant Interceptor as TransactionInterceptor
    participant TM as PlatformTransactionManager
    participant Target as raw CreditDeductionService

    Caller->>Proxy: deductOptimisticOnce()
    Proxy->>Interceptor: intercept(method, args)
    Interceptor->>TM: getTransaction(definition)
    TM-->>Interceptor: TransactionStatus (DB connection 획득)
    Interceptor->>Target: method.invoke() ← raw 호출
    Target-->>Interceptor: 정상 / 예외
    alt 정상
        Interceptor->>TM: commit(status) ← flush + UPDATE 발행
    else 예외
        Interceptor->>TM: rollback(status)
    end
    Interceptor-->>Proxy: 결과 반환
    Proxy-->>Caller: 결과 반환

핵심: 외부 호출자가 프록시를 통해 호출TransactionInterceptor 가 트랜잭션을 시작 → raw target 호출 → 결과 반환 → TransactionInterceptor 가 commit/rollback.

이 chain 의 어느 단계도 raw target 안에서 같은 객체의 다른 메서드 호출 을 가로채지 않습니다. 프록시는 외부 에 있고, raw target 의 this내부 라서 — this.method() 는 프록시 chain 을 우회합니다.

이게 self-invocation 의 본질입니다. 다음 §3 에서 TransactionInterceptor#invoke 안을 라인 단위로 들어갑니다.


3. TransactionInterceptor#invoke — 6단계 분해

@Transactional 의 실체는 TransactionInterceptor 클래스입니다. 이 클래스의 invoke(MethodInvocation) 메서드가 모든 @Transactional 호출을 처리합니다.

3.1 호출 진입점

// TransactionInterceptor.java
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
    Class<?> targetClass = (invocation.getThis() != null
        ? AopUtils.getTargetClass(invocation.getThis()) : null);

    return invokeWithinTransaction(
        invocation.getMethod(),     // 호출된 메서드
        targetClass,                // raw 타겟 클래스
        new CoroutinesInvocationCallback() {
            @Override
            public Object proceedWithInvocation() throws Throwable {
                return invocation.proceed();  // ← raw target 호출 위치
            }
        }
    );
}

invocation.proceed() — 이 한 줄이 본 글의 핵심입니다. AOP Alliance 의 MethodInvocation 인터페이스 spec 에 따르면, proceed()다음 advice 가 있으면 그것을 호출하고, 마지막 advice 면 raw target 의 method 를 호출 합니다. 즉 advice chain 의 에서 raw target 으로 jump.

3.2 6단계 시퀀스

invokeWithinTransaction 안의 핵심 로직을 6단계로 분해하면:

// TransactionAspectSupport#invokeWithinTransaction (간략화)
protected Object invokeWithinTransaction(Method method, Class<?> targetClass,
        InvocationCallback invocation) throws Throwable {

    TransactionAttributeSource tas = getTransactionAttributeSource();
    TransactionAttribute txAttr = (tas != null
        ? tas.getTransactionAttribute(method, targetClass) : null);
    PlatformTransactionManager tm = determineTransactionManager(txAttr);
    String joinpointIdentification = methodIdentification(method, targetClass, txAttr);

    if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
        // (1) 트랜잭션 시작
        TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);

        Object retVal;
        try {
            // (2) raw target 호출 ← 여기서 실제 비즈니스 로직 실행
            retVal = invocation.proceedWithInvocation();
        }
        catch (Throwable ex) {
            // (3) 예외 시 rollback 결정
            completeTransactionAfterThrowing(txInfo, ex);
            throw ex;
        }
        finally {
            // (4) TransactionInfo 정리 (ThreadLocal 복원)
            cleanupTransactionInfo(txInfo);
        }

        // (5) 비동기 / Coroutines 결과 처리
        if (retVal != null && txAttr != null) {
            TransactionStatus status = txInfo.getTransactionStatus();
            if (status != null) {
                if (retVal instanceof CompletableFuture<?> future) {
                    retVal = future.whenComplete((v, t) -> {
                        if (t == null) status.flush();
                    });
                }
            }
        }

        // (6) commit (정상 종료 시)
        commitTransactionAfterReturning(txInfo);
        return retVal;
    }
    // ... CallbackPreferring 분기 (생략)
}
단계동작효과
1createTransactionIfNecessaryDB connection 획득, @Transactional(propagation) 적용, EntityManager 열기, TransactionInfo ThreadLocal 등록
2invocation.proceedWithInvocation()raw target 의 메서드 실제 호출 (advice chain 의 끝)
3completeTransactionAfterThrowing예외가 rollbackFor 에 매칭되면 rollback, 아니면 commit
4cleanupTransactionInfoThreadLocal 의 TransactionInfo 복원
5CompletableFuture 결과 처리비동기 결과의 트랜잭션 완료 hook
6commitTransactionAfterReturningflush + commit (여기서 dirty checking 으로 인한 UPDATE 발행)

본 측정에서 단계 6 의 commitTransactionAfterReturning호출되지 않은 것finalBalance=100 의 직접 원인이었습니다. self-invocation 으로 invoke 자체가 호출되지 않았으니 6 단계 모두 skip.

3.3 단계 1 의 전파 (propagation) 분기

createTransactionIfNecessary 안에서 PROPAGATION 7 종이 분기됩니다. 핵심은 AbstractPlatformTransactionManager#handleExistingTransaction 의 7 분기:

// AbstractPlatformTransactionManager.java
private TransactionStatus handleExistingTransaction(
        TransactionDefinition definition, Object transaction, boolean debugEnabled)
        throws TransactionException {

    if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NEVER) {
        throw new IllegalTransactionStateException(...);  // 기존 Tx 있으면 에러
    }
    if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NOT_SUPPORTED) {
        Object suspendedResources = suspend(transaction);  // 기존 Tx 보류
        return prepareTransactionStatus(...);
    }
    if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW) {
        SuspendedResourcesHolder suspendedResources = suspend(transaction);  // 기존 보류
        try {
            return startTransaction(definition, transaction, false, debugEnabled, suspendedResources);
            // ↑ 새 Tx 시작 — *별개의* DB connection
        } catch (RuntimeException | Error beginEx) {
            resumeAfterBeginException(transaction, suspendedResources, beginEx);
            throw beginEx;
        }
    }
    if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NESTED) {
        if (!isNestedTransactionAllowed()) { ... }
        if (useSavepointForNestedTransaction()) {
            // Savepoint 사용 (JDBC 3.0)
            DefaultTransactionStatus status = prepareTransactionStatus(definition, transaction,
                false, false, debugEnabled, null);
            status.createAndHoldSavepoint();
            return status;
        }
        // ...
    }
    // PROPAGATION_REQUIRED / SUPPORTS / MANDATORY 분기 (생략)
}

이 7 분기 중 본 측정 코드는 REQUIRES_NEW 를 사용했습니다. self-invocation 으로 이 코드 자체가 호출되지 않았으니 — 7 분기 어디로도 가지 않고, 새 트랜잭션도 시작되지 않고, dirty checking 도 작동하지 않았습니다.

3.4 단계 6 의 commit 시퀀스

commitTransactionAfterReturning 의 commit 시퀀스는 다음과 같습니다.

TransactionInterceptor.commitTransactionAfterReturning(txInfo)
  └─ AbstractPlatformTransactionManager.commit(status)
       ├─ prepareForCommit(status)
       ├─ triggerBeforeCommit(status)  ← @TransactionalEventListener(BEFORE_COMMIT)
       ├─ doCommit(status)
       │    └─ HibernateTransactionManager.doCommit()
       │         └─ Session.flush()  ← dirty checking + UPDATE 발행
       │              └─ Session.getTransaction().commit()
       ├─ triggerAfterCommit(status)  ← @TransactionalEventListener(AFTER_COMMIT)
       └─ triggerAfterCompletion(status, COMMIT)

Session.flush() 가 dirty checking 을 트리거하는 위치. self-invocation 으로 commit 자체가 호출되지 않았으니 — flush 도 안 일어나고, acc.deduct(amount) 의 변경이 DB 에 반영되지 않습니다. successes=100 / finalBalance=100 의 측정값이 정확히 이 메커니즘의 결과 입니다.

다음 §4 에서 self-invocation 이 이 6 단계를 우회하는지 — 바이트코드 수준에서 봅니다.


4. self-invocation 이 우회되는가 — 바이트코드 수준

4.1 한 줄 코드의 두 모습

같은 메서드 안에서 다른 메서드를 호출하는 두 가지 방식.

// (a) 같은 클래스 내부 호출 — self-invocation
public void deductOptimistic(...) {
    deductOptimisticOnce(...);  // = this.deductOptimisticOnce(...)
}

// (b) 외부 빈 호출
public void deductOptimistic(...) {
    optimisticExecutor.deductOnce(...);  // 다른 빈
}

(a) 와 (b) 는 코드 한 줄 차이지만, 바이트코드 수준에서 완전히 다른 동작.

4.2 (a) 의 바이트코드 — invokevirtual direct dispatch

javac 로 (a) 를 컴파일하면 다음 바이트코드가 생성됩니다.

public void deductOptimistic(long, long);
  Code:
    0: aload_0                        // this 를 스택에
    1: lload_1                        // accountId
    2: lload_3                        // amount
    3: invokevirtual #X               // CreditDeductionService.deductOptimisticOnce(long, long)V
    6: return

핵심은 invokevirtual 의 타겟. this런타임 클래스 의 메서드를 호출합니다. 그런데 thisraw CreditDeductionService 객체 입니다 (CGLIB 프록시가 아닌). 왜냐하면 deductOptimistic 메서드가 raw target 안에서 실행 중 이라 this 가 raw 객체를 가리키기 때문.

따라서 invokevirtualraw target 의 deductOptimisticOnce 를 직접 호출 — CGLIB 프록시의 intercept 를 거치지 않음. 즉 TransactionInterceptor#invoke 가 호출되지 않습니다.

4.3 (b) 의 바이트코드 — 외부 빈 호출

public void deductOptimistic(long, long);
  Code:
    0: aload_0                        // this 를 스택에
    1: getfield #Y                    // CreditDeductionService.optimisticExecutor (필드)
    4: lload_1
    5: lload_3
    6: invokevirtual #Z               // OptimisticDeductExecutor.deductOnce(long, long)V
    9: return

optimisticExecutor 필드는 Spring 이 의존성 주입한 프록시 객체 입니다. invokevirtual 의 타겟은 런타임 클래스 — 즉 OptimisticDeductExecutor$$EnhancerByCGLIBdeductOnce 메서드.

이 호출은 CGLIB 프록시의 intercept 를 거쳐 TransactionInterceptor#invoke 로 들어갑니다. 6 단계 시퀀스가 정상 작동하고, 트랜잭션 시작 → raw target 호출 → commit → flush → UPDATE 발행.

4.4 정확히 어디서 우회되는가

§2.4 의 시퀀스 다이어그램을 다시 보면, 외부 호출자 → 프록시 → TransactionInterceptor → raw target 의 chain 이 있습니다. self-invocation 은 raw target 안에서 발생 하므로 이미 chain 의 끝 (raw target 단계) 에 와 있는 상태입니다.

[외부 호출자]

[CGLIB 프록시] ← TransactionInterceptor 가 여기서 가로챔

[raw target] ← 현재 실행 중. 여기서 this.method() 호출하면?

[같은 raw target 의 다른 method] ← 프록시 거치지 않음 ❌

이 구조는 수정 불가능 — Spring 의 AOP 가 런타임 위빙 이라 raw target 의 this영원히 raw 객체를 가리킵니다. 같은 클래스 내부에서 어떻게 해도 프록시를 거치게 만들 수 없습니다.

§6 에서 이 한계를 우회하는 4 가지 방법을 봅니다. 그 전에 §5 에서 같은 함정이 어디까지 퍼져 있는지 봅니다.


5. 같은 함정 6 어노테이션 — Spring AOP 기반 모두 동일

self-invocation 함정은 @Transactional 만의 문제가 아닙니다. Spring AOP 프록시 기반의 모든 어노테이션이 같은 함정에 걸립니다. 본 측정에서 발견한 6개:

어노테이션AOP Interceptor함정 시 효과
@TransactionalTransactionInterceptor트랜잭션 미시작 → flush 미발생 → DB 반영 안 됨
@AsyncAsyncExecutionInterceptor동기 실행 → 호출 thread 가 그대로 실행
@CacheableCacheInterceptor캐시 조회 / 저장 모두 skip
@ValidatedMethodValidationInterceptor파라미터 validation 실행 안 됨
@RetryableRetryOperationsInterceptor재시도 로직 미작동
@PreAuthorize / @PostAuthorizeMethodSecurityInterceptor권한 체크 우회 ⚠️ 보안 사고

각 어노테이션은 서로 다른 Interceptor 를 사용하지만, 모두 Spring AOP 프록시 위에 빌드. 따라서 self-invocation 시 모두 같은 방식으로 우회 됩니다.

5.1 @Async 의 self-invocation 함정

@Service
public class NotificationService {
    
    public void sendBulk() {
        for (Notification n : list) {
            sendOne(n);  // ← self-invocation. 비동기 안 됨
        }
    }

    @Async
    public void sendOne(Notification n) { ... }
}

기대: 100건이 동시에 비동기 처리되어 200ms 안에 끝남. 실제: 100건이 순차 동기 처리되어 100건 × 외부 호출 시간 누적. 풀 점유 + 사용자 대기 + 풀 고갈 가능.

5.2 @Cacheable 의 self-invocation 함정

@Service
public class ProductService {

    public Product getWithDiscount(long id) {
        Product p = getProduct(id);  // ← self-invocation. 캐시 안 됨
        return applyDiscount(p);
    }

    @Cacheable("product")
    public Product getProduct(long id) { return repo.findById(id); }
}

기대: 같은 id 에 대해 캐시 히트. 실제: 매번 DB 조회 — 캐시 발동 안 됨. 부하 N배 증가.

5.3 @PreAuthorize 의 self-invocation 함정 — 보안 사고

@Service
public class AdminService {

    public void bulkDelete(List<Long> ids) {
        for (Long id : ids) {
            deleteUser(id);  // ← self-invocation. 권한 체크 우회 ⚠️
        }
    }

    @PreAuthorize("hasRole('SUPER_ADMIN')")
    public void deleteUser(long id) { ... }
}

기대: SUPER_ADMIN 권한 없으면 AccessDeniedException. 실제: 일반 ADMIN 도 bulkDelete 호출 가능 → deleteUser 가 권한 체크 없이 실행 → 권한 escalation 사고.

이 함정이 가장 위험합니다. @PreAuthorize 의 권한 체크가 우회되는데 — 측정값에 모순 이 안 드러납니다 (성공 응답 + 정상 동작 처럼 보임). 보안 감사 시점에야 발견되는 패턴.

5.4 함정의 공통 메커니즘

6개 어노테이션이 모두 같은 함정에 걸리는 이유:

@Transactional        → TransactionInterceptor → AOP 프록시
@Async                → AsyncExecutionInterceptor → AOP 프록시
@Cacheable            → CacheInterceptor → AOP 프록시
@Validated            → MethodValidationInterceptor → AOP 프록시
@Retryable            → RetryOperationsInterceptor → AOP 프록시
@PreAuthorize         → MethodSecurityInterceptor → AOP 프록시

모두 Spring AOP 위에 빌드 → 모두 self-invocation 시 우회. 따라서 워크어라운드도 같음. §6 에서 4 가지 우회 방법.


6. 워크어라운드 4종 — 어느 게 가장 안전한가

6.1 (1) 분리 빈 — 별도 @Service 로 추출 ⭐ (본 측정 채택)

가장 안전하고 명시적인 방법. self-invocation 이 발생하는 메서드를 별도 빈 으로 추출.

@Service
public class CreditDeductionService {
    private final OptimisticDeductExecutor optimisticExecutor;

    public void deductOptimistic(long accountId, long amount) {
        boolean done = false;
        int retry = 0;
        while (!done && retry < 50) {
            try {
                optimisticExecutor.deductOnce(accountId, amount);  // ← 외부 빈 호출
                done = true;
            } catch (ObjectOptimisticLockingFailureException e) {
                retry++;
            }
        }
    }
}

@Service
public class OptimisticDeductExecutor {
    private final AccountBalanceJpaRepository repo;

    @Transactional(propagation = REQUIRES_NEW)
    public void deductOnce(long accountId, long amount) {
        AccountBalance acc = repo.findByAccountId(accountId);
        if (acc.getBalance() < amount) {
            throw new InsufficientBalanceException();
        }
        acc.deduct(amount);
    }
}

장점:

단점:

본 측정에서 이 방법으로 fix → successes=100 / finalBalance=0 / 100 deductions 으로 정상 작동. 추천 워크어라운드 1순위.

6.2 (2) ApplicationContext#getBean(self) — self 프록시 명시 획득

같은 클래스 안에서 프록시 자기 자신 을 명시적으로 획득.

@Service
public class CreditDeductionService implements ApplicationContextAware {
    private ApplicationContext ctx;

    @Override
    public void setApplicationContext(ApplicationContext ctx) {
        this.ctx = ctx;
    }

    public void deductOptimistic(long accountId, long amount) {
        CreditDeductionService self = ctx.getBean(CreditDeductionService.class);
        self.deductOptimisticOnce(accountId, amount);  // 프록시 통한 호출
    }

    @Transactional(propagation = REQUIRES_NEW)
    public void deductOptimisticOnce(...) { ... }
}

장점:

단점:

테스트가 어려워서 최후의 수단.

6.3 (3) AopContext.currentProxy() — ThreadLocal 프록시 획득

AopContext 의 ThreadLocal 에서 현재 프록시를 가져오는 방법.

@Service
@EnableAspectJAutoProxy(exposeProxy = true)  // ← exposeProxy 필수
public class CreditDeductionService {

    public void deductOptimistic(long accountId, long amount) {
        CreditDeductionService self = (CreditDeductionService) AopContext.currentProxy();
        self.deductOptimisticOnce(accountId, amount);
    }

    @Transactional(propagation = REQUIRES_NEW)
    public void deductOptimisticOnce(...) { ... }
}

장점:

단점:

본 측정 환경 (단일 스레드 측정) 에선 작동하지만, 운영 시 비동기 호출 이 섞이면 NPE 가능. 비추천.

6.4 (4) AspectJ Compile-Time / Load-Time Weaving

Spring AOP 가 아닌 AspectJ 를 사용해서 바이트코드 자체를 변경.

Compile-Time Weaving (CTW): AspectJ Compiler (ajc) 가 빌드 시점에 @Transactional 메서드의 바이트코드를 직접 수정 — Interceptor 로직을 method body 에 inline. self-invocation 이건 외부 호출이건 모든 호출에서 advice 작동.

Load-Time Weaving (LTW): 클래스 로딩 시점에 Java agent (-javaagent:aspectjweaver.jar) 가 바이트코드를 변경.

@Configuration
@EnableLoadTimeWeaving(aspectjWeaving = AUTODETECT)
public class AspectJConfig { }

장점:

단점:

대부분 운영 환경에선 빌드/실행 환경 변경이 부담이라 분리 빈 을 선호. AspectJ 는 @Configurable (Spring 이 관리하지 않는 객체에 advice 적용) 같은 Spring AOP 가 못 하는 영역 에서만 사용.

6.5 비교 매트릭스

워크어라운드안전성명시성빌드 영향테스트 용이성추천
(1) 분리 빈⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐없음⭐⭐⭐⭐⭐1순위
(2) getBean(self)⭐⭐⭐⭐⭐없음⭐⭐최후의 수단
(3) AopContext.currentProxy()⭐⭐⭐⭐exposeProxy=true⭐⭐비추천
(4) AspectJ CTW/LTW⭐⭐⭐⭐⭐⭐⭐⭐⭐특수 상황 (e.g. @Configurable)

7. 운영 진단법 — 측정값의 모순 으로 발견

self-invocation 함정의 진짜 위험은 측정값에 모순이 있어야 발견된다는 점. 코드 리뷰만으로는 거의 못 잡습니다 — 같은 클래스 안에서 메서드 두 개 호출하는 게 자연스러워 보이기 때문.

7.1 본 측정의 모순 패턴

측정값의미
successes=100 / fails=0100% 성공 보고
finalBalance=1000 차감
두 측정값의 모순함수가 호출됐는데 효과가 0 = AOP 우회 신호

이 패턴은 어떤 어노테이션이건 동일하게 나타납니다.

7.2 6 어노테이션의 모순 신호

어노테이션모순 신호
@Transactional메서드 성공 + DB 반영 안 됨 (본 측정)
@Async메서드 성공 + thread name 호출 thread 와 동일 (-async-1 안 나옴)
@Cacheable같은 인자 두 번 호출 + DB 쿼리 두 번 발행
@Validated잘못된 인자 + ConstraintViolationException 안 발생
@Retryable예외 발생 + 재시도 0번
@PreAuthorize권한 없는 사용자 + 메서드 정상 실행 (보안 감사로만 발견)

7.3 진단 워크플로

1. 메트릭이 모순되는지 확인
   - 성공 카운터 vs 실제 효과
2. SQL 로그 / Hibernate trace 활성화
   - SELECT 만 / UPDATE 0
   - 트랜잭션 begin/commit 미발생
3. 호출 stack 검사
   - this.method() 형태가 있는지
   - 같은 클래스 안에서 advice 어노테이션 메서드 호출하는지
4. 의심 메서드를 외부 빈으로 분리
   - 측정 재실행
   - 모순 사라지면 self-invocation 확진

7.4 PR 리뷰 체크리스트 0번 항목

본 측정 이후 추가한 PR 리뷰 체크리스트 0번:

[Self-invocation Check] PR 의 변경된 클래스 안에서 같은 클래스의 다른 메서드를 직접 호출 하는가? 그 메서드에 @Transactional / @Async / @Cacheable / @Validated / @Retryable / @PreAuthorize하나라도 있으면 — 외부 빈으로 분리.

체크리스트 0번 (다른 항목보다 우선 검토) 으로 둔 이유는, 이 함정은 나중에 발견될수록 비용이 커지기 때문. 코드 리뷰 단계에서 잡아야 합니다.


8. 우아한 이게 왜 롤백돼? — 다른 함정과의 결합

self-invocation 함정은 다른 함정과 결합 될 때 진짜 무서워집니다. 우아한이 공유한 응? 이게 왜 롤백되는거지? 사례를 봅니다.

8.1 시나리오 — REQUIRED rollback-only

@Service
public class OuterService {

    @Transactional  // PROPAGATION_REQUIRED
    public void process() {
        try {
            innerService.failingMethod();
        } catch (Exception e) {
            // 예외 무시하고 진행
            logger.warn("inner failed, continuing", e);
        }
        // ↓ 여기서 UnexpectedRollbackException 발생
        repo.save(...);  // 정상처럼 보이지만 commit 시 rollback
    }
}

@Service
public class InnerService {

    @Transactional  // PROPAGATION_REQUIRED — 같은 Tx 참여
    public void failingMethod() {
        throw new RuntimeException();
    }
}

기대: failingMethod 의 예외를 catch 하고 process 가 정상 commit. 실제: UnexpectedRollbackException 으로 전체 트랜잭션 rollback.

8.2 왜 그런가 — REQUIRED 의 rollback-only 마킹

PROPAGATION_REQUIRED 의 inner 가 예외 발생 시:

1. innerService.failingMethod() 호출
2. CGLIB 프록시 → TransactionInterceptor#invoke
3. 기존 Tx 발견 → 같은 Tx 참여 (PROPAGATION_REQUIRED)
4. raw target 호출 → RuntimeException
5. completeTransactionAfterThrowing
   └─ 기존 Tx 의 status 를 rollback-only 로 *마킹*
6. 외부에 RuntimeException 던짐
7. process() 가 catch — 예외 무시
8. process() 종료 시 commit 시도
9. PlatformTransactionManager 가 status 의 rollback-only 발견
10. UnexpectedRollbackException 발생 → 전체 rollback

핵심: inner 가 예외를 던지면 기존 Tx 의 status 가 rollback-only 로 마킹. outer 가 catch 해도 — 마킹은 지워지지 않음. commit 시점에 발견되어 UnexpectedRollbackException.

8.3 self-invocation 과 결합되면

@Service
public class CombinedService {

    @Transactional  // outer Tx
    public void process() {
        try {
            innerMethod();  // ← self-invocation. 같은 Tx ?
        } catch (Exception e) {
            logger.warn("inner failed", e);
        }
        repo.save(...);
    }

    @Transactional(propagation = REQUIRES_NEW)  // 새 Tx 의도
    public void innerMethod() {
        throw new RuntimeException();
    }
}

기대: innerMethod별개 Tx 에서 실행 → rollback 도 별개 → outer 가 catch → outer 정상 commit.

실제: self-invocation 으로 innerMethod@Transactional(REQUIRES_NEW) 가 발동 안 함 → outer Tx 안에서 직접 실행 → RuntimeException → outer Tx rollback-only → UnexpectedRollbackException.

이것이 우아한 회고에서 본 결합 함정 의 본질. 두 함정이 따로 있을 때보다 함께 있을 때 디버깅이 훨씬 어렵습니다.

8.4 진단 + 처방

1. UnexpectedRollbackException stack trace 보기
2. 어느 메서드의 commit 시점에 터졌는지 확인
3. 그 메서드 안에서 catch 한 예외가 있는지
4. 그 예외를 던진 메서드가
   (a) 같은 클래스 내부 호출이라면 → self-invocation 함정 (REQUIRES_NEW 가 발동 안 함)
   (b) 외부 빈 호출이지만 PROPAGATION_REQUIRED 라면 → rollback-only 마킹 함정
5. (a) 면 분리 빈 / (b) 면 PROPAGATION_REQUIRES_NEW 로 변경

9. 결론 — 글로벌 시니어가 보는 self-invocation

9.1 핵심 이해

이해
L1 표면”같은 클래스 내부 호출은 @Transactional 안 먹힘”
L2 메커니즘Spring AOP 가 런타임 위빙 이라 raw target 의 this 는 raw 객체. CGLIB 프록시 chain 우회
L2.5 소스TransactionInterceptor#invoke 의 6 단계 중 단계 1, 6 (begin / commit) skip — flush 미발생
L3 실측W3 ⑤ 측정에서 successes=100 / finalBalance=100 모순 으로 발견
L4 운영6 어노테이션 (@Transactional / @Async / @Cacheable / @Validated / @Retryable / @PreAuthorize) 모두 동일 함정. 우아한 이게 왜 롤백돼? 사례와 결합 시 디버깅 비용 폭증

9.2 워크어라운드 우선순위

  1. 분리 빈 (가장 안전) — 외부 빈으로 추출. 명시적 + 테스트 용이
  2. AspectJ CTW/LTW (특수 상황) — @Configurable 같이 Spring AOP 한계 영역
  3. getBean(self) (최후) — 의존성 역전 부담
  4. AopContext.currentProxy() (비추천) — ThreadLocal 의존

9.3 운영 점검 체크리스트

9.4 다음 글에서 다룰 것

본 글은 self-invocation 단일 함정 분해. 시리즈의 다음 글들:


10. 참고자료

공식 문서 (1순위)

Spring 6 / Hibernate 6 소스 (직접 인용)

한국 빅테크 회고

Vlad Mihalcea (Hibernate Steering Committee)

본인 측정 자산

AOP 학술 기원


Share this post on:

Previous Post
[JPA + Spring Mastery 08] 트랜잭션 분리 패턴 — Saga / Outbox / REQUIRES_NEW, 학술 기원부터 EXP-09b 9 시나리오 실측까지
Next Post
프로덕션이 'Check failed: node->IsInUse()' 한 줄로 죽었습니다 (1) — V8 GlobalHandles 해부