횡단 관심사라는 문제
서비스 코드를 작성하다 보면 비즈니스 로직과는 무관한 코드가 여기저기 반복되는 걸 경험한다. 로깅, 트랜잭션 관리, 권한 검사, 성능 측정 같은 것들이다.
public void createOrder(OrderRequest request) {
log.info("createOrder 호출 - userId: {}", request.getUserId());
long start = System.currentTimeMillis();
// 실제 비즈니스 로직
Order order = Order.create(request);
orderRepository.save(order);
long elapsed = System.currentTimeMillis() - start;
log.info("createOrder 완료 - {}ms", elapsed);
}
비즈니스 로직은 딱 두 줄인데, 로깅과 시간 측정 코드가 감싸고 있다. 이런 코드가 updateOrder(), cancelOrder(), getOrder()에도 똑같이 들어가야 한다면? 복사-붙여넣기의 지옥이 시작된다.
이처럼 여러 모듈에 걸쳐 반복되는 부가 기능을 횡단 관심사라고 부른다. 영어로는 Cross-Cutting Concern이다. "횡단"이라는 이름이 붙은 이유는, 비즈니스 로직의 흐름을 가로질러 여러 지점에 동일하게 존재하기 때문이다.
각 메서드의 실행 흐름을 세로로, 부가 기능을 가로로 놓으면 "횡단"이라는 이름이 왜 붙었는지 바로 보인다.
세로(│)가 각 메서드의 실행 흐름이고, 가로(─)가 부가 기능이다. 부가 기능이 세로 흐름을 가로질러 모든 메서드에 동일하게 꽂히고 있다. 메서드가 하나 추가될 때마다 세 줄의 가로선이 함께 늘어나야 하는 셈이다.
AOP가 해결하는 것
AOP는 Aspect-Oriented Programming, 관점 지향 프로그래밍이다. OOP가 "객체" 단위로 관심사를 분리한다면, AOP는 횡단 관심사를 별도 모듈로 분리하는 프로그래밍 패러다임이다.
핵심 아이디어는 단순하다. "어디에(Where) 무엇을(What) 끼워 넣을지"를 선언적으로 정의하면, 프레임워크가 알아서 원래 코드에 부가 기능을 결합해준다. 비즈니스 로직은 비즈니스 로직만 남기고, 부가 기능은 한 곳에서 관리하는 것이다.
앞의 예제를 AOP로 분리하면 서비스 코드가 이렇게 깔끔해진다.
public void createOrder(OrderRequest request) {
Order order = Order.create(request);
orderRepository.save(order);
}
로깅과 시간 측정은 Aspect라는 별도 클래스가 담당한다. 서비스 코드는 자신에게 부가 기능이 붙어 있는지조차 모른다.
핵심 용어 정리
AOP에는 처음 접하면 낯선 용어가 많다. 하나씩 짚어보자.
Aspect
횡단 관심사를 모듈화한 클래스다. "무엇을 할 것인가"와 "어디에 적용할 것인가"를 하나로 묶은 단위라고 보면 된다.
@Aspect
@Component
public class ExecutionTimeAspect {
// Advice + Pointcut이 이 안에 정의된다
}
@Aspect를 붙이면 Spring이 이 클래스를 AOP 모듈로 인식한다. @Component도 함께 붙여야 스프링 빈으로 등록되어 동작한다.
Advice
Aspect가 "무엇을 할 것인가"에 해당하는 부분이다. 실제로 실행되는 부가 기능 코드 자체를 말한다.
@Around("execution(* com.example.service.*.*(..))")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
long elapsed = System.currentTimeMillis() - start;
log.info("{} 실행 시간: {}ms", joinPoint.getSignature().getName(), elapsed);
return result;
}
이 메서드 전체가 하나의 Advice다. Advice에는 5가지 종류가 있는데, 자세한 내용은 다음 편에서 다룬다.
JoinPoint
Advice가 끼어들 수 있는 프로그램 실행 지점이다. 메서드 호출, 필드 접근, 객체 생성 등이 될 수 있다.
다만 Spring AOP는 메서드 실행 시점만 JoinPoint로 지원한다. 필드 접근이나 생성자 호출 시점에 Advice를 끼워 넣으려면 AspectJ를 직접 써야 한다. 실무에서는 메서드 실행 시점만으로 거의 모든 케이스를 커버할 수 있기 때문에, 이 제약이 큰 문제가 되는 경우는 드물다.
코드에서 ProceedingJoinPoint나 JoinPoint 파라미터를 통해 현재 실행 중인 메서드의 정보(이름, 파라미터, 대상 객체 등)에 접근할 수 있다.
Pointcut
JoinPoint 중에서 실제로 Advice를 적용할 지점을 선별하는 조건식이다. "어디에 적용할 것인가"를 정의한다.
@Around("execution(* com.example.service.*.*(..))")
여기서 execution(* com.example.service.*.*(..)) 부분이 Pointcut 표현식이다. "com.example.service 패키지의 모든 클래스, 모든 메서드"를 의미한다.
JoinPoint와 Pointcut의 관계를 정리하면 이렇다. JoinPoint는 Advice가 적용될 수 있는 모든 후보 지점이고, Pointcut은 그 후보 중에서 실제로 적용할 지점을 필터링하는 조건이다.
Target
Advice가 적용되는 대상 객체다. 위 예시에서 com.example.service 패키지 안의 서비스 클래스 인스턴스가 Target이 된다. Aspect가 아닌, Aspect가 감싸는 원본 객체를 가리킨다.
Weaving
Aspect를 Target에 결합하는 과정이다. Advice 코드가 Target의 JoinPoint에 실제로 끼워 넣어지는 시점을 말한다.
Weaving이 일어나는 시점은 세 가지가 있다. Java 프로그램의 생애주기에서 각 시점이 어디에 해당하는지 보자.
Spring AOP는 가장 오른쪽, 런타임 시점에 프록시를 생성하는 방식이다. 나머지 두 시점은 AspectJ의 영역이다.
- 컴파일 타임 : 소스 코드를 컴파일할 때 바이트코드를 조작한다. AspectJ 컴파일러가 이 방식을 사용한다.
- 로드 타임 : 클래스가 JVM에 로딩될 때 바이트코드를 변환한다. AspectJ의 LTW(Load-Time Weaving)가 이 방식이다.
- 런타임 : 실행 시점에 프록시 객체를 생성해서 Advice를 적용한다. Spring AOP가 사용하는 방식이다.
Spring AOP는 런타임 Weaving을 사용하기 때문에, 별도의 컴파일러나 JVM 설정 없이 스프링 컨테이너만으로 AOP가 동작한다. 대신 프록시 기반이라는 구조적 제약이 생기는데, 이 부분은 3편에서 자세히 다룬다.
용어 간의 관계
지금까지 나온 용어를 하나의 문장으로 엮으면 이렇다.
Aspect는 횡단 관심사를 모듈화한 클래스이고, 그 안에 Advice(부가 기능 코드)와 Pointcut(적용 조건)이 정의된다. Pointcut이 지정한 JoinPoint(메서드 실행 시점)에서 Target(원본 객체)에 Advice가 실행되며, 이 결합 과정을 Weaving이라 한다.
용어 사이의 흐름을 따라가면 이런 그림이 된다.
Aspect 안에서 Pointcut이 먼저 Target의 JoinPoint를 훑어 조건에 맞는 것(✓)만 골라낸다. 그 다음 Advice가 선별된 JoinPoint에 결합되는데, 이 결합이 곧 Weaving이다. 결과물은 아래쪽 Proxy 객체로, 원본 메서드 호출 전후에 Advice가 자동으로 실행되는 구조가 만들어진다.
Spring에서 AOP 시작하기
Spring Boot를 사용한다면 의존성 하나만 추가하면 된다.
implementation 'org.springframework.boot:spring-boot-starter-aop'
이 스타터가 spring-aop와 aspectjweaver를 함께 가져온다. Spring Boot의 자동 설정 덕분에 @EnableAspectJAutoProxy 같은 활성화 어노테이션을 별도로 붙이지 않아도 된다.
Spring AOP는 런타임 프록시 방식이지 AspectJ 자체를 쓰는 건 아니다. 하지만 Pointcut 표현식 문법과 @Aspect, @Before 같은 어노테이션은 AspectJ에서 빌려 쓴다. 그래서 aspectjweaver 라이브러리가 필요한 것이다.
간단한 Aspect를 만들어 보자. 모든 서비스 메서드의 실행 시간을 측정하는 Aspect다.
@Aspect
@Component
public class ExecutionTimeAspect {
private static final Logger log = LoggerFactory.getLogger(ExecutionTimeAspect.class);
@Around("execution(* com.example.service.*.*(..))")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
long elapsed = System.currentTimeMillis() - start;
log.info("[{}] 실행 시간: {}ms", joinPoint.getSignature().toShortString(), elapsed);
return result;
}
}
joinPoint.proceed()가 원본 메서드를 호출하는 부분이다. 이 호출 전후로 시간을 측정하면 실행 시간을 알 수 있다. @Around는 메서드 실행 전후 모두에 개입할 수 있는 가장 강력한 Advice 타입이다.
이 Aspect를 등록하면, com.example.service 패키지의 모든 메서드가 호출될 때마다 실행 시간이 로그에 찍힌다. 서비스 코드는 한 줄도 건드리지 않았다.
자주 하는 실수
@Aspect만 붙이고 @Component를 빼먹는 경우가 많다. @Aspect는 "이 클래스가 Aspect다"라고 선언하는 것이고, 스프링 빈 등록은 별도로 해야 한다. @Component가 없으면 스프링이 이 클래스를 인식하지 못해서 Advice가 아무것도 동작하지 않는다.
[!DANGER] Advice의 반환값 무시
@Around Advice에서 joinPoint.proceed()의 반환값을 return하지 않으면, 원본 메서드의 결과가 사라진다. 호출자는 항상 null을 받게 된다.
```java
// 잘못된 예
@Around("execution( com.example.service..*(..))")
public void wrongAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
joinPoint.proceed(); // 결과를 버림
}
```
@Around를 쓸 때는 반드시 Object를 반환하고 proceed()의 결과를 돌려줘야 한다.