Spring AOP 프록시란?
Spring AOP는 프록시 패턴으로 동작한다. @Transactional, @Cacheable, @Async 같은 어노테이션이 붙은 빈은 원본 객체가 아닌 프록시 객체가 스프링 컨테이너에 등록된다. 이 프록시가 메서드 호출을 가로채 부가 기능(트랜잭션, 캐시, 비동기 실행)을 수행한 뒤 원본 메서드를 호출한다.
프록시 메커니즘을 이해하지 못하면 “@Transactional이 왜 안 먹히지?” 같은 문제에 빠지게 된다. 이 글에서는 프록시 생성 방식, self-invocation 함정, 커스텀 Aspect 작성까지 심화 정리한다.
JDK Dynamic Proxy vs CGLIB
Spring은 두 가지 프록시 생성 방식을 제공한다.
| 구분 | JDK Dynamic Proxy | CGLIB Proxy |
|---|---|---|
| 방식 | 인터페이스 기반 | 클래스 상속 기반 |
| 조건 | 인터페이스 구현 필요 | 인터페이스 불필요 |
| final 클래스 | 가능 | ❌ 불가 |
| final 메서드 | 가능 | ❌ 프록시 적용 안 됨 |
| Spring Boot 기본 | – | ✅ 기본값 |
# Spring Boot는 CGLIB이 기본
# application.yml에서 변경 가능 (권장하지 않음)
spring:
aop:
proxy-target-class: true # CGLIB (기본)
# proxy-target-class: false # JDK Dynamic Proxy (인터페이스 필요)
// CGLIB 프록시 확인
@Service
public class UserService {
// ...
}
@Component
public class ProxyChecker implements ApplicationRunner {
@Autowired UserService userService;
@Override
public void run(ApplicationArguments args) {
System.out.println(userService.getClass().getName());
// com.example.UserService$$SpringCGLIB$$0 ← CGLIB 프록시!
System.out.println(AopUtils.isAopProxy(userService)); // true
System.out.println(AopUtils.isCglibProxy(userService)); // true
System.out.println(AopUtils.isJdkDynamicProxy(userService)); // false
}
}
Self-Invocation 함정
Spring AOP의 가장 흔한 실수다. 같은 클래스 내부에서 자기 자신의 메서드를 호출하면 프록시를 거치지 않는다.
@Service
public class OrderService {
// ❌ self-invocation: @Transactional이 동작하지 않음!
public void processOrder(Long orderId) {
// 내부 호출 → 프록시를 거치지 않음 → 트랜잭션 없음
this.saveOrder(orderId);
}
@Transactional
public void saveOrder(Long orderId) {
// 이 메서드가 직접 호출되면 트랜잭션 적용
// 하지만 processOrder()에서 호출하면 적용 안 됨!
orderRepository.save(new Order(orderId));
}
}
// 프록시 동작 원리 (의사 코드)
class OrderService$$Proxy extends OrderService {
private OrderService target; // 원본 객체
private TransactionInterceptor txInterceptor;
@Override
public void saveOrder(Long orderId) {
// 프록시가 가로챔 → 트랜잭션 시작
txInterceptor.begin();
target.saveOrder(orderId); // 원본 호출
txInterceptor.commit();
}
@Override
public void processOrder(Long orderId) {
// 프록시가 호출하지만...
target.processOrder(orderId);
// target 내부의 this.saveOrder()는 프록시가 아닌
// target 자신을 호출 → 트랜잭션 미적용!
}
}
해결 방법
// 해결 1: 별도 서비스로 분리 (권장)
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderSaveService saveService;
public void processOrder(Long orderId) {
saveService.saveOrder(orderId); // 프록시를 통한 외부 호출
}
}
@Service
public class OrderSaveService {
@Transactional
public void saveOrder(Long orderId) {
// 트랜잭션 정상 적용
}
}
// 해결 2: 자기 자신을 주입 (ObjectProvider)
@Service
public class OrderService {
@Lazy @Autowired
private OrderService self; // 프록시 객체 주입
public void processOrder(Long orderId) {
self.saveOrder(orderId); // 프록시를 통해 호출 → 트랜잭션 적용
}
@Transactional
public void saveOrder(Long orderId) { ... }
}
// 해결 3: ApplicationContext에서 직접 가져오기
@Service
public class OrderService implements ApplicationContextAware {
private ApplicationContext context;
public void processOrder(Long orderId) {
context.getBean(OrderService.class).saveOrder(orderId);
}
@Transactional
public void saveOrder(Long orderId) { ... }
}
해결법 1(서비스 분리)이 가장 깔끔하다. 트랜잭션 전파 관련 심화 내용은 Spring Transaction 전파 심화 글을 참고하자.
커스텀 Aspect 작성
// 실행 시간 측정 Aspect
@Aspect
@Component
@Slf4j
public class ExecutionTimeAspect {
// Pointcut: @Timed 어노테이션이 붙은 메서드
@Around("@annotation(timed)")
public Object measureTime(ProceedingJoinPoint joinPoint, Timed timed) throws Throwable {
String methodName = joinPoint.getSignature().toShortString();
long start = System.nanoTime();
try {
Object result = joinPoint.proceed();
long elapsed = (System.nanoTime() - start) / 1_000_000;
log.info("⏱ {} completed in {}ms", methodName, elapsed);
return result;
} catch (Throwable e) {
long elapsed = (System.nanoTime() - start) / 1_000_000;
log.error("⏱ {} failed in {}ms: {}", methodName, elapsed, e.getMessage());
throw e;
}
}
}
// 커스텀 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed {}
// 사용
@Service
public class ReportService {
@Timed
public Report generateMonthlyReport(int year, int month) {
// 이 메서드 실행 시간이 자동으로 로깅됨
}
}
Advice 타입과 실행 순서
@Aspect
@Component
public class AuditAspect {
// @Before: 메서드 실행 전
@Before("execution(* com.example.service.*.*(..))")
public void beforeAdvice(JoinPoint jp) {
log.info("Before: {}", jp.getSignature().getName());
}
// @AfterReturning: 정상 반환 후
@AfterReturning(pointcut = "execution(* com.example.service.*.*(..))",
returning = "result")
public void afterReturning(JoinPoint jp, Object result) {
log.info("Returned: {} → {}", jp.getSignature().getName(), result);
}
// @AfterThrowing: 예외 발생 시
@AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))",
throwing = "ex")
public void afterThrowing(JoinPoint jp, Exception ex) {
log.error("Exception in {}: {}", jp.getSignature().getName(), ex.getMessage());
}
// @After: 항상 실행 (finally)
@After("execution(* com.example.service.*.*(..))")
public void afterAdvice(JoinPoint jp) {
log.info("After (finally): {}", jp.getSignature().getName());
}
// @Around: 전체 제어 (가장 강력)
@Around("execution(* com.example.service.*.*(..))")
public Object aroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
// Before 로직
Object result = pjp.proceed(); // 원본 실행
// After 로직
return result;
}
}
// 실행 순서 (Spring 5.2.7+):
// @Around (before proceed)
// @Before
// 원본 메서드 실행
// @AfterReturning 또는 @AfterThrowing
// @After
// @Around (after proceed)
Pointcut 표현식 심화
@Aspect
@Component
public class PointcutExamples {
// 1. execution: 메서드 시그니처 매칭
@Pointcut("execution(public * com.example.service.*Service.*(..))")
public void allServiceMethods() {}
// 2. within: 특정 패키지/클래스 내 모든 메서드
@Pointcut("within(com.example.controller..*)")
public void allControllerMethods() {}
// 3. @annotation: 특정 어노테이션이 붙은 메서드
@Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
public void transactionalMethods() {}
// 4. @within: 특정 어노테이션이 붙은 클래스의 모든 메서드
@Pointcut("@within(org.springframework.stereotype.Service)")
public void allServiceBeans() {}
// 5. args: 파라미터 타입 매칭
@Pointcut("execution(* com.example.service.*.*(..)) && args(id,..)")
public void methodsWithIdParam(Long id) {}
// 6. 조합: AND, OR, NOT
@Pointcut("allServiceMethods() && !transactionalMethods()")
public void serviceWithoutTransaction() {}
// 7. bean: 빈 이름 매칭 (Spring AOP 전용)
@Pointcut("bean(*Service)")
public void allServiceBeans2() {}
}
실전 패턴: 감사 로그 Aspect
// 커스텀 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
String action();
String resource() default "";
}
@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {
private final AuditLogRepository auditLogRepository;
private final SecurityContextHolder securityContext;
@Around("@annotation(auditable)")
public Object audit(ProceedingJoinPoint pjp, Auditable auditable) throws Throwable {
String userId = securityContext.getCurrentUserId();
String args = Arrays.toString(pjp.getArgs());
try {
Object result = pjp.proceed();
auditLogRepository.save(AuditLog.builder()
.userId(userId)
.action(auditable.action())
.resource(auditable.resource())
.params(args)
.status("SUCCESS")
.timestamp(Instant.now())
.build());
return result;
} catch (Throwable e) {
auditLogRepository.save(AuditLog.builder()
.userId(userId)
.action(auditable.action())
.resource(auditable.resource())
.params(args)
.status("FAILED")
.errorMessage(e.getMessage())
.timestamp(Instant.now())
.build());
throw e;
}
}
}
// 사용
@Service
public class UserService {
@Auditable(action = "DELETE_USER", resource = "user")
public void deleteUser(Long userId) {
// 삭제 로직 → 자동으로 감사 로그 기록
}
}
주의사항 정리
// 1. private 메서드에는 AOP 적용 불가
// CGLIB은 상속 기반이므로 private 오버라이드 불가
@Transactional
private void saveInternal() { } // ❌ 트랜잭션 미적용
// 2. final 클래스/메서드에 CGLIB 프록시 불가
@Service
public final class FinalService { // ❌ 프록시 생성 실패
}
// 3. @PostConstruct에서 AOP 미적용
// 빈 초기화 시점에는 아직 프록시가 완성되지 않을 수 있음
@PostConstruct
@Transactional // ❌ 동작하지 않을 수 있음
public void init() { }
// 해결: ApplicationReadyEvent 사용
@EventListener(ApplicationReadyEvent.class)
@Transactional // ✅ 이 시점에는 프록시 완성됨
public void onReady() { }
// 4. Aspect 실행 순서 제어
@Aspect
@Order(1) // 숫자가 작을수록 먼저 실행
@Component
public class SecurityAspect { }
@Aspect
@Order(2)
@Component
public class LoggingAspect { }
Spring AOP 프록시의 동작 원리를 정확히 알면 @Transactional, @Cacheable, @Async 관련 버그를 사전에 방지할 수 있다. 캐시 관련 심화 내용은 Redis 캐시 전략: Cache-Aside 글을 참고하자.
마무리
| 핵심 규칙 | 설명 |
|---|---|
| 프록시 = 외부 호출만 | self-invocation에서 AOP 미적용 |
| public 메서드만 | private/protected는 프록시 불가 |
| final 금지 (CGLIB) | final 클래스/메서드는 상속 불가 |
| @Around가 가장 강력 | 실행 전후·예외·반환값 모두 제어 |
Spring AOP 프록시는 Spring의 모든 선언적 기능(@Transactional, @Cacheable, @Async, @Retryable)의 기반이다. 프록시의 동작 원리를 이해하면 “왜 안 되지?”라는 질문의 답을 즉시 찾을 수 있고, 커스텀 Aspect로 횡단 관심사를 깔끔하게 분리할 수 있다.