Spring AOP 구현

조갱·2024년 7월 7일
0

스프링 강의

목록 보기
21/23

Spring에서 제공하는 AOP를 사용해보자.

예제 프로젝트 만들기

build.gradle

plugins {
  id 'org.springframework.boot' version '2.5.5'
  id 'io.spring.dependency-management' version '1.0.11.RELEASE'
  id 'java'
}

group = 'hello'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter'
  implementation 'org.springframework.boot:spring-boot-starter-aop' //직접 추가
  compileOnly 'org.projectlombok:lombok'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  
  //테스트에서 lombok 사용
  testCompileOnly 'org.projectlombok:lombok'
  testAnnotationProcessor 'org.projectlombok:lombok'
}

test {
  useJUnitPlatform()
}

OrderRepository

@Slf4j
@Repository
public class OrderRepository {
    public String save(String itemId) {
        log.info("[orderRepository] 실행"); //저장 로직
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }
        return "ok";
    }
}

OrderService

@Slf4j
@Service
public class OrderService {
    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public void orderItem(String itemId) {
        log.info("[orderService] 실행");
        orderRepository.save(itemId);
    }
}

AopTest

@Slf4j
@SpringBootTest
public class AopTest {

    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

    @Test
    void aopInfo() {
        log.info("isAopProxy, orderService={}", AopUtils.isAopProxy(orderService));
        log.info("isAopProxy, orderRepository={}", AopUtils.isAopProxy(orderRepository));
    }

    @Test
    void success() {orderService.orderItem("itemA");}

    @Test
    void exception() {assertThatThrownBy(() -> orderService.orderItem("ex")).isInstanceOf(IllegalStateException.class);}
}

Spring AOP가 적용되면 AopUtils.isAopProxy 의 결과가 true가 반환된다.
지금은 AOP를 적용하지 않았기 때문에 false가 반환된다.

AopTest.success() 실행 결과

[orderService] 실행
[orderRepository] 실행

아직 AOP가 적용되지 않아서 단순히 실행 로그만 찍힌다.

스프링 AOP (1) - 기본 Aspect

AspectV1

@Slf4j
@Aspect
public class AspectV1 {
    //hello.aop.order 패키지와 하위 패키지
    @Around("execution(* hello.aop.order..*(..))")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
        return joinPoint.proceed();
    }
}

Pointcut : execution(* hello.aop.order..*(..))
     -> hello.aop.order 패키지 하위에 있는 모든 메소드
Advice : @Around 어노테이션이 적용된 doLog 메소드가 Advice가 된다.

이제 hello.aop.order 패키지 하위에 있는
OrderRepository, OrderService의 모든 메소드들에 AOP가 적용된다.

AopTest 일부 수정

@Slf4j
@Import(AspectV1.class) //추가
@SpringBootTest
public class AopTest {
  ... // 테스트 로직
}

@Aspect 는 단순히 자동 프록시 생성기가 프록시 생성 대상을 식별하기 위한 어노테이션이다.
Aspect 어노테이션은 ComponentScan을 포함하지 않으므로,
AspectV1 를 AOP로 사용하려면 스프링 빈으로 등록해야 한다.

AopTest.success() 실행 결과

[log] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String) 
[orderRepository] 실행

스프링 AOP (2) - 포인트컷 분리

@Around 어노테이션에 Pointcut을 포함시킬 수도 있지만,
별도로 분리하여 재활용도 가능하다.

AspectV2

@Slf4j
@Aspect
public class AspectV2 {

