들어가며
같은 클래스 안에서 메서드가 자기 자신을 this.메서드()로 호출하면, 그 메서드에 붙은 @Transactional이 동작하지 않는다. 이걸 self-invocation 문제라고 부른다.
이유는 한 줄로 정리된다. Spring AOP는 원본 객체를 프록시로 감싸서 트랜잭션 같은 부가 기능을 프록시에만 심어두는데, 내부 호출(this)은 그 프록시를 거치지 않기 때문이다. (프록시가 정확히 어떻게 동작하고 어떻게 만들어지는지는 별도 글에서 다룬다.)
이 글에서는 self-invocation이 실무에서 @Transactional, 특히 REQUIRES_NEW를 쓸 때 어떻게 터지는지와, 어떻게 해결하는지에 집중한다.
1. self-invocation이란
먼저 현상부터 보자. foo()가 같은 클래스의 bar()를 호출하는 객체가 있다.
public class SimplePojo implements Pojo {
@Override
public void foo() {
log.info("### foo");
bar(); // this.bar()
}
@Override
public void bar() {
log.info("### bar");
}
}
Java
복사
그리고 메서드 호출 전에 메서드 이름을 찍는 부가 기능(advice)을 준비한다. >>> execute method [...] 로그를 찍는 주체가 바로 이 객체다.
@Slf4j
public class ExecuteLoggingAdvice implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info(">>> execute method [{}]", invocation.getMethod().getName()); // 부가 기능
return invocation.proceed(); // 실제 타겟 메서드 호출
}
}
Java
복사
이제 SimplePojo를 프록시로 감싸면서 이 ExecuteLoggingAdvice를 붙인다.
ProxyFactory factory = new ProxyFactory(new SimplePojo());
factory.addInterface(Pojo.class);
factory.addAdvice(new ExecuteLoggingAdvice()); // ★ 로그 advice 등록
Pojo pojo = (Pojo) factory.getProxy();
pojo.foo();
Java
복사
foo()를 호출하면 직관적으로는 foo와 bar 양쪽 모두 advice 로그가 찍힐 것 같다. 하지만 실제로는 foo만 찍히고 bar는 찍히지 않는다.
>>> execute method [foo]
### foo
### bar ← advice 없이 그냥 실행됨
Java
복사
프록시를 거쳐 foo()까지는 부가 기능이 잘 적용됐는데, foo() 내부에서 부른 bar()에는 적용되지 않았다. 이것이 self-invocation이다.
핵심 원리만 짚자면, 부가 기능은 프록시에만 들어있다. 원본 타겟 객체는 자기를 감싼 프록시의 존재조차 모른다. 그래서 타겟 메서드 안에서 this.bar()를 부르면, this는 프록시가 아니라 순수한 원본 객체이므로 부가 기능을 거칠 방법이 없다. 이것이 프록시 기반 AOP의 구조적 한계다.
Spring 공식 문서도 같은 설명을 한다. 호출이 타겟 객체에 도달한 이후 그 객체가 this.bar()처럼 자기 자신을 호출하면, 그 호출은 프록시가 아니라 this 참조에 대해 일어나므로 advice가 적용될 기회가 없다.
2. 실무에서 터지는 지점: @Transactional과 REQUIRES_NEW
self-invocation이 가장 치명적으로 드러나는 곳이 트랜잭션 전파 옵션, 특히 REQUIRES_NEW다.
REQUIRES_NEW의 핵심은 **"기존 트랜잭션이 있어도 그걸 잠시 보류(suspend)하고 완전히 독립된 새 트랜잭션을 시작한다"**는 것이다. 대표 용도가 "메인 로직은 실패하면 롤백하되, 시도 이력/로그는 무조건 남기고 싶다"는 경우다.
그런데 아래처럼 짜면 의도와 정반대 결과가 나온다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OrderLogRepository orderLogRepository;
@Transactional
public void placeOrder(Order order) {
saveLog(order.getId(), "주문 처리 시작"); // this.saveLog() 내부 호출!
orderRepository.save(order);
if (order.getAmount() <= 0) {
throw new IllegalStateException("잘못된 금액");
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW) // 붙어있어도 무시됨
public void saveLog(Long orderId, String message) {
orderLogRepository.save(new OrderLog(orderId, message));
}
}
Java
복사
this.saveLog()는 내부 호출이라 프록시를 안 거치고, REQUIRES_NEW가 완전히 무시된 채 그냥 placeOrder의 트랜잭션 안에서 실행된다. 결국 예외가 터지면 로그도 메인과 함께 롤백되어 사라진다. 분리하고 싶었던 로그가 같이 날아가는, 의도와 정반대 결과다.
한 가지 헷갈리기 쉬운 점. 만약 OrderLogRepository가 JpaRepository를 상속했다면, 구현체인 SimpleJpaRepository.save에 자체 @Transactional이 붙어 있어 저장 자체는 될 수도 있다. 하지만 그건 우리가 의도한 REQUIRES_NEW 트랜잭션 덕분이 아니라 JPA 구현체 내부 트랜잭션 덕분일 뿐이다. 어노테이션은 멀쩡히 붙어있는데 조용히 안 먹으니 디버깅도 까다롭다.
3. 해결 방법
핵심 원리는 하나다. @Transactional 메서드는 반드시 프록시를 거쳐 호출돼야 발동한다. 이 조건을 만족시키는 방법들이다.
3-1. 정석: 하위 레이어 컴포넌트로 분리
REQUIRES_NEW 메서드를 다른 빈으로 빼면, 그 빈의 프록시를 거치는 외부 호출이 되어 정상 동작한다.
이때 추출한 빈을 동료 Service로 두고 Service → Service로 호출하면, 지금은 괜찮아도 나중에 반대 방향 의존이 끼어 순환 참조로 번질 수 있다. 그래서 추출 대상은 **오직 Repository에만 의존하는 하위 레이어 컴포넌트(leaf)**로 두는 게 안전하다. 의존 그래프의 말단이라 구조적으로 사이클이 생길 수 없다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OrderLogWriter orderLogWriter; // ★ 하위 leaf 컴포넌트
@Transactional
public void placeOrder(Order order) {
orderLogWriter.append(order.getId(), "주문 처리 시작"); // 다른 빈 호출 → 프록시 거침
orderRepository.save(order);
if (order.getAmount() <= 0) {
throw new IllegalStateException("잘못된 금액");
}
}
}
// 오직 Repository에만 의존하는 leaf 컴포넌트. 어떤 Service도 주입받지 않는다.
@Component
@RequiredArgsConstructor
public class OrderLogWriter {
private final OrderLogRepository orderLogRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW) // ★ 이제 진짜 발동
public void append(Long orderId, String message) {
orderLogRepository.save(new OrderLog(orderId, message));
}
}
Java
복사
동작 결과:
1.
placeOrder가 트랜잭션 A 시작
2.
orderLogWriter.append() → A를 잠시 보류, 새 트랜잭션 B 시작 → 로그 저장 후 B 즉시 커밋 → A 재개
3.
orderRepository.save(order)는 A에 속함
4.
예외 발생 → A 롤백 → 주문은 사라짐
5.
로그(B)는 이미 커밋됐으므로 DB에 남음 
책임을 분리하면서 self-invocation도 자연스럽게 사라지고, leaf로 두었기에 순환 참조 걱정도 없다. 사실상 대부분의 경우 이 방법이면 충분하다. (참고로 OrderLogWriter가 어떤 서비스도 주입받지 않는 한, 생성자 주입이라 혹시 사이클이 생기더라도 Spring이 기동 시점에 바로 에러로 잡아준다.)
주의: REQUIRES_NEW의 커넥션 점유
REQUIRES_NEW는 바깥 트랜잭션을 보류한 채 새 커넥션으로 돌기 때문에 그 순간 커넥션을 2개 동시에 점유한다. 로그 한 번 찍는 정도면 괜찮지만, 루프 안에서 남발하면 커넥션 풀이 고갈되고 최악의 경우 데드락 비슷한 상황도 날 수 있다. "꼭 독립 커밋이 필요한 지점"에만 좁게 쓰자.
또한 의도가 "주문 성공/실패와 무관하게 이력을 남긴다"가 아니라 "커밋된 후에만 후속 작업(알림 발송 등)을 한다"라면, REQUIRES_NEW보다 **@TransactionalEventListener(phase = AFTER_COMMIT)**가 더 적합하다. 용도를 구분해서 고르자.
4. 정리
•
self-invocation은 타겟 객체에서 this.~~()로 자기 자신을 호출할 때 발생한다. 부가 기능은 프록시에만 있는데, 내부 호출은 프록시를 거치지 않기 때문이다.
•
트랜잭션, 특히 REQUIRES_NEW에서 가장 치명적으로 드러난다. 어노테이션이 조용히 무시되어 의도와 정반대 결과가 나온다.
•
해결의 핵심은 "@Transactional 메서드는 반드시 프록시를 거쳐 호출돼야 한다"는 것. 하위 leaf 컴포넌트로 분리(정석), TransactionTemplate, AspectJ 중 상황에 맞게 선택하면 되고, 대부분은 하위 컴포넌트 분리로 충분하다. 이때 추출한 빈은 Repository에만 의존하는 leaf로 두어 순환 참조를 피한다.
결국 가장 좋은 건 self-invocation 상황 자체를 만들지 않도록 객체의 책임을 분리하고 외부 호출로 설계하는 것이다. 트랜잭션도 깔끔하게 잡히고 코드 구조도 좋아지는 방향이다.
Reference
•
Spring Framework 공식 문서 — Understanding AOP Proxies