백엔드 개발을 하면서 트랜잭션을 직접 제어하는 코드를 작성해본 개발자는 많지 않을 것입니다. @Transactional 하나면 트랜잭션이 생성되고, 메서드가 끝나는 시점에 자동으로 종료되니까요. 개발자는 그 안에서 비즈니스 로직에만 집중하면 됩니다.
하지만 이 추상화가 때로는 함정이 되기도 합니다. private 메서드에 @Transactional을 선언하거나, 같은 클래스 내부에서 @Transactional 메서드를 호출하는 self-invocation으로 인해 트랜잭션이 의도대로 동작하지 않는 경험, 한 번쯤 해보셨을 겁니다.
이 글에서는 @Transactional에 숨어있는 AOP의 동작 원리를, 수동 프록시에서 시작해 현대 스프링에 이르기까지의 진화 과정을 통해 추적해보겠습니다.
"마법" 뒤에 숨겨진 설계 결정들
스프링에서 프록시 빈을 지원하기 전에는 어떤 결정들을 하며 발전하였는지 알고 싶다면 이전편인 아래의 글을 참고하시길 바랍니다.
프록시가 나오게 된 이유
토비의 스프링 정복하기 11편 - 프록시가 나오게 된 이유
프록시와 Mock프록시(AOP)와 Mock(@MockitoBean)은 목적은 다릅니다. 하나는 부가기능을 끼워넣기 위해, 다른 하나는 테스트 격리를 위해 존재합니다. 하지만 구조적으로는 놀라울 정도로 닮아있습니다
codediary21.tistory.com
ProxyFactoryBean — 스프링이 추상화에 나서다
핵심 아이디어
프록시 빈의 핵심 철학은 "프록시 생성 메커니즘을 스프링 IoC와 결합하고, 부가기능(Advice)과 적용 대상(Pointcut)을 분리하자." 입니다. Ioc는 이전에 배웠던 것과 같이 객체의 생성과 의존성 주입 등을 관리해주는 기술입니다. 한마디로 프록시 객체의 생성과 주입 또한 프레임워크에 위임을 할 수 있게 된 것입니다.
스프링의 ProxyFactoryBean은 JDK Dynamic Proxy와 CGLIB을 모두 감싸는 추상화 레이어입니다. ProxyFactoryBean을 활용하면 개발자는 적용 대상(Pointcut)과 부가기능(Advice)만 지정하면, 프록시 객체 생성·주입·실제 객체 호출은 프레임워크가 처리합니다.
하지만 여전히 빈 하나마다 ProxyFactoryBean 설정이 필요하다는 한계가 남아있습니다. 서비스가 50개면 50번의 설정이 필요한 셈이죠.
💬 CGLIB: 바이트코드를 조작해서 클래스의 서브클래스를 런타임에 생성하는 라이브러리
ProxyFactoryBean pfBean = new ProxyFactoryBean();
pfBean.setTarget(new AppUserService(userDao));
// Pointcut: "어디에" 적용할 것인가
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedName("changePassword*");
// Advice: "무엇을" 적용할 것인가
TransactionAdvice txAdvice = new TransactionAdvice(transactionManager);
// Advisor = Pointcut + Advice ("어디에" + "무엇을")
pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, txAdvice));
UserService proxied = (UserService) pfBean.getObject();
ProxyFactoryBean에 등장한 핵심 개념
| 개념 | 역활 | 비유 |
| Advice | 부가기능 자체 (트랜잭션, 로깅 등) | 택배 기사가 하는 "일" |
| Pointcut | 어떤 메서드에 적용할지 선별 | 택배 기사의 "배달 목록" |
| Advisor | Advice + Pointcut의 묶음 | "이 기사(Advice)는 이 목록(Pointcut)대로 배달한다" |
BeanPostProcessor 기반 자동 프록시 — 자동화의 완성
핵심 아이디어
이전에 ProxyBeanFactory에서 가지고 있는 ProxyFactoryBean을 모든 곳에 설정해야하는 문제점을 스프링에서는 "빈이 생성될 때 자동으로 Advisor를 확인하고, 조건에 맞으면 프록시로 감싸서 등록하자."라는 아이디어로 문제를 해결 하였습니다.
DefaultAdvisorAutoProxyCreator는 BeanPostProcessor의 구현체로, 스프링 컨테이너의 빈 생명주기에 끼어들어 자동으로 프록시를 생성하는 역할입니다. 간략하게 동작 흐름을 살펴보면 아래와 같습니다.
동작 흐름
빈 인스턴스 생성
↓
BeanPostProcessor 체인 실행
↓
DefaultAdvisorAutoProxyCreator가 개입
↓
등록된 모든 Advisor의 Pointcut으로 현재 빈 검사
↓
매칭되는 Advisor가 있으면 → 프록시 생성 → 원본 대신 프록시를 빈으로 등록
매칭 안 되면 → 원본 빈 그대로 등록
이 단계에서 개발자가 해야 할 일은 Advisor를 빈으로 등록하는 것 뿐입니다. 어떤 빈에 프록시를 씌울지는 스프링이 알아서 판단하고 결정하며 개발자는 어떤 관심사를 분리하여 주입할 것인지만 신경쓰면 됩니다.
예시코드
// 1. Advice 정의 — 메서드 실행 시간 측정
@Component
public class LatencyLoggingAdvice implements MethodInterceptor {
private static final Logger log = LoggerFactory.getLogger(LatencyLoggingAdvice.class);
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
long start = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long elapsed = System.currentTimeMillis() - start;
log.info("[Latency] {}.{}() — {}ms",
invocation.getThis().getClass().getSimpleName(),
invocation.getMethod().getName(),
elapsed);
}
}
}
// 2. Advisor 등록 — Service 계층 전체에 자동 적용
@Configuration
public class AutoProxyConfig {
@Bean
public DefaultAdvisorAutoProxyCreator autoProxyCreator() {
return new DefaultAdvisorAutoProxyCreator();
}
@Bean
public Advisor latencyAdvisor(LatencyLoggingAdvice advice) {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* com.example.service..*.*(..))");
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
// 3. 서비스 빈은 아무 설정 없이 그냥 등록
@Component
public class UserService { ... }
@Component
public class OrderService { ... }
@Component
public class PaymentService { ... }
// → service 패키지 하위 모든 빈의 메서드에 자동으로 레이턴시 로깅 적용
출력 결과
// 실행 결과
[Latency] UserService.changePassword() — 23ms
[Latency] OrderService.placeOrder() — 142ms
[Latency] PaymentService.processPayment() — 89ms
왜 이것이 게임 체인저인가
이전 단계까지의 모든 접근법은 "이 빈에 이 프록시를 적용해라"라고 명시적으로 선언해야 했습니다. BeanPostProcessor 기반 자동 프록시는 이 관계를 뒤집어 "부가 기능과 어떻게 적용할지만 알려줘"라는 내용만 알려주면 손쉽게 프록시 객체를 활용할 수 있게 되었습니다.
- 이전: "UserService에 프록시 설정, OrderService에 프록시 설정, PaymentService에 프록시 설정..."
- 이후: "service 패키지 하위 메서드에 레이턴시 로깅을 적용해라"
이것은 단순한 편의 개선이 아니라, 사고 모델의 전환입니다. 빈 하나하나를 생각하는 것에서 횡단 관심사(cross-cutting concern) 자체를 정의하는 것으로 넘어간 것입니다.
📢 횡단 관심사: 핵심 로직을 도와주기 위해 여러 곳에 공통적으로 삽입되는 기능
@AspectJ와 현대 스프링 — 우리가 실제로 사용하는 모습
현대적 AOP의 모습
위의 모든 진화 과정이 응축된 결과가 바로 우리가 매일 사용하는 @Transactional과 @Aspect입니다.
@Aspect
@Component
public class PerformanceAspect {
@Around("@annotation(Monitored)")
public Object measureTime(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed();
} finally {
long elapsed = System.nanoTime() - start;
log.info("{}.{} took {}ms",
pjp.getTarget().getClass().getSimpleName(),
pjp.getSignature().getName(),
elapsed / 1_000_000);
}
}
}
이 몇 줄의 코드 안에 앞서 살펴본 모든 개념이 녹아있습니다. 이전 섹션에서 봤던 레이턴시 로깅이 @Monitored라는 어노테이션을 통해 @Transactional처럼 코드 한 줄로 레이턴시를 측정하는 로깅을 할 수 있게 된 것입니다.
| @AspectJ 요소 | 내부 매핑 |
@Aspect |
Advisor를 정의하는 클래스 |
@Around(...) |
Pointcut 표현식 + Advice 타입 |
ProceedingJoinPoint.proceed() |
method.invoke(target, args) — 다이내믹 프록시 시절의 그것 |
@Component |
자동 프록시 생성기가 스캔할 수 있도록 빈 등록 |
Spring Boot에서의 AOP 인프라
Spring Boot를 사용한다면, 위의 인프라가 전부 자동 설정됩니다.
spring-boot-starter-aop의존성 추가 시,@EnableAspectJAutoProxy가 자동 활성화AnnotationAwareAspectJAutoProxyCreator가 빈 후처리기로 등록 (DefaultAdvisorAutoProxyCreator의 확장판)- Spring Boot 2.0부터 기본 프록시 방식이 CGLIB (
proxyTargetClass=true)
특히 마지막 포인트는 중요합니다. 토비의 스프링에서 다룬 JDK Dynamic Proxy의 "인터페이스 필수" 제약이 현대 스프링에서는 기본값에서 사라졌다는 의미입니다.
그래서 이제는 프록시 빈을 활용하기 위해 인터페이스를 보일러플레이트처럼 작성했었던 코드가 더이상 필요하지 않습니다. CGLIB를 통해 클래스에 직접 프록시 빈을 주입할 수 있기 때문입니다.
실무에서 반드시 알아야 할 AOP의 함정들
1. Self-Invocation 문제 — 가장 흔한 실수
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
// ... 주문 처리
sendNotification(order); // ⚠️ 프록시를 거치지 않는 내부 호출!
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendNotification(Order order) {
// REQUIRES_NEW가 적용되지 않는다!
}
}
프록시 기반 AOP는 외부에서 프록시 객체를 통해 호출될 때만 동작합니다. 코드로는 컨트롤러에서 서비스를 바로 호출하는 것처럼 보이지만 실제로는 컨트롤러에서 프록시 빈을 호출하고 프록시 빈에서 서비스를 호출하는 것입니다.
같은 클래스 내부에서 this.sendNotification()을 호출하면, 프록시가 아닌 실제 타깃 객체의 메서드가 직접 호출됩니다. 이것은 프록시 기반 AOP의 구조적 특성에서 비롯된 근본적 한계이기 때문에 분리하는 방법 외에는 없습니다.
InvocationHandler.invoke()는 외부 호출을 가로챌 뿐, 타깃 내부의 this 참조까지 바꾸지는 못합니다. 그래서 요즘은 Finder, Processor, Creator 등 컴포넌트로 분리하여 사용하고는 합니다.
해결 방법:
- 메서드를 별도 컴포넌트 클래스로 분리 (추천)
ApplicationContext에서 자기 자신의 프록시를 가져와 호출 (비권장, 순환 참조)- AspectJ 위빙 사용 (바이트코드 레벨에서 해결)
2. Pointcut 표현식의 런타임 특성
// 이 표현식의 오타는 컴파일 타임에 잡히지 않는다!
@Around("execution(* com.example.servce.*.*(..))") // "service" 오타
public Object around(ProceedingJoinPoint pjp) throws Throwable {
return pjp.proceed();
}
Pointcut 표현식은 런타임에 평가됩니다. 오타가 있어도 컴파일은 성공하고, 단지 아무 메서드도 매칭되지 않을 뿐입니다. 통합 테스트 없이는 발견하기 어렵기 때문에 테스트 코드를 꼭 작성하여 배포되기 전 문제를 찾을 수 있도록 준비해두면 좋습니다.
3. 프록시 투명성의 한계
@Autowired
private UserService userService;
// ❌ CGLIB 프록시 객체는 원본 클래스의 서브클래스이므로,
// 구체 타입으로 캐스팅 시 예상과 다르게 동작할 수 있다
UserService proxy = (UserService) context.getBean("userService");
System.out.println(proxy.getClass() == UserService.class); // false!
디버깅 시, 빈이 프록시로 감싸져 있다는 사실을 인지하지 못하면 혼란이 생길 수 있습니다. instanceof 체크나 구체 타입 캐스팅 시 주의가 필요합니다.
@Transactional 어노테이션으로 감싸진 클래스는 실제 코드에 작성된 클래스가 아니라 프록시라는 클래스로 한번 더 감싸져 있기 때문입니다.
정리: AOP의 역사
수동 프록시 클래스 작성
│ 문제: 타깃마다 프록시 클래스 필요
▼
JDK Dynamic Proxy (InvocationHandler)
│ 해결: 런타임 프록시 생성, 하나의 Handler로 통합
│ 문제: 프록시 생성·빈 등록이 수동, 인터페이스 필수
▼
ProxyFactoryBean (Advisor = Pointcut + Advice)
│ 해결: 관심사 분리 (어디에 + 무엇을), JDK/CGLIB 추상화
│ 문제: 빈마다 개별 설정 필요
▼
BeanPostProcessor 자동 프록시
│ 해결: 빈 생성 시 자동 프록시 적용
│ 전환: "빈 중심" → "횡단 관심사 중심" 사고
▼
@AspectJ + Spring Boot 자동 설정
│ 해결: 선언적 AOP, CGLIB 기본, 설정 최소화
└ 현재: @Transactional 한 줄로 완성
스프링의 @Transactional과 @Aspect는 단순히 뚝딱하고 만들어진 기술이 아닙니다. 서로 다른 관심사를 가진 객체에서 반복되는 코드를 어떻게 줄이고 추상화할 것인지 많은 시행착오와 토론을 통해 만들어졌습니다.
이 덕에 스프링을 사용하는 개발자들은 편하게 개발을 할 수 있게 되었습니다. 우리는 이런 기술을 사용하면서 단순히 관습처럼 쓰기보다는 이 기술이 어떤 문제를 해결하기 위해 나온 기술이고 어떤 원리로 동작하는지 살펴보고 사용하는 것은 어떨까요?
