내부 호출과 프록시
외부 프록시 호출과 this 내부 호출의 어드바이스 개수 차이를 실행하고, 별도 빈·조정자·TransactionTemplate·자기 주입·AopContext·AspectJ 위빙 대안을 결합도와 검증 비용으로 비교합니다.
Spring AOP 포인트컷이 메서드와 정확히 맞아도 대상 내부의 this.method()에는 어드바이스가 실행되지 않습니다.
클라이언트가 가진 프록시로 들어온 외부 호출 뒤에는 실제 대상 객체가 실행되고, 그 안의 Java 호출은 프록시로 되돌아가지 않기 때문입니다.
트랜잭션·재시도·보안을 내부 메서드에 붙인 채 이 경로를 놓치면 애노테이션이 장식으로 남습니다.
외부·내부 호출 비교
다음 모델은 프록시 콜백이 외부 호출에서만 카운터를 증가시키고 대상의 외부가 내부를 직접 부르는 차이를 최소 코드로 보여 줍니다.
package board.selfcall;
import java.util.concurrent.atomic.AtomicInteger;
public final class SelfCallModel {
static final class Target {
String outer() {
return "outer->" + inner();
}
String inner() {
return "inner";
}
}
static final class ProxyBoundary {
private final Target target;
private final AtomicInteger adviceCalls;
ProxyBoundary(Target target, AtomicInteger adviceCalls) {
this.target = target;
this.adviceCalls = adviceCalls;
}
String outer() {
adviceCalls.incrementAndGet();
return target.outer();
}
String inner() {
adviceCalls.incrementAndGet();
return target.inner();
}
}
public static void main(String[] args) {
var calls = new AtomicInteger();
var proxy = new ProxyBoundary(new Target(), calls);
System.out.println(proxy.outer());
System.out.println("afterOuter=" + calls.get());
System.out.println(proxy.inner());
System.out.println("afterExternalInner=" + calls.get());
}
}outer->inner
afterOuter=1
inner
afterExternalInner=2외부 안의 내부는 추가 어드바이스 개수를 만들지 않습니다.
실제 Spring 컨텍스트에서는 AopUtils.isAopProxy, 어드바이저 카운터, 트랜잭션 활성 플래그를 같은 방식으로 확인합니다.
관찰 결과는 외부 outer() 호출에서 어드바이스 1회·대상 외부 1회·대상 내부 1회이고, 외부 inner() 호출을 추가한 뒤 어드바이스 누계가 2회가 되는 것입니다.
내부 대상은 총 2회 실행됐지만 어드바이스도 2회라고 추측하면 내부 호출 한 건을 놓친 셈입니다.
내부 정책의 책임 분리
외부가 흐름 조정이고 내부가 독립 트랜잭션이나 재시도 단위라면 서로 다른 애플리케이션 책임일 가능성이 큽니다. 워커 빈으로 추출하고 외부가 주입받은 프록시 참조를 호출합니다. 클래스를 나누는 목적은 AOP 기교가 아니라 경계를 객체 그래프에 드러내는 것입니다.
package board.selfcall;
import java.util.List;
public final class RegistrationFlow {
public static class Coordinator {
private final RegistrationWorker worker;
public Coordinator(RegistrationWorker worker) {
this.worker = worker;
}
public int registerAll(List<String> titles) {
int completed = 0;
for (String title : titles) {
worker.registerOne(title);
completed++;
}
return completed;
}
}
public interface RegistrationWorker {
void registerOne(String title);
}
private RegistrationFlow() { }
}워커 구현의 공개 메서드에 트랜잭션·재시도 메타데이터를 둡니다. 조정자에 긴 트랜잭션을 두지 않으면 REQUIRES_NEW가 외부 연결을 동시에 점유하는 문제도 줄어듭니다.
self injection·AopContext
자기 인터페이스 프록시를 지연 주입해 self.inner()로 호출할 수 있습니다.
순환 의존성과 초기화 순서가 생기고 단위 테스트에서 자기 참조를 별도로 조립해야 합니다.
구조를 당장 나누기 어려운 레거시 브리지로만 제한합니다.
AopContext.currentProxy()는 exposeProxy 옵션과 스레드-로컬 프록시 컨텍스트를 요구합니다.
애플리케이션 코드가 Spring AOP API를 직접 알고 리액티브·비동기 스레드 전환에서 동작을 기대하기 어렵습니다.
메서드 한 줄을 어드바이스에 걸기 위해 아키텍처를 숨기지 않습니다.
프록시 객체를 필드에 캐시하거나 생성자에서 currentProxy를 읽으면 아직 호출 컨텍스트가 없어 실패합니다.
진단 예외를 삼키고 대상을 직접 호출하면 정책이 조용히 빠지므로 즉시 실패시키는 편이 낫습니다.
프로그래밍식 템플릿
별도 빈이 부자연스럽고 트랜잭션 블록 하나만 필요하면 TransactionTemplate를 호출합니다.
내부 호출과 무관하게 콜백이 매니저를 직접 사용합니다.
대신 전파, 예외, null 허용 반환이 코드에 나타나고 애스펙트 통일성은 줄어듭니다.
재시도에도 재사용 가능한 실행자 콜백을 주입할 수 있습니다. 이 방식은 대상 메서드 애노테이션을 찾지 않으므로 같은 클래스 내부에서 명확하게 보입니다. 프로그래밍 방식 경계가 늘면 오케스트레이션 객체 분리를 다시 검토합니다.
AspectJ 위빙
컴파일 시점 또는 적재 시점 위빙은 바이트코드 조인 지점을 사용해 내부 호출도 어드바이스할 수 있습니다.
도메인 객체와 생성자·필드 접근까지 넓힐 수 있지만 빌드 플러그인, javaagent, 클래스 로더, IDE, 테스트, 네이티브 이미지에 미치는 영향이 큽니다.
프록시 한계 한 건을 피하려고 위빙을 도입하면 팀의 디버그 모델 전체가 바뀝니다. 정말 메서드 외 조인 지점이 광범위하게 필요하고 도구 비용을 감수할 때 아키텍처 결정으로 선택합니다. 동일 애스펙트의 이중 적용을 통합 테스트합니다.
적용 불가 지점
클래스 프록시가 재정의할 수 없는 final 메서드와 외부에서 부를 수 없는 private 메서드는 어드바이스 경계로 삼지 않습니다.
new로 만든 엔티티·도우미는 BeanPostProcessor를 거치지 않습니다.
애스펙트 표현식만 넓혀도 해결되지 않습니다.
구성이 대상 구체 타입을 직접 반환해 다른 코드가 원본 참조를 보관하는지 확인합니다. Spring 빈 팩토리가 최종 프록시를 유일한 공유 참조로 제공하게 합니다.
진입 경로 회귀 테스트
외부→외부, 외부→내부, 외부→내부 자기 호출 세 경로에서 어드바이스·대상 개수를 기록합니다. 트랜잭션이면 행과 활성 상태, 보안이면 거부 대상 개수 0, 재시도면 시도 수를 확인합니다. 애노테이션 리플렉션 테스트는 프록시 우회를 발견하지 못합니다.
코드 검토에서는 어드바이스 대상 메서드를 같은 클래스가 호출하는 참조를 검색해 경고할 수 있습니다. 다만 모든 내부 호출이 별도 어드바이스를 원하지 않으므로 아키텍처 규칙에 예외 설명을 둡니다.
연습 문제
publish()가 내부 saveOutbox()를 호출하고 후자에 REQUIRES_NEW와 재시도가 붙은 코드를 고치세요.
아웃박스는 애그리거트와 함께 커밋되어야 한다는 요구를 반영해 애노테이션을 단순히 살리는 것보다 올바른 경계를 제시하세요.
해설 보기
아웃박스는 독립 커밋이 아니라 애그리거트 트랜잭션에 필수로 참여해야 합니다. 별도 리포지토리 포트를 호출하되 발행 중계만 트랜잭션 밖 워커로 분리합니다.
package board.selfcall;
import org.springframework.transaction.annotation.Transactional;
public class PostCreation {
private final PostStore posts;
private final OutboxStore outbox;
public PostCreation(PostStore posts, OutboxStore outbox) {
this.posts = posts;
this.outbox = outbox;
}
@Transactional
public long register(String title, String eventId) {
long id = posts.insert(title);
outbox.append(eventId, "PostCreated", id);
return id;
}
public interface PostStore { long insert(String title); }
public interface OutboxStore {
void append(String eventId, String type, long aggregateId);
}
}중계 재시도는 이벤트 ID로 멱등하게 처리하고 애플리케이션 트랜잭션 프록시와 별도 빈 경계에서 실행합니다.