    //hello.aop.order 패키지와 하위 패키지
    @Pointcut("execution(* hello.aop.order..*(..))") //pointcut expression
    private void allOrder() {} //pointcut signature

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }
}
  • @Pointcut 에 포인트컷 표현식을 사용한다.
  • 메서드 이름과 파라미터를 합쳐서 포인트컷 시그니처(signature)라 한다.
  • 메서드의 반환 타입은 void 여야 한다.
  • 코드 내용은 비워둔다.
  • 포인트컷 시그니처는 allOrder() 이다. (=주문과 관련된 모든 기능 대상)
  • @Around 어드바이스에서는 포인트컷을 직접 지정해도 되지만, 포인트컷 시그니처를 사용해도 된다. 여기서는 @Around("allOrder()") 를 사용한다.
  • private , public 같은 접근 제어자는 내부에서만 사용하면 private 을 사용해도 되지만, 다른 Aspect에서 참고하려면 public 을 사용해야 한다.

AopTest 일부 수정

@Slf4j
@Import(AspectV2.class) // V2로 변경
@SpringBootTest
public class AopTest {
  ... // 테스트 로직
}

AopTest.success() 실행 결과

[log] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String) 
[orderRepository] 실행

실행 결과는 AspectV1과 동일하다.

스프링 AOP (3) - 여러 어드바이스

AspectV3

@Slf4j
@Aspect
public class AspectV3 {

    //hello.aop.order 패키지와 하위 패키지
    @Pointcut("execution(* hello.aop.order..*(..))")
    public 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 {
        try {
            log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e) {
            log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
            throw e;
        } finally {
            log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
        }
    }
}

이전에는 Aspect 안에 로그를 찍는 doLog(...) Advice만 있었다.
이제는 트랜잭션 처리를 하는 doTransaction(...) 을 추가했다.

doTransaction은 allService() 포인트컷 시그니처에 의해 클래스 명이 Service로 끝나야
Advice를 적용한다.

-> OrderRepository : doLog()
-> OrderService : doLog(), doTransaction()
Advice가 적용된다.

AopTest 일부 수정

@Slf4j
@Import(AspectV3.class) // V3로 변경
@SpringBootTest
public class AopTest {
  ... // 테스트 로직
}

AopTest.success() 실행 결과

[log] void hello.aop.order.OrderService.orderItem(String)
[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
[트랜잭션 커밋] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)

AopTest.exception() 실행 결과

[log] void hello.aop.order.OrderService.orderItem(String)
[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
[트랜잭션 롤백] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)

동작 순서

스프링 AOP (4) - 포인트컷을 별도로 관리

@Pointcut 을 별도로 분리하여 공통으로 재활용하여 사용할 수도 있다.
접근제한자만 public으로 지정하고,
Advice에서 포인트컷 시그니처에 패키지명을 함께 명시하면 된다.

Pointcuts

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() {}
}

AspectV4

@Slf4j
@Aspect
public class AspectV4 {

    @Around("hello.aop.order.aop.Pointcuts.allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }

    @Around("hello.aop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e) {
            log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
            throw e;
        } finally {
            log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
        }
    }
}

AopTest 일부 수정

@Slf4j
@Import(AspectV4.class) // V4로 변경
@SpringBootTest
public class AopTest {
  ... // 테스트 로직
}

AopTest.success() 실행 결과

[log] void hello.aop.order.OrderService.orderItem(String)
[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
[트랜잭션 커밋] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)

기존 (V3)과 같다.

AopTest.exception() 실행 결과

[log] void hello.aop.order.OrderService.orderItem(String)
[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
[트랜잭션 롤백] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)

기존 (V3)과 같다.

스프링 AOP (5) - 어드바이스 순서

Advice는 기본적으로 순서를 보장하지 않는다.
위 예시에서는 doLog가 먼저 실행되고, doTransaction이 그 다음에 실행됐음을 확인할 수 있다.

Advice가 적용되는 순서를 doTransaction -> doLog로 변경하려면 어떻게 해야할까?
Aspect 단위@Order어노테이션을 통해 Advice의 순서를 변경할 수 있다.

@Order 어노테이션은 Aspect 단위로 적용되기 때문에, 클래스도 분리해야 한다.

AspectV5

@Slf4j
public class AspectV5 {

