내부 호출과 프록시
외부 호출과 this 내부 호출의 프록시 경로를 비교하고 빈 분리·조정 객체·템플릿·AspectJ 대안을 평가합니다.
Spring AOP 포인트컷이 메서드와 정확히 맞아도 대상 내부의 this.method()에는 어드바이스가 실행되지 않습니다.
클라이언트가 가진 프록시로 들어온 외부 호출 뒤에는 실제 대상 객체가 실행되고, 그 안의 Java 호출은 프록시로 되돌아가지 않기 때문입니다.
트랜잭션·재시도·보안을 내부 메서드에 붙인 채 이 경로를 놓치면 애노테이션이 장식으로 남습니다.
포인트컷이 맞아도 프록시를 다시 지나지 않으면 어드바이스는 없다
ProxyBoundary outer 진입 뒤 Target 내부 inner 직접 호출에서 advice 누계가 증가하지 않는 상태를 보여 준다.
SELF CALL · TARGET COUNT AND POLICY BOUNDARY
자기 호출은 대상에 도달해도 새 Advice를 만들지 않는다
외부 outer 호출은 프록시를 통과하지만 this.inner()는 같은 대상 안에서 끝납니다. 독립 정책 경계가 필요하면 Worker의 공개 프록시 참조로 위임합니다.
- 외부 outer → outer 대상 → this.inner
outer는 프록시를 지납니다. inner 대상은 1회 실행되지만 inner의 추가 advice는 0회입니다.
- Coordinator → Worker 프록시 → Worker 대상
독립 경계로 위임하고 일치 관찰 advice가 한 번 proceed하면 Worker advice1·대상1입니다.
진단 · 빈 → 경로 → Pointcut
프록시 여부, 실제 참조와 호출 경로를 먼저 확인합니다. 최종 참조·advice·대상·트랜잭션 수로 증명하며 애노테이션만 검사하지 않습니다.
아웃박스 요구를 먼저 지킨다
business와 outbox는 같은 로컬 트랜잭션입니다. saveOutbox의 REQUIRES_NEW를 살리기 위해 단순 분리하지 않고 발행 relay만 별도 워커로 옮깁니다.
블록 하나의 TransactionTemplate, 임시 자기 주입, AspectJ 위빙은 각각 비용이 다릅니다. 외부 트랜잭션이 있으면 내부 호출도 기존 트랜잭션 안에서 실행될 수 있습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- SelfCallModel의 outer 호출이다. Target 영역은 실제 대상 하나이고 내부 outer에서 inner로 이어지는 직접 호출은 새로운 ProxyBoundary 진입이 아니다. 두 화살표 모두 호출 전달이며 원 Advice 누계는 한 번이다. 이 순수 Java 모델의 객체를 자동 Spring 프록시로 간주하지 않는다.
- RegistrationFlow를 필요한 워커 프록시로 조립한 경로다. Coordinator의 registerAll은 주입된 Worker 노출 참조로 registerOne을 부르고 그 프록시가 실제 워커에 위임한다. 클래스 분리만으로 프록시를 보장하지 않으며 원 인터페이스/조정자 발췌는 자동 컨텍스트나 저장 커밋을 구현하지 않는다.
- 위 경로는 outer 프록시, outer 대상, this.inner 대상이며 추가 inner advice는 0, inner 대상은 1이다. 아래는 Coordinator가 Worker 프록시를 거쳐 Worker 대상에 호출하는 경로이며 일치 관찰 advice 1, 대상 1이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 포인트컷이 맞아도 프록시를 다시 지나지 않으면 어드바이스는 없다 |
| 24 | 위 경로는 outer 프록시, outer 대상, this.inner 대상이며 추가 inner advice는 0, inner 대상은 1이다. 아래는 Coordinator가 Worker 프록시를 거쳐 Worker 대상에 호출하는 경로이며 일치 관찰 advice 1, 대상 1이다. |
| 42 | 독립 경계로 위임하고 일치 관찰 advice가 한 번 proceed하면 Worker advice 1·대상 1입니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| outer proxy 진입 SelfCallModel의 outer 메서드가 ProxyBoundary를 통과합니다. | advice 누계: 1 · target: 한 객체 | 외부 진입에서 advice가 실행됩니다. |
| target 내부 inner outer 메서드가 같은 target의 inner 메서드를 직접 호출합니다. | 호출: target 내부 · proxy 재진입: 없음 | 직접 호출은 새 경계를 열지 않습니다. |
| 한 번의 advice로 반환 inner와 outer가 반환합니다. | advice 누계: 1 · 모델: 순수 Java | 내부 호출에도 누계는 늘지 않습니다. |
포인트컷이 맞아도 프록시를 다시 지나지 않으면 어드바이스는 없다
ProxyBoundary outer 진입 뒤 Target 내부 inner 직접 호출에서 advice 누계가 증가하지 않는 상태를 보여 준다.
SELF CALL · TARGET COUNT AND POLICY BOUNDARY
자기 호출은 대상에 도달해도 새 Advice를 만들지 않는다
외부 outer 호출은 프록시를 통과하지만 this.inner()는 같은 대상 안에서 끝납니다. 독립 정책 경계가 필요하면 Worker의 공개 프록시 참조로 위임합니다.
- 외부 outer → outer 대상 → this.inner
outer는 프록시를 지납니다. inner 대상은 1회 실행되지만 inner의 추가 advice는 0회입니다.
- Coordinator → Worker 프록시 → Worker 대상
독립 경계로 위임하고 일치 관찰 advice가 한 번 proceed하면 Worker advice1·대상1입니다.
진단 · 빈 → 경로 → Pointcut
프록시 여부, 실제 참조와 호출 경로를 먼저 확인합니다. 최종 참조·advice·대상·트랜잭션 수로 증명하며 애노테이션만 검사하지 않습니다.
아웃박스 요구를 먼저 지킨다
business와 outbox는 같은 로컬 트랜잭션입니다. saveOutbox의 REQUIRES_NEW를 살리기 위해 단순 분리하지 않고 발행 relay만 별도 워커로 옮깁니다.
블록 하나의 TransactionTemplate, 임시 자기 주입, AspectJ 위빙은 각각 비용이 다릅니다. 외부 트랜잭션이 있으면 내부 호출도 기존 트랜잭션 안에서 실행될 수 있습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- SelfCallModel의 outer 호출이다. Target 영역은 실제 대상 하나이고 내부 outer에서 inner로 이어지는 직접 호출은 새로운 ProxyBoundary 진입이 아니다. 두 화살표 모두 호출 전달이며 원 Advice 누계는 한 번이다. 이 순수 Java 모델의 객체를 자동 Spring 프록시로 간주하지 않는다.
- RegistrationFlow를 필요한 워커 프록시로 조립한 경로다. Coordinator의 registerAll은 주입된 Worker 노출 참조로 registerOne을 부르고 그 프록시가 실제 워커에 위임한다. 클래스 분리만으로 프록시를 보장하지 않으며 원 인터페이스/조정자 발췌는 자동 컨텍스트나 저장 커밋을 구현하지 않는다.
- 위 경로는 outer 프록시, outer 대상, this.inner 대상이며 추가 inner advice는 0, inner 대상은 1이다. 아래는 Coordinator가 Worker 프록시를 거쳐 Worker 대상에 호출하는 경로이며 일치 관찰 advice 1, 대상 1이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 포인트컷이 맞아도 프록시를 다시 지나지 않으면 어드바이스는 없다 |
| 24 | 위 경로는 outer 프록시, outer 대상, this.inner 대상이며 추가 inner advice는 0, inner 대상은 1이다. 아래는 Coordinator가 Worker 프록시를 거쳐 Worker 대상에 호출하는 경로이며 일치 관찰 advice 1, 대상 1이다. |
| 42 | 독립 경계로 위임하고 일치 관찰 advice가 한 번 proceed하면 Worker advice 1·대상 1입니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| outer proxy 진입 SelfCallModel의 outer 메서드가 ProxyBoundary를 통과합니다. | advice 누계: 1 · target: 한 객체 | 외부 진입에서 advice가 실행됩니다. |
| target 내부 inner outer 메서드가 같은 target의 inner 메서드를 직접 호출합니다. | 호출: target 내부 · proxy 재진입: 없음 | 직접 호출은 새 경계를 열지 않습니다. |
| 한 번의 advice로 반환 inner와 outer가 반환합니다. | advice 누계: 1 · 모델: 순수 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회 실행됐지만 그중 프록시를 통과한 호출은 1회입니다. 두 실행에 각각 어드바이스가 적용됐다고 추측하면 자기 호출 한 건을 놓친 셈입니다.
내부 정책의 책임 분리
외부가 흐름 조정이고 내부가 독립 트랜잭션이나 재시도 단위라면 서로 다른 애플리케이션 책임일 가능성이 큽니다.
워커 빈으로 추출하고 외부가 주입받은 프록시 참조를 호출합니다.
클래스를 나누는 목적은 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가 외부 연결을 동시에 점유하는 문제도 줄어듭니다.
독립 확인: registerAll에 세 제목을 순서대로 줍니다. 주입된 worker 참조는 필요한 정책의 프록시이고, 첫 registerOne은 정상 반환하며 둘째는 RuntimeException을 던집니다. 조정자는 예외를 잡지 않습니다. worker 호출 수·completed 증가 횟수·셋째 호출 여부·registerAll의 정상 반환 여부를 구하세요. 저장·트랜잭션 성공은 따로 관측한다고 가정합니다.
풀이 확인
worker는 2회 호출되고 completed는 첫 호출 뒤에만 1회 증가합니다. 둘째가 던진 뒤 루프가 종료되므로 셋째는 호출하지 않으며 registerAll은 정수 1을 정상 반환하지 않고 예외를 전달합니다. completed=1은 그 시점의 지역 변수 추적값입니다. 각 워커의 실제 커밋 행 수나 외부 트랜잭션의 최종 성공을 이 변수만으로 증명하지 않습니다.
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로 멱등하게 처리하고 애플리케이션 트랜잭션 프록시와 별도 빈 경계에서 실행합니다.
예시 PostCreation을 프록시로 호출하고 두 저장소가 같은 트랜잭션 매니저의 자원에 참여한다고 가정합니다. posts.insert가 실행되어 ID를 받은 뒤 outbox.append가 RuntimeException을 던지면 register의 정상 반환과 경계의 커밋에는 도달하지 않습니다. 기본 롤백 규칙이 유지되면 앞 insert도 함께 롤백되는 경로입니다. insert가 한 번 실행됐다는 사실과 게시글 행이 커밋됐다는 사실을 구별하세요.
별도 변형으로 outbox만 독립 REQUIRES_NEW에서 먼저 커밋하고 이후 게시글 트랜잭션이 실패한다면, 아웃박스가 남을 수 있어 문제의 함께 커밋 요구를 충족하지 않습니다. 이벤트 중계의 독립 재시도와 이벤트 저장의 원자성을 분리해야 하는 이유입니다. 다른 저장소 자원·사용자 지정 롤백 규칙을 쓰는 경우에는 이 가정을 새로 검증하며, 이 발췌만으로 원격 저장소 간 원자성을 보장하지 않습니다.