레거시한 프로젝트를 전부 @Aspect 로 바꿀 일이 생겨
공부하는 겸 정리했습니다.
들어가기 앞서 기본적으로 빈후처리기, 프록시팩토리 등 기본지식이 있어야 이해하실 수 있습니다.
설치
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-aop' //직접 추가
@Aspect
@Slf4j
@Aspect
public class AspectA1 {
// hello.aop.order 패키지와 하위 패키지
@Around("execution(* hello.aop.order..*(..))")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
@Around 애노테이션의 값인 execution(* hello.aop.order..*(..)) 는 포인트컷이 됩니다.
@Around 애노테이션의 메서드인 doLog 는 어드바이스가 됩니다.
@Around("execution(* hello.aop.order..*(..))") 는 hello.aop.order 패키지와 그 하위 패키지(..) 를 지정하는 AspectJ 포인트컷 표현식입니다.
만약 저 order 패키지 경로에 OrderService, OrderRepository 가 있다면 해당 파일의 모든 메서드는 AOP 적용의 대상이 됩니다.
주의할점
@Aspect 는 애스펙트 라는 표식이지 컴포넌트 스캔이 되는것은 아닙니다.
따라서 AOP 로 사용하려면 스프링 빈으로 등록하셔야 합니다.
포인트컷 분리
@Around 에 포인트컷 표현식을 직접 넣을 수 있지만, @Pointcut 애노테이션을 사용해서 별도로 분리할 수도 있습니다.
@Slf4j
@Aspect
public class AspectV2 {
// hello.aop.order 패키지와 하위 패키지
@Pointcut("execution(* hello.aop.order..*(..))")
private void allOrder(){}
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
이러한 @Pointcut 은 몇가지 규칙을 가집니다.
1. 메서드 반환 타입은 void 여야 합니다.
2. 코드 내용은 비워둬야 합니다.
3. private,public 같은 접근 제어자는 내부에서만 사용하면 private 을 사용해도 되지만, 다른 애스펙트에서 참고하려면 public 으로 하셔야 합니다.
두가지 포인트컷 합치기
아래와 같이 두가지의 포인트컷을 합칠 수 있습니다.
여기서 오해할 수 있는 부분은 "allOrder() && allService()" 이러한 표현은 @Around 를 실행하는 게 아니라
@Pointcut 에 선언된 경로 조건만 조합해서 사용하는 것입니다.
@Slf4j
@Aspect
public class AspectV2 {
// hello.aop.order 패키지와 하위 패키지
@Pointcut("execution(* hello.aop.order..*(..))")
private void allOrder(){}
// 클래스 이름 패턴이 ~Service 인 것들
@Pointcut("execution(* *..*(Service.*(..)))")
private void allService(){}
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
// hello.aop.order 패키지와 하위 패키지 이면서 클래스 이름 패턴이 ~Service 인 경우
@Around("allOrder() && allService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
//...
}
}
allService() 포인트컷은 타입 이름 패턴이 XxxService 처럼 Service 로 끝나는 것을 대상으로 합니다.
예를 들어 *Servi* 라는 패턴도 가능합니다.
@Around("allOrder() && allService()") 는 &&, ||, ! 3가지 조합도 가능합니다.
여기서 궁금한 부분은 상위 경로에도 AOP 가 적용되어 있고 하위 경로에도 AOP 가 적용되어 있다면 어떻게 코드가 실행될 지 입니다.
즉 doLog 가 실행되고 doTransaction 이 실행되는 건가? 라는 의문점이 생기면 맞는 생각입니다.
이렇게 프록시가 체인처럼 연결된 것을 프록시 체인 이라고 합니다.
프록시 체인
@Around("allOrder()")
public Object doLog(...) // 로그 어드바이스
@Around("allOrder() && allService()")
public Object doTransaction(...) // 트랜잭션 어드바이스
@Around("allOrder()") 인 doLog() 는 allOrder() 조건에 모든 타켓에 적용이 됩니다.
@Around("allOrder() && allService()") 인 doTransaction() 은 서비스 클래스 중에서만 적용됩니다.
스프링은 이 두 어드바이스를 체인으로 연결해서 적용해 줍니다.
doLog() {
// 로그 출력
doTransaction() {
// 트랜잭션 처리
targetMethod() // 진짜 서비스 로직 호출
}
}
즉 스프링이 알아서 아래처럼 묶어준다고 생각하시면 됩니다.
doLog() {
log.info("로그 찍기 전");
return doTransaction() {
log.info("트랜잭션 시작");
result = targetMethod();
log.info("트랜잭션 커밋");
return result;
}
log.info("로그 찍기 후");
}
그때그때 다음 Aspect를 찾아 실행한다"기보단 스프링이 처음부터 적용 가능한 모든 어드바이스를 쭉 모아서 체인 형태로 연결해 놓고, 그 체인을 따라가며 joinPoint.proceed()를 호출하는 것입니다.
실제 동작 구조
1.스프링이 AOP 프록시를 만들 때, 그 메서드에 적용 가능한 모든 @Around 어드바이스를 미리 정렬해둠.
해당 메서드 호출 시 첫 번째 어드바이스 실행 그 안에서 joinPoint.proceed() → 다음 어드바이스 실행
다시 joinPoint.proceed() → ... 어드바이스가 더 없으면 진짜 대상 메서드 호출
코드로 체인이 연결되는 예제는 아래와 같습니다.
@Around("execution(* hello.aop.order..*(..))") // 상위 경로 전체 대상
@Around("execution(* *..*Service.*(..))") // 클래스 이름이 *Service인 대상
포인트컷을 외부로 빼서 사용
다음과 같이 포인트컷을 공용으로 사용하기 위해 별도의 외부 클래스에 모아두어도 됩니다. 참고로 외부에서 호출할 때는 포인트컷의 접근 제어자를 public 으로 열어두어야 합니다.
package hello.aop.order.aop;
import org.aspectj.lang.annotation.Pointcut;
public class Pointcuts {
//hello.springaop.app 패키지와 하위 패키지
@Pointcut("execution(* hello.aop.order..*(..))")
public void allOrder(){}
//타입 패턴이 *Service
@Pointcut("execution(* *..*Service.*(..))")
public void allService(){}
//allOrder && allService
@Pointcut("allOrder() && allService()")
public void orderAndService(){}
}
@Slf4j
@Aspect
public class AspectV4Pointcut {
@Around("hello.aop.order.aop.Pointcuts.allOrder()") // 경로 지정
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
//...
}
//...
}
AOP 는 순서대로 실행
테스트를 하면 아시겠지만 AOP 가 적용되고 실행되는 순서가 위에서 아래로 실행이 됩니다.
만약 어드바이스가 적용되는 순서를 변경하고 싶으면 어떻게 해야 할까요??
결론적으로 어드바이스는 기본적으로 순서를 보장하지 않습니다. 순서를 지정하고 싶으면 @Aspect 적용 단위로 @Order 애노테이션을 적용해야 합니다. 문제는 이것을 어드바이스단위가 아니라 클래스 단위로 적용할 수 있다는 점입니다. 그래서 지금처럼 하나의 애스펙트에 여러 어드바이스가 있으면 순서를 보장받을 수 없습니다. 따라서 애스펙트를 별도의 클래스로 분리해야 합니다.
@Slf4j
public class AspectV50Order {
@Aspect
@Order(2)
public static class LogAspect {
@Around("hello.aop.order.aop.Pointcuts.allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
//...
}
}
@Aspect
@Order(1)
public static class TxAspect {
@Around("hello.aop.order.aop.Pointcuts.allOrderAndService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
//...
}
}
}
어드바이스의 종류
앞서 살펴본 @Around 외에도 여러가지를 사용할 수 있습니다.
1. @Around : 메서드 호출 전후에 수행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환 값 변환, 예외변환 등이 가능
2. @Before : 조인 포인트 실행 이전에 실행
3. @AfterReturning : 조인 포인트가 정상 완료후 실행
4. @AfterThrowing : 메서드가 예외를 던지는 경우 실행
5. @After : 조인 포인트가 정상 또는 예외에 관계없이 실행 (finally)
package hello.aop.order.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
@Slf4j
@Aspect
public class AspectV6Advice {
// 💡 트랜잭션 관리 (@Around 어드바이스)
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
// @Before 역할
log.info("[around][트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed(); // 다음 어드바이스나 실제 메서드 실행
// @AfterReturning 역할
log.info("[around][트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
// @AfterThrowing 역할
log.info("[around][트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally {
// @After 역할
log.info("[around][리소스 릴리즈] {}", joinPoint.getSignature());
}
}
// 💡 메서드 실행 전에 동작 (@Before 어드바이스)
@Before("hello.aop.order.aop.Pointcuts.orderAndService()")
public void doBefore(JoinPoint joinPoint) {
log.info("[before] {}", joinPoint.getSignature());
}
// 💡 메서드 정상 종료 후 동작 (@AfterReturning 어드바이스)
@AfterReturning(
value = "hello.aop.order.aop.Pointcuts.orderAndService()",
returning = "result"
)
public void doReturn(JoinPoint joinPoint, Object result) {
log.info("[return] {} return={}", joinPoint.getSignature(), result);
}
// 💡 예외 발생 시 동작 (@AfterThrowing 어드바이스)
@AfterThrowing(
value = "hello.aop.order.aop.Pointcuts.orderAndService()",
throwing = "ex"
)
public void doThrowing(JoinPoint joinPoint, Exception ex) {
log.info("[ex] {} message={}", joinPoint.getSignature(), ex.getMessage());
}
// 💡 메서드 실행 후 무조건 실행 (@After 어드바이스)
@After("hello.aop.order.aop.Pointcuts.orderAndService()")
public void doAfter(JoinPoint joinPoint) {
log.info("[after] {}", joinPoint.getSignature());
}
}
public class Pointcuts {
@Pointcut("execution(* hello.aop.order..*(..))")
public void allOrder() {}
@Pointcut("execution(* *..*Service.*(..))")
public void allService() {}
@Pointcut("allOrder() && allService()")
public void orderAndService() {}
}
이러한 어드바이스는 필요에 따라 찾아보시는 것을 추천합니다.
저는 @Around 을 사용하고 있어 필요한 어드바이스가 있을 경우 그때그때 찾아봅니다.
좋은 설계는 제약이 있는 것이다.
좋은 설계는 제약이 있는 것입니다. @Around 만 있으면 되는데 왜? 이렇게 제약을 두는가? 제약은 실수를 미연에 방지합니다.
일종의 가이드 역할을 한다고 할 수 있습니다. 만약 @Around 를 사용했는데, 중간에 다른 개발자가 해당 코드를 수정해서 호출하지 않았다면? 큰 장애가 발생할 수 있습니다. 처음부터 @Before 을 사용했다면 이런 문제 자체가 발생하지 않습니다. 제약 덕분에 역할이 명확해 진 것이죠. 다른 개발자도 이 코드를 보고 고민해야 하는 범위가 줄어들고 코드의 의도도 파악하기 쉬워집니다.
'Spring' 카테고리의 다른 글
JPA 에 대하여 (8) | 2025.08.25 |
---|---|
Spring 에서 CORS 설정 (3) | 2025.07.09 |
@Aspect 쓰지 않고 순수 Proxy, Proxy Factory 사용법 (0) | 2025.04.29 |
다양한 패턴 (0) | 2025.04.25 |
트랜잭션 프록시와 예외 (0) | 2025.04.08 |