Spring 06: AOP 완전 정복
관점 지향 프로그래밍의 개념부터 실전 활용까지
1. AOP 개념과 필요성
2. Spring AOP 동작 원리
3. Advice 종류와 활용
4. Pointcut 표현식
5. 실전 AOP 활용 패턴
6. Aspect 순서와 고급 설정
7. 정리 및 베스트 프랙티스
- • AOP의 핵심 개념과 필요성을 이해한다
- • Spring AOP의 프록시 기반 동작 원리를 파악한다
- • 다양한 Advice와 Pointcut 표현식을 활용한다
- • 실무에서 자주 사용하는 AOP 패턴을 구현한다
AOP 개념과 필요성
AOP(Aspect-Oriented Programming)란?
AOP는 관점 지향 프로그래밍으로, 핵심 비즈니스 로직과 부가 기능(횡단 관심사)을 분리하여 모듈화하는 프로그래밍 패러다임입니다. 로깅, 트랜잭션, 보안, 캐싱 등 여러 모듈에 공통으로 적용되는 기능을 한 곳에서 관리할 수 있습니다.
횡단 관심사(Cross-Cutting Concerns)
AOP 없이 구현할 때의 문제점
// 모든 서비스 메서드에 반복되는 코드
@Service
public class OrderService {
public Order createOrder(OrderRequest request) {
long startTime = System.currentTimeMillis(); // 성능 측정
log.info("createOrder 시작: {}", request); // 로깅
try {
validateUser(); // 보안 체크
Order order = processOrder(request); // 핵심 로직
log.info("createOrder 완료: {}", order);
return order;
} catch (Exception e) {
log.error("createOrder 실패", e); // 예외 로깅
throw e;
} finally {
long endTime = System.currentTimeMillis();
log.info("실행 시간: {}ms", endTime - startTime);
}
}
public void cancelOrder(Long orderId) {
long startTime = System.currentTimeMillis(); // 또 반복!
log.info("cancelOrder 시작: {}", orderId);
try {
validateUser();
// 핵심 로직...
} catch (Exception e) {
log.error("cancelOrder 실패", e);
throw e;
} finally {
// 또 반복...
}
}
// 모든 메서드에 동일한 패턴 반복...
}문제점
- • 코드 중복: 동일한 부가 기능 코드가 모든 메서드에 반복
- • 유지보수 어려움: 로깅 형식 변경 시 모든 메서드 수정 필요
- • 핵심 로직 가독성 저하: 비즈니스 로직이 부가 기능에 묻힘
- • 단일 책임 원칙 위반: 서비스가 여러 관심사를 처리
AOP 적용 후
// 핵심 로직만 남은 깔끔한 서비스
@Service
public class OrderService {
@LogExecutionTime
@Secured("ROLE_USER")
public Order createOrder(OrderRequest request) {
return processOrder(request); // 핵심 로직만!
}
@LogExecutionTime
@Secured("ROLE_USER")
public void cancelOrder(Long orderId) {
// 핵심 로직만!
}
}
// 부가 기능은 Aspect로 분리
@Aspect
@Component
public class LoggingAspect {
@Around("@annotation(LogExecutionTime)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
String methodName = joinPoint.getSignature().getName();
log.info("{} 시작", methodName);
try {
Object result = joinPoint.proceed();
log.info("{} 완료", methodName);
return result;
} catch (Exception e) {
log.error("{} 실패", methodName, e);
throw e;
} finally {
long endTime = System.currentTimeMillis();
log.info("{} 실행 시간: {}ms", methodName, endTime - startTime);
}
}
}장점
- • 관심사 분리: 핵심 로직과 부가 기능이 명확히 분리
- • 코드 재사용: 부가 기능을 여러 메서드에 쉽게 적용
- • 유지보수 용이: 부가 기능 변경 시 Aspect만 수정
- • 가독성 향상: 비즈니스 로직에 집중 가능
AOP 핵심 용어
| 용어 | 설명 | 예시 |
|---|---|---|
| Aspect | 횡단 관심사를 모듈화한 것 | @Aspect LoggingAspect |
| Join Point | Aspect를 적용할 수 있는 지점 (메서드 실행, 생성자 호출 등) | 메서드 실행 시점 |
| Pointcut | Join Point를 선별하는 표현식 | execution(* com.example.service.*.*(..)) |
| Advice | 특정 Join Point에서 실행되는 코드 | @Before, @After, @Around |
| Target | Aspect가 적용되는 대상 객체 | OrderService 인스턴스 |
| Weaving | Aspect를 Target에 적용하는 과정 | 컴파일/로드/런타임 시점 |
Spring AOP 동작 원리
프록시 기반 AOP
Spring AOP는 프록시 패턴을 사용하여 구현됩니다. 실제 객체 대신 프록시 객체가 호출을 가로채서 부가 기능을 수행한 후 실제 객체의 메서드를 호출합니다.
프록시 동작 흐름
Client → Proxy → Advice(Before) → Target Method → Advice(After) → Client
[호출 흐름]
1. 클라이언트가 orderService.createOrder() 호출
2. 실제로는 프록시 객체의 메서드가 호출됨
3. 프록시가 @Before Advice 실행
4. 프록시가 실제 Target의 createOrder() 호출
5. 프록시가 @After Advice 실행
6. 결과를 클라이언트에게 반환JDK Dynamic Proxy
- • 인터페이스 기반 프록시
- • 인터페이스가 있어야 사용 가능
- • Java 표준 라이브러리 사용
- • 리플렉션 기반으로 동작
// 인터페이스 필요
public interface OrderService {
Order createOrder(OrderRequest req);
}
@Service
public class OrderServiceImpl
implements OrderService {
// 구현
}CGLIB Proxy
- • 클래스 기반 프록시
- • 인터페이스 없이도 사용 가능
- • 바이트코드 조작으로 서브클래스 생성
- • Spring Boot 기본값
// 인터페이스 없이도 가능
@Service
public class OrderService {
public Order createOrder(
OrderRequest req) {
// 구현
}
}프록시 생성 과정
// Spring이 내부적으로 수행하는 프록시 생성 (개념적 코드)
@Configuration
@EnableAspectJAutoProxy
public class AopConfig {
// @EnableAspectJAutoProxy가 활성화되면
// BeanPostProcessor가 등록되어 프록시 생성
}
// 프록시 생성 과정
1. Spring Container가 Bean 생성
2. BeanPostProcessor가 @Aspect Bean들을 확인
3. Pointcut과 매칭되는 Bean 탐색
4. 매칭되는 Bean에 대해 프록시 객체 생성
5. 원본 Bean 대신 프록시 Bean을 Container에 등록
// 프록시 확인 방법
@Service
public class TestService {
@Autowired
private OrderService orderService;
public void checkProxy() {
System.out.println(orderService.getClass());
// 출력: class com.example.OrderService$$EnhancerBySpringCGLIB$$abc123
boolean isProxy = AopUtils.isAopProxy(orderService);
boolean isCglib = AopUtils.isCglibProxy(orderService);
System.out.println("Is Proxy: " + isProxy); // true
System.out.println("Is CGLIB: " + isCglib); // true
}
}⚠️ Self-Invocation 문제
같은 클래스 내에서 메서드를 호출하면 프록시를 거치지 않아 AOP가 적용되지 않습니다.
@Service
public class OrderService {
@LogExecutionTime // AOP 적용됨
public void processOrder(Long orderId) {
// 내부 메서드 호출 - AOP 적용 안됨!
this.validateOrder(orderId); // 프록시를 거치지 않음
}
@LogExecutionTime // 내부 호출 시 적용 안됨
public void validateOrder(Long orderId) {
// 검증 로직
}
}
// 외부에서 호출하면 AOP 적용됨
orderService.validateOrder(orderId); // 프록시 통과 → AOP 적용
// 내부에서 호출하면 AOP 적용 안됨
this.validateOrder(orderId); // 프록시 우회 → AOP 미적용해결 방법
// 방법 1: 자기 자신을 주입받아 사용
@Service
public class OrderService {
@Autowired
private OrderService self; // 프록시 객체 주입
public void processOrder(Long orderId) {
self.validateOrder(orderId); // 프록시 통과!
}
}
// 방법 2: AopContext 사용 (권장하지 않음)
@EnableAspectJAutoProxy(exposeProxy = true)
public class AopConfig {}
public void processOrder(Long orderId) {
((OrderService) AopContext.currentProxy()).validateOrder(orderId);
}
// 방법 3: 별도 클래스로 분리 (권장)
@Service
public class OrderService {
@Autowired
private OrderValidator validator;
public void processOrder(Long orderId) {
validator.validate(orderId); // 다른 Bean 호출 → AOP 적용
}
}프록시 설정
# application.yml
spring:
aop:
proxy-target-class: true # CGLIB 강제 사용 (기본값)
# false로 설정하면 인터페이스가 있을 때 JDK Proxy 사용
# Java Config
@Configuration
@EnableAspectJAutoProxy(
proxyTargetClass = true, // CGLIB 사용
exposeProxy = false // AopContext 사용 여부
)
public class AopConfig {
}Spring Boot 기본 설정
Spring Boot 2.0부터 proxyTargetClass=true가 기본값입니다. 인터페이스 유무와 관계없이 CGLIB 프록시를 사용합니다.
Advice 종류와 활용
Advice 종류 개요
| Advice | 실행 시점 | 주요 용도 |
|---|---|---|
| @Before | 메서드 실행 전 | 파라미터 검증, 로깅, 권한 체크 |
| @AfterReturning | 메서드 정상 완료 후 | 결과 로깅, 후처리 |
| @AfterThrowing | 예외 발생 시 | 예외 로깅, 알림 발송 |
| @After | 메서드 완료 후 (finally) | 리소스 정리 |
| @Around | 메서드 실행 전후 | 성능 측정, 트랜잭션, 캐싱 |
@Before - 메서드 실행 전
@Aspect
@Component
@Slf4j
public class ValidationAspect {
// 메서드 실행 전에 파라미터 검증
@Before("execution(* com.example.service.*Service.create*(..))")
public void validateBeforeCreate(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
log.info("메서드 실행 전: {}", methodName);
log.info("파라미터: {}", Arrays.toString(args));
// 파라미터 검증
for (Object arg : args) {
if (arg == null) {
throw new IllegalArgumentException("파라미터가 null입니다");
}
}
}
// 특정 어노테이션이 붙은 메서드에만 적용
@Before("@annotation(secured)")
public void checkSecurity(JoinPoint joinPoint, Secured secured) {
String[] roles = secured.value();
log.info("필요 권한: {}", Arrays.toString(roles));
// 현재 사용자 권한 체크
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!hasAnyRole(auth, roles)) {
throw new AccessDeniedException("권한이 없습니다");
}
}
}@AfterReturning - 정상 완료 후
@Aspect
@Component
@Slf4j
public class ResultLoggingAspect {
// 반환값을 받아서 처리
@AfterReturning(
pointcut = "execution(* com.example.service.*Service.*(..))",
returning = "result"
)
public void logResult(JoinPoint joinPoint, Object result) {
String methodName = joinPoint.getSignature().getName();
log.info("메서드 완료: {} - 결과: {}", methodName, result);
}
// 특정 타입의 반환값만 처리
@AfterReturning(
pointcut = "execution(* com.example.service.OrderService.*(..))",
returning = "order"
)
public void afterOrderCreated(JoinPoint joinPoint, Order order) {
if (order != null) {
log.info("주문 생성됨: orderId={}, amount={}",
order.getId(), order.getTotalAmount());
// 이벤트 발행, 알림 발송 등 후처리
eventPublisher.publish(new OrderCreatedEvent(order));
}
}
}@AfterThrowing - 예외 발생 시
@Aspect
@Component
@Slf4j
public class ExceptionLoggingAspect {
@Autowired
private SlackNotificationService slackService;
// 모든 예외 로깅
@AfterThrowing(
pointcut = "execution(* com.example.service.*.*(..))",
throwing = "ex"
)
public void logException(JoinPoint joinPoint, Exception ex) {
String methodName = joinPoint.getSignature().toShortString();
Object[] args = joinPoint.getArgs();
log.error("예외 발생 - 메서드: {}, 파라미터: {}, 예외: {}",
methodName, Arrays.toString(args), ex.getMessage(), ex);
}
// 특정 예외만 처리
@AfterThrowing(
pointcut = "execution(* com.example.service.*.*(..))",
throwing = "ex"
)
public void handleBusinessException(JoinPoint joinPoint, BusinessException ex) {
// BusinessException만 처리됨
log.warn("비즈니스 예외: {} - {}", ex.getErrorCode(), ex.getMessage());
}
// 심각한 예외 발생 시 알림
@AfterThrowing(
pointcut = "execution(* com.example.service.PaymentService.*(..))",
throwing = "ex"
)
public void notifyPaymentError(JoinPoint joinPoint, Exception ex) {
String methodName = joinPoint.getSignature().getName();
slackService.sendAlert(
"결제 오류 발생",
String.format("메서드: %s, 오류: %s", methodName, ex.getMessage())
);
}
}@After - 메서드 완료 후 (finally)
@Aspect
@Component
@Slf4j
public class CleanupAspect {
// 성공/실패 관계없이 항상 실행 (finally와 동일)
@After("execution(* com.example.service.*Service.*(..))")
public void cleanup(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
log.debug("메서드 종료: {}", methodName);
// 리소스 정리, ThreadLocal 클리어 등
RequestContextHolder.resetRequestAttributes();
}
// MDC 정리
@After("execution(* com.example.controller.*Controller.*(..))")
public void clearMdc() {
MDC.clear();
}
}@Around - 메서드 실행 전후 (가장 강력)
@Aspect
@Component
@Slf4j
public class PerformanceAspect {
// 실행 시간 측정
@Around("execution(* com.example.service.*.*(..))")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().toShortString();
long startTime = System.currentTimeMillis();
try {
// 메서드 실행 전 로직
log.info("시작: {}", methodName);
// 실제 메서드 실행 - 반드시 호출해야 함!
Object result = joinPoint.proceed();
// 메서드 실행 후 로직 (정상 완료)
return result;
} catch (Exception e) {
// 예외 발생 시 로직
log.error("실패: {} - {}", methodName, e.getMessage());
throw e; // 예외 재발생
} finally {
// 항상 실행되는 로직
long endTime = System.currentTimeMillis();
log.info("종료: {} - 실행시간: {}ms", methodName, endTime - startTime);
}
}
// 파라미터 변경
@Around("execution(* com.example.service.*.*(String, ..))")
public Object trimStringParams(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
// 문자열 파라미터 trim 처리
for (int i = 0; i < args.length; i++) {
if (args[i] instanceof String) {
args[i] = ((String) args[i]).trim();
}
}
// 변경된 파라미터로 실행
return joinPoint.proceed(args);
}
// 결과 변경
@Around("execution(* com.example.service.ProductService.findAll(..))")
public Object filterInactiveProducts(ProceedingJoinPoint joinPoint) throws Throwable {
@SuppressWarnings("unchecked")
List<Product> products = (List<Product>) joinPoint.proceed();
// 결과 필터링
return products.stream()
.filter(p -> p.getStatus() == ProductStatus.ACTIVE)
.collect(Collectors.toList());
}
// 캐싱 구현
@Around("@annotation(Cacheable)")
public Object cacheResult(ProceedingJoinPoint joinPoint) throws Throwable {
String cacheKey = generateCacheKey(joinPoint);
// 캐시에서 조회
Object cached = cacheManager.get(cacheKey);
if (cached != null) {
log.debug("캐시 히트: {}", cacheKey);
return cached;
}
// 캐시 미스 - 실제 메서드 실행
Object result = joinPoint.proceed();
// 결과 캐싱
cacheManager.put(cacheKey, result);
log.debug("캐시 저장: {}", cacheKey);
return result;
}
}@Around 주의사항
- •
joinPoint.proceed()를 반드시 호출해야 실제 메서드가 실행됨 - • 호출하지 않으면 메서드가 실행되지 않음 (의도적으로 차단 가능)
- • 반환값을 반드시 return 해야 함
- • 예외를 catch하면 반드시 다시 throw 하거나 적절히 처리
Pointcut 표현식
Pointcut은 어떤 Join Point에 Advice를 적용할지 선별하는 표현식입니다. 정확한 Pointcut 작성은 AOP 활용의 핵심입니다.
execution 표현식 (가장 많이 사용)
execution(접근제어자? 반환타입 선언타입?메서드명(파라미터) 예외?)
// 각 부분 설명
execution(
public // 접근제어자 (생략 가능)
String // 반환 타입 (* = 모든 타입)
com.example.service.OrderService // 클래스 (생략 가능)
.createOrder // 메서드명 (* = 모든 메서드)
(Long, String) // 파라미터 타입
throws Exception // 예외 (생략 가능)
)execution 예제
// 모든 public 메서드
execution(public * *(..))
// 모든 메서드 (접근제어자 무관)
execution(* *(..))
// 특정 패키지의 모든 메서드
execution(* com.example.service.*.*(..))
// 특정 패키지와 하위 패키지의 모든 메서드
execution(* com.example..*.*(..))
// Service로 끝나는 클래스의 모든 메서드
execution(* com.example..*Service.*(..))
// 특정 메서드명 패턴
execution(* com.example.service.*.find*(..)) // find로 시작
execution(* com.example.service.*.*Order(..)) // Order로 끝남
// 특정 반환 타입
execution(List com.example.service.*.*(..)) // List 반환
execution(void com.example.service.*.*(..)) // void 반환
// 특정 파라미터
execution(* com.example.service.*.*(Long)) // Long 1개
execution(* com.example.service.*.*(Long, String)) // Long, String
execution(* com.example.service.*.*(Long, ..)) // Long으로 시작, 나머지 무관
execution(* com.example.service.*.*(*)) // 파라미터 1개 (타입 무관)
execution(* com.example.service.*.*(*, *)) // 파라미터 2개 (타입 무관)within 표현식
특정 타입(클래스) 내의 모든 메서드에 적용
// 특정 클래스의 모든 메서드
@Pointcut("within(com.example.service.OrderService)")
public void orderServiceMethods() {}
// 특정 패키지의 모든 클래스
@Pointcut("within(com.example.service.*)")
public void servicePackage() {}
// 특정 패키지와 하위 패키지
@Pointcut("within(com.example.service..*)")
public void servicePackageAndSubPackages() {}
// 특정 인터페이스 구현체
@Pointcut("within(com.example.service.PaymentService+)")
public void paymentServiceImplementations() {}@annotation 표현식
특정 어노테이션이 붙은 메서드에 적용
// 커스텀 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime {}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
int maxAttempts() default 3;
long delay() default 1000;
}
// @annotation으로 Pointcut 정의
@Aspect
@Component
public class AnnotationBasedAspect {
// 특정 어노테이션이 붙은 메서드
@Around("@annotation(LogExecutionTime)")
public Object logTime(ProceedingJoinPoint joinPoint) throws Throwable {
// ...
}
// 어노테이션 값 사용
@Around("@annotation(retry)")
public Object retryOnFailure(ProceedingJoinPoint joinPoint, Retry retry)
throws Throwable {
int maxAttempts = retry.maxAttempts();
long delay = retry.delay();
Exception lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return joinPoint.proceed();
} catch (Exception e) {
lastException = e;
if (attempt < maxAttempts) {
Thread.sleep(delay);
}
}
}
throw lastException;
}
// Spring 제공 어노테이션
@Before("@annotation(org.springframework.transaction.annotation.Transactional)")
public void beforeTransaction(JoinPoint joinPoint) {
// @Transactional 메서드 실행 전
}
}@within, @target 표현식
// 클래스 레벨 어노테이션 정의
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {}
@Loggable
@Service
public class OrderService {
// 이 클래스의 모든 메서드에 AOP 적용
}
// @within - 해당 어노테이션이 선언된 클래스의 메서드
@Pointcut("@within(Loggable)")
public void loggableClass() {}
// @target - 런타임에 해당 어노테이션을 가진 객체의 메서드
@Pointcut("@target(Loggable)")
public void loggableTarget() {}
// @within vs @target
// @within: 정적 - 선언된 클래스 기준
// @target: 동적 - 실제 객체 타입 기준 (상속 고려)args, @args 표현식
// args - 파라미터 타입으로 매칭
@Pointcut("args(Long, ..)")
public void firstArgLong() {}
@Pointcut("args(com.example.dto.OrderRequest)")
public void orderRequestArg() {}
// 파라미터 바인딩
@Before("execution(* com.example.service.*.*(..)) && args(id, ..)")
public void logFirstArg(Long id) {
log.info("첫 번째 파라미터: {}", id);
}
// @args - 파라미터에 특정 어노테이션이 있는 경우
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Validated {}
@Validated
public class OrderRequest { }
@Before("@args(Validated)")
public void validateArg(JoinPoint joinPoint) {
// @Validated가 붙은 타입의 파라미터가 있는 메서드
}Pointcut 조합
@Aspect
@Component
public class CombinedPointcutAspect {
// 재사용 가능한 Pointcut 정의
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceLayer() {}
@Pointcut("execution(* com.example.repository.*.*(..))")
public void repositoryLayer() {}
@Pointcut("@annotation(LogExecutionTime)")
public void logAnnotation() {}
// AND 조합 (&&)
@Around("serviceLayer() && logAnnotation()")
public Object logServiceMethods(ProceedingJoinPoint joinPoint) throws Throwable {
// Service 레이어이면서 @LogExecutionTime이 붙은 메서드
return joinPoint.proceed();
}
// OR 조합 (||)
@Before("serviceLayer() || repositoryLayer()")
public void beforeServiceOrRepository(JoinPoint joinPoint) {
// Service 또는 Repository 레이어 메서드
}
// NOT 조합 (!)
@After("serviceLayer() && !logAnnotation()")
public void afterNonLoggedService(JoinPoint joinPoint) {
// Service 레이어이면서 @LogExecutionTime이 없는 메서드
}
// 복잡한 조합
@Pointcut("serviceLayer() && !execution(* *.get*(..))")
public void nonGetterServiceMethods() {}
}Pointcut 표현식 정리
| 표현식 | 설명 | 예시 |
|---|---|---|
| execution | 메서드 시그니처 매칭 | execution(* com.example..*.*(..)) |
| within | 특정 타입 내 메서드 | within(com.example.service.*) |
| @annotation | 메서드 어노테이션 | @annotation(Transactional) |
| @within | 클래스 어노테이션 | @within(Service) |
| args | 파라미터 타입 | args(Long, String) |
| bean | Bean 이름 (Spring 전용) | bean(*Service) |
실전 AOP 활용 패턴
1. 성능 모니터링 Aspect
@Aspect
@Component
@Slf4j
public class PerformanceMonitoringAspect {
private final MeterRegistry meterRegistry;
@Around("@annotation(MonitorPerformance)")
public Object monitorPerformance(ProceedingJoinPoint joinPoint) throws Throwable {
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
String metricName = className + "." + methodName;
Timer.Sample sample = Timer.start(meterRegistry);
try {
Object result = joinPoint.proceed();
// 성공 메트릭
sample.stop(Timer.builder("method.execution")
.tag("class", className)
.tag("method", methodName)
.tag("status", "success")
.register(meterRegistry));
return result;
} catch (Exception e) {
// 실패 메트릭
sample.stop(Timer.builder("method.execution")
.tag("class", className)
.tag("method", methodName)
.tag("status", "error")
.tag("exception", e.getClass().getSimpleName())
.register(meterRegistry));
throw e;
}
}
// 느린 쿼리 감지
@Around("execution(* com.example.repository.*.*(..))")
public Object detectSlowQuery(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long executionTime = System.currentTimeMillis() - startTime;
if (executionTime > 1000) { // 1초 이상
log.warn("느린 쿼리 감지: {} - {}ms",
joinPoint.getSignature().toShortString(), executionTime);
// 알림 발송
alertService.sendSlowQueryAlert(
joinPoint.getSignature().toString(),
executionTime
);
}
}
}
}2. 감사(Audit) 로깅 Aspect
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
String action();
String resourceType();
}
@Aspect
@Component
@Slf4j
public class AuditAspect {
private final AuditLogRepository auditLogRepository;
@AfterReturning(
pointcut = "@annotation(auditable)",
returning = "result"
)
public void audit(JoinPoint joinPoint, Auditable auditable, Object result) {
String username = getCurrentUsername();
String action = auditable.action();
String resourceType = auditable.resourceType();
String resourceId = extractResourceId(result);
AuditLog auditLog = AuditLog.builder()
.username(username)
.action(action)
.resourceType(resourceType)
.resourceId(resourceId)
.methodName(joinPoint.getSignature().getName())
.parameters(serializeArgs(joinPoint.getArgs()))
.result(serializeResult(result))
.ipAddress(getClientIpAddress())
.userAgent(getUserAgent())
.timestamp(LocalDateTime.now())
.build();
auditLogRepository.save(auditLog);
log.info("감사 로그: {} - {} {} {}", username, action, resourceType, resourceId);
}
@AfterThrowing(
pointcut = "@annotation(auditable)",
throwing = "ex"
)
public void auditFailure(JoinPoint joinPoint, Auditable auditable, Exception ex) {
AuditLog auditLog = AuditLog.builder()
.username(getCurrentUsername())
.action(auditable.action() + "_FAILED")
.resourceType(auditable.resourceType())
.errorMessage(ex.getMessage())
.timestamp(LocalDateTime.now())
.build();
auditLogRepository.save(auditLog);
}
}
// 사용 예시
@Service
public class OrderService {
@Auditable(action = "CREATE", resourceType = "ORDER")
public Order createOrder(OrderRequest request) {
// 주문 생성 로직
}
@Auditable(action = "CANCEL", resourceType = "ORDER")
public void cancelOrder(Long orderId) {
// 주문 취소 로직
}
}3. 분산 락 Aspect
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key(); // 락 키 (SpEL 지원)
long waitTime() default 5000; // 락 대기 시간 (ms)
long leaseTime() default 10000; // 락 유지 시간 (ms)
}
@Aspect
@Component
@Slf4j
public class DistributedLockAspect {
private final RedissonClient redissonClient;
private final SpelExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock)
throws Throwable {
// SpEL로 동적 키 생성
String lockKey = parseKey(distributedLock.key(), joinPoint);
RLock lock = redissonClient.getLock("lock:" + lockKey);
boolean acquired = false;
try {
// 락 획득 시도
acquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
TimeUnit.MILLISECONDS
);
if (!acquired) {
throw new LockAcquisitionException("락 획득 실패: " + lockKey);
}
log.debug("락 획득: {}", lockKey);
return joinPoint.proceed();
} finally {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
log.debug("락 해제: {}", lockKey);
}
}
}
private String parseKey(String keyExpression, ProceedingJoinPoint joinPoint) {
if (!keyExpression.contains("#")) {
return keyExpression;
}
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
Expression expression = parser.parseExpression(keyExpression);
return expression.getValue(context, String.class);
}
}
// 사용 예시
@Service
public class StockService {
@DistributedLock(key = "'stock:' + #productId", waitTime = 3000, leaseTime = 5000)
public void decreaseStock(Long productId, int quantity) {
// 재고 감소 로직 - 동시성 안전
}
@DistributedLock(key = "'order:' + #orderId")
public void processPayment(Long orderId) {
// 결제 처리 - 중복 방지
}
}4. API Rate Limiting Aspect
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int requests() default 100; // 허용 요청 수
int seconds() default 60; // 시간 윈도우 (초)
String key() default ""; // 제한 키 (기본: IP)
}
@Aspect
@Component
@Slf4j
public class RateLimitAspect {
private final StringRedisTemplate redisTemplate;
@Around("@annotation(rateLimit)")
public Object rateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit)
throws Throwable {
String key = buildRateLimitKey(rateLimit, joinPoint);
int maxRequests = rateLimit.requests();
int windowSeconds = rateLimit.seconds();
// Redis를 이용한 슬라이딩 윈도우 카운터
String redisKey = "rate_limit:" + key;
Long currentCount = redisTemplate.opsForValue().increment(redisKey);
if (currentCount == 1) {
redisTemplate.expire(redisKey, windowSeconds, TimeUnit.SECONDS);
}
if (currentCount > maxRequests) {
log.warn("Rate limit 초과: key={}, count={}", key, currentCount);
throw new RateLimitExceededException(
String.format("요청 한도 초과: %d/%d (윈도우: %d초)",
currentCount, maxRequests, windowSeconds)
);
}
return joinPoint.proceed();
}
private String buildRateLimitKey(RateLimit rateLimit, ProceedingJoinPoint joinPoint) {
if (!rateLimit.key().isEmpty()) {
return parseSpelKey(rateLimit.key(), joinPoint);
}
// 기본: IP + 메서드명
String clientIp = getClientIpAddress();
String methodName = joinPoint.getSignature().toShortString();
return clientIp + ":" + methodName;
}
}
// 사용 예시
@RestController
public class ApiController {
@RateLimit(requests = 10, seconds = 60) // 분당 10회
@GetMapping("/api/search")
public List<Product> search(@RequestParam String keyword) {
return productService.search(keyword);
}
@RateLimit(requests = 5, seconds = 3600, key = "'user:' + #userId") // 시간당 5회
@PostMapping("/api/orders")
public Order createOrder(@RequestParam Long userId, @RequestBody OrderRequest request) {
return orderService.createOrder(userId, request);
}
}5. 트랜잭션 로깅 Aspect
@Aspect
@Component
@Slf4j
public class TransactionLoggingAspect {
// @Transactional 메서드 모니터링
@Around("@annotation(transactional)")
public Object logTransaction(ProceedingJoinPoint joinPoint,
Transactional transactional) throws Throwable {
String methodName = joinPoint.getSignature().toShortString();
String propagation = transactional.propagation().name();
String isolation = transactional.isolation().name();
boolean readOnly = transactional.readOnly();
String txId = UUID.randomUUID().toString().substring(0, 8);
MDC.put("txId", txId);
log.info("트랜잭션 시작: {} [propagation={}, isolation={}, readOnly={}]",
methodName, propagation, isolation, readOnly);
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
long duration = System.currentTimeMillis() - startTime;
log.info("트랜잭션 커밋: {} [{}ms]", methodName, duration);
return result;
} catch (Exception e) {
long duration = System.currentTimeMillis() - startTime;
// 롤백 대상 예외인지 확인
boolean willRollback = isRollbackException(transactional, e);
if (willRollback) {
log.error("트랜잭션 롤백: {} [{}ms] - {}",
methodName, duration, e.getMessage());
} else {
log.warn("트랜잭션 커밋 (예외 발생했지만 롤백 제외): {} - {}",
methodName, e.getMessage());
}
throw e;
} finally {
MDC.remove("txId");
}
}
private boolean isRollbackException(Transactional tx, Exception e) {
// rollbackFor에 포함되거나 RuntimeException/Error인 경우 롤백
for (Class<? extends Throwable> rollbackFor : tx.rollbackFor()) {
if (rollbackFor.isInstance(e)) return true;
}
// noRollbackFor에 포함되면 롤백 안함
for (Class<? extends Throwable> noRollback : tx.noRollbackFor()) {
if (noRollback.isInstance(e)) return false;
}
return e instanceof RuntimeException || e instanceof Error;
}
}Aspect 순서와 고급 설정
Aspect 실행 순서 제어
여러 Aspect가 같은 Join Point에 적용될 때 실행 순서를 제어할 수 있습니다.@Order 값이 낮을수록 먼저 실행됩니다.
// 순서: SecurityAspect → LoggingAspect → TransactionAspect → 실제 메서드
@Aspect
@Component
@Order(1) // 가장 먼저 실행
public class SecurityAspect {
@Before("execution(* com.example.service.*.*(..))")
public void checkSecurity(JoinPoint joinPoint) {
log.info("1. 보안 체크");
}
}
@Aspect
@Component
@Order(2)
public class LoggingAspect {
@Around("execution(* com.example.service.*.*(..))")
public Object log(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("2. 로깅 시작");
Object result = joinPoint.proceed();
log.info("2. 로깅 종료");
return result;
}
}
@Aspect
@Component
@Order(3) // 가장 나중에 실행 (실제 메서드에 가장 가까움)
public class TransactionAspect {
@Around("execution(* com.example.service.*.*(..))")
public Object transaction(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("3. 트랜잭션 시작");
Object result = joinPoint.proceed();
log.info("3. 트랜잭션 종료");
return result;
}
}
// 실행 순서 (양파 껍질 구조)
// → SecurityAspect.before
// → LoggingAspect.around (before)
// → TransactionAspect.around (before)
// → 실제 메서드 실행
// ← TransactionAspect.around (after)
// ← LoggingAspect.around (after)
// ← SecurityAspect.after권장 순서
- 1. 보안/인증 (가장 바깥)
- 2. 로깅/모니터링
- 3. 캐싱
- 4. 트랜잭션 (가장 안쪽)
같은 Aspect 내 Advice 순서
@Aspect
@Component
public class OrderedAdviceAspect {
// 같은 Aspect 내에서의 실행 순서
// 1. @Around (before proceed)
// 2. @Before
// 3. 실제 메서드
// 4. @AfterReturning 또는 @AfterThrowing
// 5. @After
// 6. @Around (after proceed)
@Around("execution(* com.example.service.*.*(..))")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("Around - before");
try {
Object result = joinPoint.proceed();
log.info("Around - after returning");
return result;
} catch (Exception e) {
log.info("Around - after throwing");
throw e;
} finally {
log.info("Around - finally");
}
}
@Before("execution(* com.example.service.*.*(..))")
public void before() {
log.info("Before");
}
@AfterReturning("execution(* com.example.service.*.*(..))")
public void afterReturning() {
log.info("AfterReturning");
}
@AfterThrowing("execution(* com.example.service.*.*(..))")
public void afterThrowing() {
log.info("AfterThrowing");
}
@After("execution(* com.example.service.*.*(..))")
public void after() {
log.info("After");
}
}
// 정상 실행 시 출력:
// Around - before
// Before
// [실제 메서드]
// AfterReturning
// After
// Around - after returning
// Around - finally
// 예외 발생 시 출력:
// Around - before
// Before
// [실제 메서드 - 예외 발생]
// AfterThrowing
// After
// Around - after throwing
// Around - finallyJoinPoint 정보 활용
@Aspect
@Component
@Slf4j
public class JoinPointInfoAspect {
@Before("execution(* com.example.service.*.*(..))")
public void logJoinPointInfo(JoinPoint joinPoint) {
// 시그니처 정보
Signature signature = joinPoint.getSignature();
log.info("메서드명: {}", signature.getName());
log.info("선언 타입: {}", signature.getDeclaringTypeName());
log.info("전체 시그니처: {}", signature.toLongString());
log.info("짧은 시그니처: {}", signature.toShortString());
// MethodSignature로 캐스팅하면 더 많은 정보
if (signature instanceof MethodSignature) {
MethodSignature methodSig = (MethodSignature) signature;
log.info("반환 타입: {}", methodSig.getReturnType());
log.info("파라미터 타입: {}", Arrays.toString(methodSig.getParameterTypes()));
log.info("파라미터 이름: {}", Arrays.toString(methodSig.getParameterNames()));
log.info("메서드: {}", methodSig.getMethod());
}
// 대상 객체 정보
log.info("Target 클래스: {}", joinPoint.getTarget().getClass());
log.info("Proxy 클래스: {}", joinPoint.getThis().getClass());
// 파라미터 값
Object[] args = joinPoint.getArgs();
log.info("파라미터 값: {}", Arrays.toString(args));
// JoinPoint 종류
log.info("JoinPoint 종류: {}", joinPoint.getKind()); // method-execution
}
// 메서드의 어노테이션 정보 가져오기
@Before("@annotation(loggable)")
public void getAnnotationInfo(JoinPoint joinPoint, Loggable loggable) {
// 어노테이션 값 직접 접근
String value = loggable.value();
log.info("어노테이션 값: {}", value);
}
// 리플렉션으로 어노테이션 가져오기
@Before("execution(* com.example.service.*.*(..))")
public void getAnnotationByReflection(JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 메서드 어노테이션
if (method.isAnnotationPresent(Transactional.class)) {
Transactional tx = method.getAnnotation(Transactional.class);
log.info("트랜잭션 readOnly: {}", tx.readOnly());
}
// 클래스 어노테이션
Class<?> targetClass = joinPoint.getTarget().getClass();
if (targetClass.isAnnotationPresent(Service.class)) {
log.info("Service 클래스입니다");
}
// 파라미터 어노테이션
Annotation[][] paramAnnotations = method.getParameterAnnotations();
for (int i = 0; i < paramAnnotations.length; i++) {
for (Annotation annotation : paramAnnotations[i]) {
log.info("파라미터 {} 어노테이션: {}", i, annotation);
}
}
}
}조건부 Aspect 활성화
// Profile에 따른 활성화
@Aspect
@Component
@Profile("!prod") // 운영 환경에서는 비활성화
public class DebugLoggingAspect {
@Around("execution(* com.example..*.*(..))")
public Object debugLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.debug("DEBUG: {} 시작", joinPoint.getSignature());
Object result = joinPoint.proceed();
log.debug("DEBUG: {} 종료 - 결과: {}", joinPoint.getSignature(), result);
return result;
}
}
// 조건부 Bean 등록
@Configuration
public class AopConfig {
@Bean
@ConditionalOnProperty(name = "app.aop.performance.enabled", havingValue = "true")
public PerformanceAspect performanceAspect() {
return new PerformanceAspect();
}
@Bean
@ConditionalOnExpression("${app.aop.audit.enabled:false}")
public AuditAspect auditAspect() {
return new AuditAspect();
}
}
// application.yml
app:
aop:
performance:
enabled: true
audit:
enabled: falseAOP 테스트
@SpringBootTest
class AopTest {
@Autowired
private OrderService orderService;
@Autowired
private ApplicationContext context;
@Test
void proxyTest() {
// 프록시 확인
assertThat(AopUtils.isAopProxy(orderService)).isTrue();
assertThat(AopUtils.isCglibProxy(orderService)).isTrue();
}
@Test
void aspectAppliedTest() {
// Aspect가 적용되었는지 확인
Advised advised = (Advised) orderService;
Advisor[] advisors = advised.getAdvisors();
assertThat(advisors).isNotEmpty();
for (Advisor advisor : advisors) {
log.info("Advisor: {}", advisor);
}
}
}
// Aspect 단위 테스트
@ExtendWith(MockitoExtension.class)
class LoggingAspectTest {
@InjectMocks
private LoggingAspect loggingAspect;
@Mock
private ProceedingJoinPoint joinPoint;
@Mock
private Signature signature;
@Test
void logExecutionTime_success() throws Throwable {
// given
when(joinPoint.getSignature()).thenReturn(signature);
when(signature.toShortString()).thenReturn("OrderService.createOrder(..)");
when(joinPoint.proceed()).thenReturn("result");
// when
Object result = loggingAspect.logExecutionTime(joinPoint);
// then
assertThat(result).isEqualTo("result");
verify(joinPoint).proceed();
}
@Test
void logExecutionTime_exception() throws Throwable {
// given
when(joinPoint.getSignature()).thenReturn(signature);
when(signature.toShortString()).thenReturn("OrderService.createOrder(..)");
when(joinPoint.proceed()).thenThrow(new RuntimeException("error"));
// when & then
assertThatThrownBy(() -> loggingAspect.logExecutionTime(joinPoint))
.isInstanceOf(RuntimeException.class)
.hasMessage("error");
}
}정리 및 베스트 프랙티스
AOP 핵심 요약
핵심 개념
- • Aspect: 횡단 관심사 모듈
- • Advice: 실행할 코드
- • Pointcut: 적용 대상 선별
- • Join Point: 적용 가능 지점
- • Weaving: Aspect 적용 과정
Advice 종류
- • @Before: 메서드 실행 전
- • @AfterReturning: 정상 완료 후
- • @AfterThrowing: 예외 발생 시
- • @After: 항상 실행 (finally)
- • @Around: 전후 모두 (가장 강력)
실무 활용 사례
로깅/모니터링
- • 메서드 실행 시간 측정
- • 입출력 파라미터 로깅
- • 예외 로깅 및 알림
- • 메트릭 수집
보안/인증
- • 권한 체크
- • 감사(Audit) 로깅
- • Rate Limiting
- • 입력값 검증
인프라
- • 트랜잭션 관리
- • 캐싱
- • 재시도 로직
- • 분산 락
Pointcut 표현식 Quick Reference
// 가장 많이 사용하는 패턴
execution(* com.example.service.*.*(..)) // service 패키지 모든 메서드
execution(* com.example..*.*(..)) // 하위 패키지 포함
execution(* *..service.*.*(..)) // 어떤 패키지든 service 하위
@annotation(LogExecutionTime) // 특정 어노테이션
@within(org.springframework.stereotype.Service) // @Service 클래스
// 조합
serviceLayer() && @annotation(Transactional)
execution(* *..*Service.*(..)) && !execution(* *..*Service.get*(..))✅ 베스트 프랙티스
공통 Pointcut은 별도 클래스에 정의하여 재사용
public class CommonPointcuts {
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceLayer() {}
}단순 로깅은 @Before/@After로 충분, @Around는 필요할 때만
여러 Aspect 사용 시 @Order로 순서 명확히 지정
@Around에서 예외 catch 시 반드시 재발생 또는 적절한 처리
너무 넓은 Pointcut은 성능에 영향, 필요한 범위만 지정
⚠️ 주의사항
- Self-Invocation: 같은 클래스 내부 호출은 AOP 적용 안됨→ 별도 클래스로 분리하거나 self 주입
- private 메서드: CGLIB도 private 메서드에는 AOP 적용 불가→ protected 이상으로 변경
- final 클래스/메서드: CGLIB 프록시 생성 불가→ final 제거 또는 인터페이스 사용
- proceed() 누락: @Around에서 proceed() 미호출 시 메서드 실행 안됨→ 의도적 차단이 아니면 반드시 호출
Spring AOP vs AspectJ
| 구분 | Spring AOP | AspectJ |
|---|---|---|
| Weaving | 런타임 (프록시) | 컴파일/로드 타임 |
| Join Point | 메서드 실행만 | 필드, 생성자 등 모두 |
| 성능 | 프록시 오버헤드 | 네이티브 수준 |
| 설정 | 간단 | 복잡 (컴파일러 필요) |
| 사용 | 대부분의 경우 충분 | 고급 기능 필요 시 |
권장: 대부분의 엔터프라이즈 애플리케이션에서는 Spring AOP로 충분합니다. 필드 접근 제어나 생성자 AOP가 필요한 특수한 경우에만 AspectJ를 고려하세요.