4.1 ~ 4.4 AOP (관점 지향 프로그래밍)
1. AOP의 필요성 — 공통 관심사 분리
좋은 코드를 작성하다 보면 한 가지 짜증나는 상황에 부딪힙니다. 아래처럼 로그 기록, 실행 시간 측정, 트랜잭션 처리 같은 코드가 여러 메서드마다 중복으로 등장하는 것입니다.
public class OrderService {
public void placeOrder(String item) {
long start = System.currentTimeMillis(); // 공통 코드 ①
System.out.println("[LOG] placeOrder 시작"); // 공통 코드 ②
// ✅ 핵심 비즈니스 로직 (단 한 줄)
System.out.println(item + " 주문 완료!");
long end = System.currentTimeMillis(); // 공통 코드 ①
System.out.println("[LOG] 실행 시간: " + (end - start) + "ms"); // 공통 코드 ②
}
public void cancelOrder(String item) {
long start = System.currentTimeMillis(); // 공통 코드 ①
System.out.println("[LOG] cancelOrder 시작"); // 공통 코드 ②
// ✅ 핵심 비즈니스 로직 (단 한 줄)
System.out.println(item + " 주문 취소!");
long end = System.currentTimeMillis(); // 공통 코드 ①
System.out.println("[LOG] 실행 시간: " + (end - start) + "ms"); // 공통 코드 ②
}
}
핵심 로직은 딱 한 줄인데, 반복되는 부가 코드가 메서드를 무겁게 만들고 있습니다. 메서드가 100개라면 100곳에 모두 고쳐야 합니다.
이처럼 핵심 비즈니스 관심(Core Concern) 과 지속적으로 같이 등장하는 공통·부가 관심사(Cross-cutting Concern) 를 완전히 분리하자는 아이디어가 바로 AOP (Aspect-Oriented Programming) 입니다.
2. AOP 핵심 용어
| 용어 | 한국어 설명 | 예시 |
|---|---|---|
| Aspect | 공통 관심사를 하나의 모듈로 묶은 것 | @Aspect 클래스 |
| Target | AOP가 적용될 실제 비즈니스 객체 | OrderService |
| Advice | 실제로 실행될 공통 코드 (부가 기능) | 로그 출력, 시간 측정 |
| Pointcut | Advice를 어디에 적용할지 결정하는 표현식 | execution(* com.example..*(..)) |
| JoinPoint | Advice가 적용될 수 있는 시점 (메서드 실행 등) | placeOrder() 실행 순간 |
| Weaving | Aspect를 Target 객체에 연결하는 과정 | 스프링이 프록시를 만드는 과정 |
Advice의 종류 (언제 실행되는가?)
| 종류 | 설명 |
|---|---|
@Before | 메서드 실행 전 에 동작 |
@AfterReturning | 메서드가 정상 반환 후 동작 |
@AfterThrowing | 메서드에서 예외 발생 후 동작 |
@After | 정상/오류 여부 무관하게 동작 (Finally 역할) |
@Around | 메서드 실행 전후 모두 제어 (가장 강력) |
@AfterReturning | 메서드가 성공적으로 실행되었을 때 결과값을 처리 |
@AfterThrowing | 예외가 발생했을 때 예외 정보를 필요로 할 때 |
Pointcut 표현식 가이드
execution(* com.example.service.*.*(..)) 같은 포인트컷은 어떻게 작성할까요?
*: 모든 리턴 타입 / 모든 메서드명 / 모든 패키지 경로..: 0개 이상의 파라미터 / 하위 모든 패키지(..): 모든 파라미터 허용execution: 메서드 실행 조인 포인트와 매칭@annotation: 특정 애너테이션이 붙은 메서드만 매칭
3. 스프링 AOP 실전 예시 (실행 시간 측정)
이제 AOP를 사용하면 비즈니스 로직에서 공통 코드를 완전히 분리할 수 있습니다.
1단계: 의존성 추가
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-aop'
2단계: Aspect 클래스 작성
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect // 이 클래스는 AOP Aspect입니다.
@Component // 스프링 빈으로 등록
public class TimeTraceAop {
// Pointcut: com.example 패키지 하위의 모든 메서드에 적용
@Around("execution(* com.example..*(..))")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
System.out.println("[AOP] START: " + joinPoint.toString());
try {
return joinPoint.proceed(); // ✅ 실제 비즈니스 메서드 실행
} finally {
long finish = System.currentTimeMillis();
System.out.println("[AOP] END: " + joinPoint.toString() + " " + (finish - start) + "ms");
}
}
}
3단계: 비즈니스 로직은 순수하게 유지
@Service
public class OrderService {
public void placeOrder(String item) {
// ✅ 이제 핵심 로직만 남았습니다!
System.out.println(item + " 주문 완료!");
}
public void cancelOrder(String item) {
// ✅ 깔끔하게 핵심 로직만 남았습니다!
System.out.println(item + " 주문 취소!");
}
}
TimeTraceAop가 대신 모든 메서드를 감시하며 시간을 측정하므로, OrderService는 자신의 책임(주문 처리)에만 집중할 수 있습니다.
4. 프록시(Proxy) 동작 원리
AOP가 마법처럼 짜여진 이유가 궁금할 것입니다. 스프링 AOP의 핵심 구현 원리는 런타임 프록시(Dynamic Proxy) 패턴입니다.
클라이언트 코드 스프링이 만들어 넣는 프록시 객체 진짜 객체
OrderController --> OrderService (Proxy) [AOP 코드 포함] --> OrderService (Real)
- 스프링 컨테이너는
@Aspect를 감지하면, Target 클래스(OrderService)를 직접 주입하지 않고, 그 클래스를 감싸는 ** 프록시 객체**를 대신 주입합니다. - 클라이언트는 프록시인지 모르고 메서드를 호출합니다.
- 프록시 내부에서 Advice(공통 코드) 를 실행한 뒤, 진짜 객체의 메서드를 호출합니다.
정리: AOP는 비즈니스 코드를 전혀 건드리지 않으면서, 외부에서 투명하게 부가 기능을 끼워 넣는 세련된 기술입니다. 스프링의
@Transactional이 바로 이 AOP 프록시로 구현되어 있습니다.
5. 실무 팁: 커스텀 애너테이션과 AOP 조합
특성 패키지 전체가 아니라, 내가 원하는 메서드에만 스티커를 붙이듯 AOP를 적용하고 싶을 때 커스텀 애너테이션 을 사용합니다.
1단계: 애너테이션 생성
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime { } // 단순히 표시 용도
2단계: Aspect에서 애너테이션 감지
@Around("@annotation(LogExecutionTime)") // 이 애너테이션이 붙은 메서드만!
public Object logTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object proceed = joinPoint.proceed();
long executionTime = System.currentTimeMillis() - start;
System.out.println(joinPoint.getSignature() + " 실행 시간: " + executionTime + "ms");
return proceed;
}
3단계: 필요한 메서드에 적용
@LogExecutionTime
public void heavyJob() {
// 로직 수행...
}