Java 아키텍처·Spring 용어 사전
Spring 코어self-invocation · 내부 호출

자기 호출

같은 클래스 안에서 자기 메서드를 부르면 프록시를 거치지 않아 애노테이션이 무시되는 현상.

같은 클래스 안에서 자기 메서드를 직접 호출하면 프록시를 거치지 않아, @Transactional 같은 애노테이션이 조용히 무시되는 현상.

왜 안 걸리나

@Service
class OrderService {
    public void outer() {
        inner();                 // ← this.inner() 다. 프록시가 아니라 원본을 직접 부른다
    }

    @Transactional
    public void inner() { ... }  // 트랜잭션이 시작되지 않는다
}
호출자 → [프록시] → 원본.outer()
                        │
                        └ this.inner()      ← 프록시를 우회한다
                          (프록시는 outer 진입만 가로챘다)

프록시는 바깥에서 들어오는 호출만 가로챈다. 객체 내부의 호출은 볼 수 없다.

확인하는 법

// 트랜잭션이 실제로 걸렸는지
System.out.println(TransactionSynchronizationManager.isActualTransactionActive());  // false

// 로그로 보기
// logging.level.org.springframework.transaction.interceptor=TRACE

로그에 Getting transaction for [...]이 안 찍히면 프록시를 안 탄 것이다.

해결하는 세 가지

// ① 클래스를 분리한다 (권장 — 책임도 함께 나뉜다)
@Service
class OrderService {
    private final OrderTxService tx;
    public void outer() { tx.inner(); }      // 다른 빈이므로 프록시를 탄다
}

// ② 자기 자신을 주입받는다 (순환 참조라 @Lazy 가 필요하다)
@Service
class OrderService {
    @Lazy private final OrderService self;
    public void outer() { self.inner(); }
}

// ③ AopContext 로 현재 프록시를 꺼낸다 (설정이 필요하고 가독성이 나쁘다)
// @EnableAspectJAutoProxy(exposeProxy = true)
public void outer() { ((OrderService) AopContext.currentProxy()).inner(); }

①이 정석이다. ②③은 문제를 덮는 쪽에 가깝고, 특히 ③은 코드를 읽는 사람에게 의도가 전달되지 않는다.

같은 원인의 형제 증상

@Cacheable 이 안 먹는다 @Async 가 동기로 돈다 @Retryable 이 재시도하지 않는다

  • @PreAuthorize 가 통과된다 — ← 보안 사고가 된다

전부 프록시를 안 탄 것이 원인이다. 하나를 이해하면 나머지가 같이 풀린다.

면접 함정

  • "private 메서드에 @Transactional을 붙이면 된다" → 프록시가 오버라이드할 수 없어 역시 무시된다.
  • "예외가 나니까 안 걸린 걸 알 수 있다" → 아무 예외도 안 난다. 트랜잭션 없이 그냥 실행된다.

왜 예외가 안 나는가

프록시는 '메서드 호출을 가로채는' 방식이다

  • 가로챌 기회가 없으면 그냥 원본이 실행될 뿐이다
  • 애노테이션은 메타데이터일 뿐 그 자체로 아무 동작도 하지 않는다

그래서 컴파일 오류도 런타임 예외도 나지 않는다 "조용히 아무 일도 안 일어난다" 가 이 함정이 위험한 이유다

테스트로 잡는다

@Test
void 트랜잭션이_실제로_걸리는지() {
    assertThatThrownBy(() -> service.outerThatShouldRollback())
        .isInstanceOf(RuntimeException.class);
    assertThat(repo.count()).isZero();      // 롤백됐어야 한다
}

"애노테이션을 붙였다"가 아니라 "실제로 롤백되는가"를 테스트해야 이 유형의 버그가 잡힌다.

정적 분석으로 예방하기

ArchUnit · 커스텀 린트 규칙으로

  • "같은 클래스 안에서 @Transactional 메서드를 호출하지 않는다" 를 검사할 수 있다 리뷰에만 의존하면 반드시 새어 나간다

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — Spring 핵심 — IoC·DI·AOP