    @Aspect
    @Order(2)
    public static class LogAspect {
        @Around("hello.aop.order.aop.Pointcuts.allOrder()")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[log] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }
    }

    @Aspect
    @Order(1)
    public static class TxAspect {
        @Around("hello.aop.order.aop.Pointcuts.orderAndService()")
        public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
            try {
                log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
                Object result = joinPoint.proceed();
                log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
                return result;
            } catch (Exception e) {
                log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
                throw e;
            } finally {
                log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
            }
        }
    }
}

doTransaction과 doLog의 순서를 조정하기 위해 @Order 어노테이션을 적용했다.
그리고, @Order 어노테이션은 @Aspect 단위로 적용할 수 있기 때문에
doLog() 를 담당하는 LogAspect와, doTransaction()을 담당하는 TxAspect로 클래스를 분리했다.

AopTest 일부 수정

@Slf4j
@Import(AspectV5.class) // V5로 변경
@SpringBootTest
public class AopTest {
  ... // 테스트 로직
}

AopTest.success() 실행 결과

[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String) 
[log] void hello.aop.order.OrderService.orderItem(String) 
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String) 
[orderRepository] 실행
[트랜잭션 커밋] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)

트랜잭션이 먼저 시작된 것을 볼 수 있다.

AopTest.exception() 실행 결과

[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String) 
[log] void hello.aop.order.OrderService.orderItem(String) 
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String) 
[orderRepository] 실행
[트랜잭션 롤백] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)

스프링 AOP (6) - 어드바이스 종류

Advice는 앞에서 본 @Around 외에도 여러 종류가 존재한다.

  • @Around : 메서드 호출 전후에 수행
    JoinPoint 호출 여부, Return 값 변환, Exception 변환 등 여러 부가 기능 가능
  • @Before : 조인 포인트 실행 이전에 실행
  • @AfterReturning : 조인 포인트가 정상 완료후 실행
  • @AfterThrowing : 메서드가 예외를 던지는 경우 실행
  • @After : 조인 포인트가 정상 또는 예외에 관계없이 실행(finally)

실행 순서: @Around > @Before > @After > @AfterReturning > @AfterThrowing
* @Aspect 안에 동일한 Advice가 2개 이상 있으면 순서가 보장되지 않는다.
  Aspect 안에 동일한 Advice의 순서를 보장 받고 싶다면, @Aspect 를 분리하여 @Order를 적용하자.

@Around 하나로 나머지 Advice를 모두 커버할 수 있는데, 여러 Advice가 존재하는 이유?

@Around 는 책임이 많기 때문에, 실수할 가능성이 있다.
-> JoinPoint를 실수로 실행하지 않는다거나,
-> JoinPoint의 실행 결과를 제대로 return하지 못하거나...

다른 Advice를 사용하면, 그 책임과 의도가 명확해진다.

AspectV6

@Slf4j
@Aspect
public class AspectV6 {

