@Aspect - AOP

레거시한 프로젝트를 전부 @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