빈을 감싸는 대리 객체(프록시) 를 만들어 앞뒤에 부가 기능을 끼우는 방식. Spring 애노테이션 대부분의 동작 원리다.
무엇이 프록시로 도나
- @Transactional · @Cacheable · @Async · @Retryable · @PreAuthorize ...
- 전부 프록시가 가로채서 부가 동작을 수행한 뒤 원본을 호출한다
호출자 → [프록시] → 원본 빈
│
├ 트랜잭션 시작
├ 원본 메서드 호출
└ 커밋 또는 롤백
두 가지 프록시
-
JDK 동적 프록시 — 인터페이스 기반. 같은 인터페이스를 구현한 객체를 만든다
- 인터페이스에 없는 메서드는 프록시할 수 없다
-
CGLIB — 클래스 상속 기반. 대상 클래스를 상속한 자식을 만든다
- final 클래스·final 메서드·private 메서드는 오버라이드 불가
- Spring Boot 는 기본이 CGLIB 다 (인터페이스가 없어도 되게)
// 프록시인지 확인해 보기
System.out.println(orderService.getClass());
// class com.example.OrderService$$SpringCGLIB$$0 ← 프록시다
프록시가 못 끼는 자리
- private 메서드 — 오버라이드가 불가능하다 → 애노테이션이 무시된다
- final 메서드 — 같은 이유
- final 클래스 — CGLIB 상속 자체가 안 된다
- 자기 호출 — 프록시를 거치지 않는다 (가장 유명한 함정)
- 생성자 안 — 프록시가 아직 완성되지 않았다
애노테이션을 붙였는데 아무 일도 안 일어나면 이 목록부터 확인한다.
용어
- 조인 포인트(Join Point) — 부가 기능을 끼울 수 있는 지점 (Spring 은 메서드 실행만)
- 포인트컷(Pointcut) — 어디에 끼울지 고르는 표현식
- 어드바이스(Advice) — 무엇을 끼울지 (Before · After · Around)
- 애스펙트(Aspect) — 포인트컷 + 어드바이스 묶음
@Aspect @Component
class TimingAspect {
@Around("@annotation(Timed)")
Object measure(ProceedingJoinPoint pjp) throws Throwable {
long t = System.nanoTime();
try { return pjp.proceed(); } // 원본 호출
finally { log.info("{} took {}ns", pjp.getSignature(), System.nanoTime()-t); }
}
}
면접 함정
- ❌ "AOP는 컴파일 시점에 코드를 짜 넣는다" → AspectJ는 그렇지만 Spring AOP는 런타임 프록시다.
- ❌ "Spring AOP는 모든 지점에 끼울 수 있다" → 메서드 실행 지점만 가능하다. 필드 접근·생성자는 안 된다.
프록시 방식을 바꾸기
spring:
aop:
proxy-target-class: true # true=CGLIB(기본) · false=인터페이스가 있으면 JDK 프록시
// 지금 무엇이 적용됐는지 확인
AopUtils.isAopProxy(bean);
AopUtils.isCglibProxy(bean);
AopUtils.isJdkDynamicProxy(bean);
AopProxyUtils.ultimateTargetClass(bean); // 원본 클래스
적용 순서를 정한다
@Aspect
@Order(1) // 숫자가 작을수록 바깥이다
class LoggingAspect { }
@Aspect @Order(2)
class TimingAspect { }
트랜잭션과 커스텀 애스펙트의 순서가 중요할 때가 있다
- 예: 트랜잭션 커밋 후 로그를 남기고 싶다면
- 로깅 애스펙트가 트랜잭션 '바깥' 에 있어야 한다
@Transactional 의 기본 순서는 Ordered.LOWEST_PRECEDENCE (가장 안쪽)