Spring의 선언적 부가 기능(@Transactional, @Cacheable, @Async, @Configurable)을 적용하는 방법은 두 갈래다.
- 프록시 모드(기본값): 런타임에 대상 빈을 감싸는 프록시 객체를 만들고, 프록시의 메서드가 호출될 때 어드바이스를 실행한다. spring-aop가 담당한다.
- AspectJ 모드: 클래스의 바이트코드 자체에 어드바이스를 짜 넣는다. 프록시라는 중간 객체가 없다. 이 모듈(spring-aspects)이 담당한다.
이 챕터는 후자가 언제·왜 필요한지, 그리고 spring-aspects의 모든 애스펙트가 공유하는 공통 골격을 설명한다. 개별 기능(트랜잭션/캐시/비동기/DI)은 다음 챕터들에서 다루고, 여기서는 네 애스펙트가 똑같이 따르는 패턴을 한 번에 짚는다.
[프록시 모드]
caller ──▶ ┌─────────┐ advice ┌──────────┐
│ Proxy │ ───────▶ │ Target │
└─────────┘ └──────────┘
▲ │
│ self-invocation은 │ this.foo() ──┐
│ 프록시를 안 거침 │◀─────────────┘ (advice 누락!)
한계 1) Spring이 만든 빈만 프록시로 감쌀 수 있다 → new 객체엔 적용 불가
한계 2) 객체 내부의 this.method() 호출은 프록시를 우회 → advice 누락
한계 3) 인터페이스 프록시(JDK)는 public 메서드만, 인터페이스 호출만 가로챔
[AspectJ 모드]
바이트코드에 advice가 직접 박혀 있음
┌────────────────────────────┐
│ class Target { │
│ void foo() { │
│ <advice 시작 코드> │ ◀── 위버가 삽입
│ ...원래 본문... │
│ <advice 종료 코드> │ ◀── 위버가 삽입
│ } │
│ } │
└────────────────────────────┘
→ new 객체든, self-invocation이든, 비public 메서드든 전부 적용
위빙은 두 시점 중 하나에서 일어난다.
- 컴파일 타임 위빙(CTW): AspectJ 컴파일러(
ajc)가 빌드 시점에.aj애스펙트와 대상 클래스를 함께 컴파일하며 어드바이스를 삽입한다. - 로드 타임 위빙(LTW): JVM이 클래스를 로딩할 때 자바 에이전트(
spring-instrument+@EnableLoadTimeWeaving)가 바이트코드를 변환한다.
@Configurable(다음 챕터)은 거의 항상 LTW와 함께 쓰인다. 트랜잭션/캐시/비동기 애스펙트는 CTW로 미리 위빙해 두는 방식이 일반적이다.
spring-aspects의 네 기능 애스펙트는 약속이라도 한 듯 동일한 3층 구조를 갖는다.
┌──────────────────────────────────────────────────────────┐
│ (1) *AspectSupport — 다른 모듈에 있는 "진짜 로직" │
│ TransactionAspectSupport / CacheAspectSupport / │
│ AsyncExecutionAspectSupport / BeanConfigurerSupport │
└───────────────────────────┬──────────────────────────────┘
│ extends (또는 위임)
▼
┌──────────────────────────────────────────────────────────┐
│ (2) Abstract*Aspect.aj — around advice 한 개 + │
│ 추상 pointcut 한 개. "잡으면 무엇을 할지"를 정의. │
└───────────────────────────┬──────────────────────────────┘
│ extends
▼
┌──────────────────────────────────────────────────────────┐
│ (3) Annotation*Aspect.aj — 구체 pointcut 정의. │
│ "어떤 조인 포인트를 잡을지"(@Transactional 등)를 명시 │
└──────────────────────────────────────────────────────────┘
- (1)
*AspectSupport: 트랜잭션 시작/커밋/롤백, 캐시 조회/저장, 비동기 제출 같은 실제 로직. spring-aspects가 아니라 spring-tx·spring-context에 들어 있다. 애스펙트는 이걸extends해서 그 메서드(invokeWithinTransaction,execute,doSubmit)를 호출할 뿐이다. - (2)
Abstract*Aspect.aj: AspectJaround(또는before/after) 어드바이스를 하나 갖고, 추상pointcut을 선언한다. 어드바이스 본문은 "조인 포인트의 메서드 정보를 꺼내 (1)의 support 메서드를 부르고, 결과를 그대로 반환"하는 어댑터다. - (3)
Annotation*Aspect.aj: 추상 pointcut을@Transactional·@Cacheable·@Async같은 애너테이션 매칭으로 구체화한다.
프록시 모드와 AspectJ 모드는 같은 어드바이스 로직을 공유해야 한다. 트랜잭션 전파 규칙이나 캐시 키 생성 규칙이 두 모드에서 다르면 안 되기 때문이다. 그래서 로직은 *AspectSupport에 한 번만 구현하고, 프록시용 인터셉터(TransactionInterceptor 등)와 AspectJ용 애스펙트가 둘 다 그것을 상속한다. spring-aspects는 그중 AspectJ 진입점만 책임진다.
AspectJ 애스펙트는 일반 객체처럼 new로 만들 수 없다. AspectJ 런타임이 애스펙트마다 싱글톤 인스턴스를 관리하고, AspectName.aspectOf() 정적 메서드로 그 인스턴스를 꺼낸다. 문제는 이 싱글톤에 TransactionManager·CacheManager·Executor 같은 협력 객체를 주입해 줘야 한다는 것이다.
이를 잇는 것이 각 패키지의 @Configuration 클래스다. 예를 들어 트랜잭션:
// AspectJTransactionManagementConfiguration
@Bean(name = TransactionManagementConfigUtils.TRANSACTION_ASPECT_BEAN_NAME)
public AnnotationTransactionAspect transactionAspect(TransactionAttributeSource tas) {
AnnotationTransactionAspect txAspect = AnnotationTransactionAspect.aspectOf(); // 위버가 만든 싱글톤
txAspect.setTransactionAttributeSource(tas);
if (this.txManager != null) {
txAspect.setTransactionManager(this.txManager);
}
return txAspect; // 그 싱글톤을 Spring 빈으로 노출 → 의존성 주입 완료
}흐름을 정리하면:
@EnableTransactionManagement(mode = ASPECTJ)
│
▼ ConfigurationSelector가 mode 보고 분기
AspectJTransactionManagementConfiguration (@Configuration 등록)
│
▼ @Bean 메서드 실행
AnnotationTransactionAspect.aspectOf() ──▶ AspectJ 위버가 보관 중인 싱글톤 획득
│
▼ setTransactionManager(...) 등 주입
싱글톤 애스펙트가 Spring 빈으로 등록됨
│
▼ (별도로) 위빙된 대상 클래스의 @Transactional 메서드가 호출되면
그 싱글톤 애스펙트의 around advice가 발동 → 주입받은 txManager로 트랜잭션 수행
핵심은 "위빙은 코드에, 설정은 빈에" 라는 분리다. 어떤 조인 포인트를 잡을지는 컴파일/로드 타임에 바이트코드로 결정되지만, 그 잡힌 지점에서 사용할 협력 객체는 런타임에 Spring이 aspectOf() 싱글톤에 주입한다.
AspectName.aspectOf(): AspectJ가 합성해 주는 정적 메서드. 위버가 관리하는 애스펙트 싱글톤을 반환한다. 네 패키지의 모든@Configuration이 이걸로 빈을 만든다.- 추상
pointcut:Abstract*Aspect가protected abstract pointcut transactionalMethodExecution(Object txObject);처럼 "잡을 지점의 형태"만 선언하고, 구체 애스펙트가 애너테이션 조건으로 채운다. 전형적인 템플릿 메서드 패턴의 pointcut 판이다. @SuppressAjWarnings("adviceDidNotMatch"): 어드바이스가 어떤 클래스에도 매칭되지 않을 때 AspectJ가 내는 경고를 끈다. 라이브러리 애스펙트는 사용처를 알 수 없으니 당연히 붙인다.@Role(BeanDefinition.ROLE_INFRASTRUCTURE): 등록되는 애스펙트 빈이 사용자용이 아닌 인프라용임을 표시한다. 네@Configuration모두 이 표식을 단다.- checked 예외 우회: AspectJ
around는 선언된 예외만 던질 수 있다. 그러나 위빙 대상 메서드는 임의의 checked 예외를 던질 수 있으므로,Rethrower(트랜잭션)·AnyThrow(캐시) 같은 제네릭 트릭으로 원래 예외를 그대로 다시 던진다. 이는 AspectJ 어드바이스 어댑터에서 반복되는 관용구다.
- 템플릿 메서드 + 전략: 상위 애스펙트는 advice 골격을, 하위 애스펙트는 pointcut 전략을 제공한다. Spring 애너테이션용(
AnnotationTransactionAspect)과 표준 스펙용(JtaAnnotationTransactionAspect,JCacheCacheAspect)이 같은 골격을 공유한다. - DRY across 모드: 로직을
*AspectSupport에 모아 프록시 모드와 공유함으로써, 사용자가 모드를 바꿔도 의미가 동일하게 유지된다. - 확장점:
GenericInterfaceDrivenDependencyInjectionAspect(다음 챕터)처럼 사용자가 직접 서브 애스펙트를 작성해 자기 도메인 타입에 DI를 거는 길도 열려 있다.
spring-aspects는 "프록시로는 닿지 않는 곳까지 Spring의 횡단 기능을 넣기 위해" AspectJ 위빙 진입점을 제공하는 모듈이다. 네 기능 애스펙트는 모두 *AspectSupport(진짜 로직) → Abstract*Aspect(advice 골격) → Annotation*Aspect(구체 pointcut)의 3층 구조를 따르고, @Enable...(mode = ASPECTJ) → @Configuration → aspectOf() 경로로 위버 싱글톤에 협력 객체를 주입받는다. 이 공통 골격을 머릿속에 두고 다음 챕터의 각 기능을 보면 된다.