    @Around("hello.aop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            // @Before 시점
            log.info("[around][트랜잭션 시작] {}", joinPoint.getSignature());
            
            // Joinpoint 실행
            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("hello.aop.order.aop.Pointcuts.orderAndService()")
    public void doBefore(JoinPoint joinPoint) {
        log.info("[before] {}", joinPoint.getSignature());
    }

    @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(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(value = "hello.aop.order.aop.Pointcuts.orderAndService()")
    public void doAfter(JoinPoint joinPoint) {
        log.info("[after] {}", joinPoint.getSignature());
    }
}

doTransaction Advice의 주석을 보면 @~~ 시점을 볼 수 있다.
즉, @Around를 사용하면, 다른 Advice들 (Before, After, AfterReturning, AfterThrowing)을 모두 커버할 수 있다.

Advice에서 참고 정보를 활용하기 위해서는 파라미터로 받은 JoinPoint를 사용하면 된다.
@AroundAdvice에서는 ProceedingJoinPoint를 사용하며
그 외 Advice에서는 JoinPoint를 사용한다.

JoinPoint 는 다양한 정보를 제공하지만, 그 중에서 주로 사용되는 정보는 아래와 같다.

  • getArgs() : 메서드의 인자를 반환한다.
  • getThis() : 프록시 객체를 반환한다.
  • getTarget() : 대상 객체를 반환한다.
  • getSignature() : 메소드의 시그니처를 반환한다.
  • toString() : 일반적으로 포인트컷 지시자를 반환한다.
    MethodInvocationProceedingJoinPoint
    public String toString() {
       return "execution(" + this.getSignature().toString() + ")";
    }
    JointpointImpl.class
    public final String toString() {
       return this.toString(StringMaker.middleStringMaker);
    }
    String toString(StringMaker sm) {
       StringBuffer buf = new StringBuffer();
       buf.append(sm.makeKindName(this.getKind()));
       buf.append("(");
       buf.append(((SignatureImpl)this.getSignature()).toString(sm));
       buf.append(")");
       return buf.toString();
    }
  • (ProceedingJoinPoint 한정) proceed() : 다음 어드바이스나 타켓을 호출

@Before : JoinPoint 실행 전

 @Before("hello.aop.order.aop.Pointcuts.orderAndService()")
 public void doBefore(JoinPoint joinPoint) {
     log.info("[before] {}", joinPoint.getSignature());
 }

@Around 와 다르게 작업 흐름을 변경할 수는 없다.
@AroundProceedingJoinPoint.proceed() 를 호출해야 다음 대상이 호출된다.
만약 호출하지 않으면 다음대상이 호출되지 않는다.

반면에 @BeforeProceedingJoinPoint.proceed() 자체를 사용하지 않는다.
메서드 종료시 자동으로 다음 타켓이 호출된다.

물론 예외가 발생하면 다음 코드가 호출되지는 않는다.

@AfterReturning : JoinPoint가 성공적으로 반환된 후

 @AfterReturning(value = "hello.aop.order.aop.Pointcuts.orderAndService()", returning = "result")
 public void doReturn(JoinPoint joinPoint, Object result) {
     log.info("[return] {} return={}", joinPoint.getSignature(), result);
 }

returning 속성에 사용된 이름은 Advice 메소드 매개변수 이름과 일치해야 한다.
result의 (자식을 포함한)타입을 반환하는 메소드에만 수행된다.

@Around 와 다르게 반환되는 객체를 변경할 수는 없다.
(반환되는 객체를 활용할 수는 있다.)

@AfterThrowing : JoinPoint가 예외를 발생한 후

 @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());
 }

throwing 속성에 사용된 이름은 Advice 메소드 매개변수 이름과 일치해야 한다.
ex의 (자식을 포함한)타입과 일치하는 Exception이 발생될 때 실행한다.

@After : JoinPoint가 종료되면, 성공 유무 관계 없이 (=finally)

일반적으로 리소스를 해제하는 데 사용한다.

@Around : JoinPoint 실행 전후

다른 Advice들을 모두 커버할 수 있다. (= 가장 강력하다.)

  • JoinPoint 호출 여부 선택 가능
  • JoinPoint 반환 값 변조 가능
  • Exception 발생 시 변조 혹은 예외 처리 가능
  • 트랜잭션 처럼 try ~ catch~ finally 모두 들어가는 구문 처리 가능

@Around Advice의 첫 번째 파라미터는 ProceedingJoinPoint를 사용해야 한다.
proceed() 를 통해 대상을 실행한다.
proceed() 를 여러번 실행할 수도 있음(재시도)

AopTest.success() 실행 결과

[around][트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[before] void hello.aop.order.OrderService.orderItem(String) 
[orderService] 실행
[orderRepository] 실행
[return] void hello.aop.order.OrderService.orderItem(String) return=null
[after] void hello.aop.order.OrderService.orderItem(String)
[around][트랜잭션 커밋] void hello.aop.order.OrderService.orderItem(String)
[around][리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)
profile
A fast learner.

0개의 댓글