AOP란 무엇인가
AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 핵심 비즈니스 로직과 횡단 관심사(Cross-Cutting Concerns) 를 분리하는 프로그래밍 패러다임입니다.
횡단 관심사란 여러 모듈에 걸쳐 반복적으로 등장하는 공통 기능을 말합니다.
- 로깅
- 트랜잭션 관리
- 보안 인증/인가
- 실행 시간 측정
- 예외 처리
이런 기능들을 각 서비스마다 직접 작성하면 코드 중복이 발생하고 유지보수가 어려워집니다. AOP는 이 문제를 해결합니다.
핵심 용어 정리
| 용어 | 설명 |
|---|---|
| Aspect | 횡단 관심사를 모듈화한 클래스. Pointcut + Advice의 조합 |
| Advice | 실제로 실행될 부가 기능 코드 (언제 실행할지) |
| Pointcut | JoinPoint에 매칭되는 술어(predicate). Advice는 Pointcut 표현식과 연결되어 매칭된 JoinPoint에서 실행됨 |
| JoinPoint | Advice가 끼어들 수 있는 실행 지점 (Spring AOP에서는 메서드 실행만 해당) |
| Target Object | Advice가 적용되는 실제 객체 |
| Proxy | Target을 감싸는 대리 객체. AOP의 핵심 동작 주체 |
| Weaving | Aspect를 Target에 적용하는 과정 |
Spring AOP 동작 방식
Spring AOP는 런타임 Proxy 방식으로 동작합니다.
Client → Proxy (Advice 실행) → Target Object (핵심 로직)
Proxy 구현체는 두 가지입니다.
- JDK Dynamic Proxy : Target이 하나 이상의 인터페이스를 구현한 경우
- CGLIB Proxy : Target이 인터페이스를 구현하지 않은 경우 (서브클래스 방식)
Spring Framework 자체는 인터페이스 유무에 따라 위 두 가지를 자동으로 선택합니다. Spring Boot는 spring.aop.proxy-target-class의 기본값이 true이므로 CGLIB를 기본으로 사용하며, JDK 프록시를 사용하려면 이 값을 false로 설정해야 합니다.
의존성 추가
spring-boot-starter-aop 를 추가하면 됩니다.
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
// Gradle
implementation 'org.springframework.boot:spring-boot-starter-aop'
Aspect 작성 기본 구조
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect // 이 클래스가 Aspect임을 선언
@Component // Spring Bean으로 등록
public class LoggingAspect {
// Advice 메서드들이 여기에 위치합니다
}
Advice 종류별 예제
Before
메서드 실행 전에 동작합니다. 실행 흐름을 막을 수는 없지만, 예외를 던지면 JoinPoint로의 진행을 막을 수 있습니다.
@Before("execution(* com.example.service.*.*(..))")
public void beforeAdvice(JoinPoint joinPoint) {
System.out.println("[Before] 메서드 실행 전: " + joinPoint.getSignature().getName());
}
AfterReturning
메서드가 정상적으로 반환된 후 동작합니다. 반환값을 읽을 수 있지만, 완전히 다른 참조로 교체하는 것은 불가능합니다.
@AfterReturning(
pointcut = "execution(* com.example.service.*.*(..))",
returning = "result"
)
public void afterReturningAdvice(JoinPoint joinPoint, Object result) {
System.out.println("[AfterReturning] 반환값: " + result);
}
AfterThrowing
메서드에서 예외가 발생한 후 동작합니다. 예외를 잡아서 처리하거나 로깅할 수 있습니다.
@AfterThrowing(
pointcut = "execution(* com.example.service.*.*(..))",
throwing = "ex"
)
public void afterThrowingAdvice(JoinPoint joinPoint, Exception ex) {
System.out.println("[AfterThrowing] 예외 발생: " + ex.getMessage());
}
After
메서드 실행 후 정상/예외 여부 관계없이 항상 동작합니다. 공식 문서에서는 "after finally advice"로 표현하며, Java의 finally 블록과 동일한 의미입니다. 주로 리소스 해제 등에 사용됩니다.
@After("execution(* com.example.service.*.*(..))")
public void afterAdvice(JoinPoint joinPoint) {
System.out.println("[After] 항상 실행: " + joinPoint.getSignature().getName());
}
Around
메서드 실행 전·후를 모두 제어할 수 있는 가장 강력한 Advice입니다. ProceedingJoinPoint를 통해 실제 메서드 실행 여부를 직접 결정합니다. 공식 문서는 요구사항을 충족하는 가장 약한 형태의 Advice를 사용하도록 권장합니다. @Before로 충분하다면 @Around를 사용하지 않는 것이 좋습니다.
@Around("execution(* com.example.service.*.*(..))")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("[Around] 실행 전");
Object result = joinPoint.proceed(); // 실제 메서드 실행
System.out.println("[Around] 실행 후, 반환값: " + result);
return result;
}
joinPoint.proceed() 를 호출하지 않으면 실제 메서드가 실행되지 않습니다. 이를 이용해 캐싱, 권한 검사 등을 구현할 수 있습니다.
Pointcut 표현식
execution 표현식이 가장 많이 사용됩니다.
execution([접근제어자] 반환타입 [패키지.클래스.]메서드명(파라미터))
주요 예시
// 특정 패키지의 모든 메서드
execution(* com.example.service.*.*(..))
// 특정 클래스의 모든 메서드
execution(* com.example.service.UserService.*(..))
// 특정 메서드명
execution(* com.example.service.UserService.findById(..))
// 반환타입 지정
execution(String com.example.service.*.*(..))
// 파라미터 없는 메서드
execution(* com.example.service.*.*())
// 첫 번째 파라미터가 Long인 메서드
execution(* com.example.service.*.*(Long, ..))
와일드카드 의미
| 기호 | 의미 |
|---|---|
* |
하나의 아무 값 (패키지 한 단계, 메서드명, 반환타입 등) |
.. |
0개 이상 (파라미터 여러 개 또는 패키지 여러 단계) |
Pointcut 재사용
@Aspect
@Component
public class LoggingAspect {
// Pointcut 별도 정의
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceLayer() {}
// 재사용
@Before("serviceLayer()")
public void beforeAdvice(JoinPoint joinPoint) {
// ...
}
@After("serviceLayer()")
public void afterAdvice(JoinPoint joinPoint) {
// ...
}
}
JoinPoint 활용
JoinPoint 객체를 통해 현재 실행 중인 메서드의 정보를 얻을 수 있습니다.
@Before("execution(* com.example.service.*.*(..))")
public void beforeAdvice(JoinPoint joinPoint) {
// 메서드 시그니처
String methodName = joinPoint.getSignature().getName();
// 클래스명
String className = joinPoint.getTarget().getClass().getSimpleName();
// 전달된 인자값
Object[] args = joinPoint.getArgs();
System.out.println("클래스: " + className + ", 메서드: " + methodName);
System.out.println("인자: " + Arrays.toString(args));
}
주의사항
Self-invocation 문제
같은 클래스 내부에서 메서드를 호출하면 Proxy를 거치지 않아 AOP가 동작하지 않습니다.
@Service
public class OrderService {
public void placeOrder() {
// 내부 호출 → Proxy 미경유 → AOP 미적용
this.validateOrder();
}
@LogExecutionTime // 적용 안 됨
public void validateOrder() {
// ...
}
}
해결 방법으로는 별도 클래스로 분리하거나, ApplicationContext에서 자기 자신 Bean을 주입받아 호출하는 방식을 사용합니다.
final 클래스/메서드
CGLIB는 상속을 통해 Proxy를 생성하므로 final 클래스나 final 메서드에는 AOP를 적용할 수 없습니다.
Spring Bean이 아닌 객체
new 키워드로 직접 생성한 객체는 Spring이 관리하지 않으므로 AOP가 적용되지 않습니다. 반드시 Spring Bean으로 등록된 객체에만 적용됩니다.