Spring AOP - 관점 지향 프로그래밍 완벽 가이드

서비스 코드 곳곳에 같은 로깅, 같은 트랜잭션 처리, 같은 권한 체크가 반복되기 시작하면 결국 AOP가 답이 된다. Spring AOP는 이런 횡단 관심사를 본문 코드에서 떼어내, 별도의 Aspect로 따로 관리할 수 있게 해 준다.
AOP 핵심 개념
주요 용어
| 용어 | 설명 |
|---|---|
| Aspect | 횡단 관심사를 모듈화한 것 (예: 로깅, 트랜잭션) |
| Join Point | Aspect를 적용할 수 있는 지점 (메서드 실행, 예외 발생 등) |
| Pointcut | Join Point를 선별하는 표현식 |
| Advice | 실제로 실행되는 코드 (Before, After, Around 등) |
| Weaving | Aspect를 대상 객체에 적용하는 과정 |
AOP 처리 흐름
Business Method 호출
↓
Point Cut (어디에 적용할지 결정)
↓
Advice (언제 실행할지 결정)
↓
Aspect (실제 실행되는 공통 로직)
Spring AOP 설정
의존성 추가
Gradle:
implementation 'org.springframework.boot:spring-boot-starter-aop'
Maven:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Kotlin 프로젝트 추가 설정
Kotlin에서 AOP를 사용하려면 추가 의존성이 필요합니다:
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
implementation 'org.jetbrains.kotlin:kotlin-reflect'
Aspect 클래스 작성
기본 구조
@Aspect
@Component
public class LoggingAspect {
private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class);
@After("execution(* com.example.service.*.*(..))")
public void logAfterMethod(JoinPoint joinPoint) {
logger.info("Method executed: {}", joinPoint.getSignature().toShortString());
}
}
Kotlin 예시
@Aspect
@Component
class LoggingAspect {
private val logger = LoggerFactory.getLogger(LoggingAspect::class.java)
@After("execution(* com.example.service.*.*(..))")
fun logAfterMethod(joinPoint: JoinPoint) {
logger.info("Method executed: ${joinPoint.signature.toShortString()}")
}
}
Advice 종류
@Before
메서드 실행 전에 실행됩니다:
@Before("execution(* com.example.service.*.*(..))")
public void beforeAdvice(JoinPoint joinPoint) {
logger.info("Before: {}", joinPoint.getSignature().getName());
}
@After
메서드 실행 후 항상 실행됩니다 (예외 발생 여부와 관계없이):
@After("execution(* com.example.service.*.*(..))")
public void afterAdvice(JoinPoint joinPoint) {
logger.info("After: {}", joinPoint.getSignature().getName());
}
@AfterReturning
메서드가 정상적으로 반환된 후 실행됩니다:
@AfterReturning(pointcut = "execution(* com.example.service.*.*(..))",
returning = "result")
public void afterReturningAdvice(JoinPoint joinPoint, Object result) {
logger.info("Method {} returned: {}",
joinPoint.getSignature().getName(), result);
}
@AfterThrowing
메서드에서 예외가 발생한 후 실행됩니다:
@AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))",
throwing = "exception")
public void afterThrowingAdvice(JoinPoint joinPoint, Exception exception) {
logger.error("Method {} threw exception: {}",
joinPoint.getSignature().getName(), exception.getMessage());
}
@Around
메서드 실행 전후를 모두 제어할 수 있습니다:
@Around("execution(* com.example.service.*.*(..))")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
return result;
} finally {
long endTime = System.currentTimeMillis();
logger.info("Method {} execution time: {}ms",
joinPoint.getSignature().getName(), (endTime - startTime));
}
}
Pointcut 표현식
execution 표현식
execution(modifiers-pattern? return-type-pattern declaring-type-pattern?
method-name-pattern(param-pattern) throws-pattern?)
예시:
// 모든 public 메서드
@Pointcut("execution(public * *(..))")
public void publicMethod() {}
// 특정 패키지의 모든 메서드
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceLayer() {}
// 특정 클래스의 모든 메서드
@Pointcut("execution(* com.example.service.UserService.*(..))")
public void userServiceMethods() {}
// 특정 메서드명으로 시작하는 메서드
@Pointcut("execution(* com.example.service.*.get*(..))")
public void getterMethods() {}
// 특정 인자 타입을 가진 메서드
@Pointcut("execution(* com.example.service.*.*(String, ..))")
public void methodsWithStringFirstParam() {}
within 표현식
특정 타입 내의 모든 메서드를 선택합니다:
@Pointcut("within(com.example.service.*)")
public void inServicePackage() {}
@Pointcut("within(com.example.service..*)")
public void inServicePackageAndSubPackages() {}
@annotation 표현식
특정 어노테이션이 붙은 메서드를 선택합니다:
@Pointcut("@annotation(com.example.annotation.Loggable)")
public void loggableMethods() {}
Pointcut 조합
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceMethods() {}
@Pointcut("execution(* com.example.repository.*.*(..))")
public void repositoryMethods() {}
// AND 조합
@Before("serviceMethods() && repositoryMethods()")
public void combinedAdvice(JoinPoint joinPoint) {}
// OR 조합
@Before("serviceMethods() || repositoryMethods()")
public void eitherAdvice(JoinPoint joinPoint) {}
// NOT 조합
@Before("serviceMethods() && !repositoryMethods()")
public void excludeAdvice(JoinPoint joinPoint) {}
실전 예제
실행 시간 측정 Aspect
@Aspect
@Component
public class PerformanceAspect {
private static final Logger logger = LoggerFactory.getLogger(PerformanceAspect.class);
@Around("@annotation(com.example.annotation.MeasureExecutionTime)")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long executionTime = System.currentTimeMillis() - start;
logger.info("{} executed in {}ms",
joinPoint.getSignature().toShortString(), executionTime);
}
}
}
// 커스텀 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MeasureExecutionTime {}
// 사용 예시
@Service
public class UserService {
@MeasureExecutionTime
public User findUser(Long id) {
// 비즈니스 로직
return userRepository.findById(id);
}
}
감사(Audit) 로깅 Aspect
@Aspect
@Component
public class AuditAspect {
private static final Logger logger = LoggerFactory.getLogger(AuditAspect.class);
@AfterReturning(
pointcut = "execution(* com.example.service.*.save*(..)) || " +
"execution(* com.example.service.*.update*(..)) || " +
"execution(* com.example.service.*.delete*(..))",
returning = "result"
)
public void auditDataChange(JoinPoint joinPoint, Object result) {
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
logger.info("AUDIT: {} called with args: {}, result: {}",
methodName, Arrays.toString(args), result);
}
}
예외 처리 Aspect
@Aspect
@Component
public class ExceptionHandlingAspect {
private static final Logger logger = LoggerFactory.getLogger(ExceptionHandlingAspect.class);
@Autowired
private NotificationService notificationService;
@AfterThrowing(
pointcut = "execution(* com.example.service.*.*(..))",
throwing = "ex"
)
public void handleException(JoinPoint joinPoint, Exception ex) {
String methodName = joinPoint.getSignature().toShortString();
logger.error("Exception in {}: {}", methodName, ex.getMessage(), ex);
// 심각한 예외인 경우 알림 발송
if (ex instanceof CriticalException) {
notificationService.sendAlert(
"Critical error in " + methodName + ": " + ex.getMessage()
);
}
}
}
Kotlin에서 AOP 사용 시 주의사항
Kotlin 클래스는 기본적으로 final이므로, AOP 프록시가 상속을 통해 동작할 수 없습니다. 다음과 같이 open 키워드를 사용해야 합니다:
open class UserService(private val userRepository: UserRepository) {
open fun findUser(id: Long): User {
return userRepository.findById(id)
}
}
또는 all-open 플러그인을 사용하면 자동으로 처리됩니다:
plugins {
kotlin("plugin.spring") version "1.9.0"
}
AOP 동작 원리
프록시 기반 AOP
Spring AOP는 프록시 패턴을 사용합니다:
- JDK Dynamic Proxy: 인터페이스 기반으로 프록시 생성
- CGLIB Proxy: 클래스 기반으로 프록시 생성 (인터페이스가 없는 경우)
Self-Invocation 문제
같은 클래스 내에서 메서드를 호출하면 AOP가 적용되지 않습니다:
@Service
public class UserService {
public void processUser(Long id) {
// AOP가 적용되지 않음!
this.logUser(id);
}
@Loggable
public void logUser(Long id) {
// 로직
}
}
해결 방법:
@Service
public class UserService {
@Autowired
private ApplicationContext applicationContext;
public void processUser(Long id) {
// 프록시를 통해 호출
applicationContext.getBean(UserService.class).logUser(id);
}
@Loggable
public void logUser(Long id) {
// 로직
}
}
정리하며
AOP는 한 번 적용해 두면 본문 코드가 눈에 띄게 가벼워지는 반면, 잘못 쓰면 어느 시점에 어떤 Advice가 끼어드는지 추적하기 어려워진다. self-invocation 문제와 Kotlin의 final 클래스 이슈처럼 프록시 기반 동작에서 비롯되는 함정만 미리 알고 있으면, 로깅·트랜잭션·권한 같은 공통 관심사를 깔끔하게 정리하는 좋은 도구다.
Comments