[고급편] 스프링 AOP 구현
포스트
취소

[고급편] 스프링 AOP 구현

모듈 생성

  • 루트 모듈에 AOP를 위한 모듈인 chapter3를 생성하자.
  • demo 우클릭 → 새로 만들기 → 모듈… → “chapter3” 입력 → 생성
  • 자동 생성된 Main 클래스를 Chapter3Application으로 이름 변경
  • application.yaml을 생성하여 server.port를 8083로 변경
  • com.example 패키지 아래에 app 패키지 생성

build.gradle

  • 루트 모듈에 추가해두긴 했지만 만약 AOP가 동작하지 않을 경우에는 아래의 디펜던시를 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-aop'
  • 테스트 환경에서의 롬복 사용을 위해 아래의 디펜던시도 함께 추가한다.
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'

스프링 부트가 아닐 경우

  • 스프링 부트가 아닐 경우에는 @Aspect 애노테이션을 사용하려면 @EnableAspectJAutoProxy를 스프링 설정에 추가해야 한다.
  • 스프링 부트일 경우에는 자동으로 적용된다.

예제 프로젝트 만들기

  • 간단한 예제 프로젝트를 만들어보자.

리포지토리

package com.example.app;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;

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

서비스

package com.example.app;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

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

테스트 생성 및 실행

import com.example.Chapter3Application;
import com.example.app.OrderRepository;
import com.example.app.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
@SpringBootTest
@ContextConfiguration(classes = Chapter3Application.class)
public class AopTest {
    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void aopInfo() {
        //AOP 적용 여부 확인
        log.info("isAopProxy, orderService={}", AopUtils.isAopProxy(orderService));
        log.info("isAopProxy, orderRepository={}", AopUtils.isAopProxy(orderRepository));
    }

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

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

스프링 AOP 구현1 - 시작

  • 우선 AOP를 간단하게 구현해보자.

애스펙트

  • @Around 애노테이션의 값인 execution(* com.example.app..*(..))는 포인트컷이 된다.
  • @Around 애노테이션의 메소드인 doLog는 어드바이스가 된다.
  • execution(* com.example.app..*(..))com.example.app 패키지와 그 하위 패키지를 지정한다.
    • AspectJ 포인트컷 표현식
    • app 뒤의 ..이 하위 패키지를 의미한다.
  • 해당 애스팩트를 적용하면 OrderService와 OrderRepository의 모든 메소드는 AOP 적용의 대상이 된다.
  • 스프링은 프록시 방식의 AOP를 사용하므로 프록시를 통하는 메소드만 적용 대상이 된다.
  • 참고
    • 스프링 AOP는 AspectJ의 문법을 차용하고, 프록시 방식의 AOP를 제공한다.
    • 그렇다고 AspectJ를 직접 사용하는 것은 아니다.
    • 스프링 AOP를 사용할 때는 @Aspect 애노테이션을 주로 사용한다.
    • @Aspect 애노테이션도 AspectJ가 제공하는 애노테이션이다.
    • 스프링에서는 AspectJ가 제공하는 애노테이션이나 관련 인터페이스만 사용하는 것이다.
    • 실제 AspectJ가 제공하는 컴파일, 로드타임 위버 등을 사용하는 것은 아니다.
package com.example.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;

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

테스트 생성

  • AopTest에 AOP를 적용한 테스트를 생성해보자.
  • @Import 애노테이션으로 아까 만든 애스팩트를 적용하자.
@Import(AspectV1.class)

애스팩트는 빈으로 등록해야 한다.

  • @Aspect는 애스펙트라는 표식이지 컴포넌트 스캔이 되는 것은 아니다.
  • 그래서 애스팩트는 별도로 스프링 빈으로 등록해야 동작한다.
  • 스프링 빈으로 등록하는 방법
    • @Bean을 사용해서 직접 등록
    • @Component 컴포넌트 스캔을 사용해서 자동 등록
    • @Import로 설정 파일을 추가해서 사용(@Configuration 활용)

스프링 AOP 구현2 - 포인트컷 분리

  • @Around 에 포인트컷 표현식을 직접 넣을 수도 있지만, @Pointcut 애노테이션을 사용해서 별도로 분리할 수도 있다.
  • 포인트컷을 분리한 애스팩트를 구현해보자.

애스팩트

package com.example.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Slf4j
@Aspect
public class AspectV2 {
    //com.example.app 패키지와 하위 패키지
    @Pointcut("execution(* com.example.app..*(..))") //pointcut expression
    private void allOrder(){} //pointcut signature

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

테스트 생성

  • 아까의 AopTest에서 이번에는 AspectV1 대신에 AspectV2를 적용해보자.
@Import(AspectV2.class)

@Pointcut

  • @Pointcut 애노테이션에 포인트컷 표현식을 사용한다.
  • 메소드 이름과 파라미터를 합쳐서 포인트컷 시그니처(signature)라 한다.
  • 메소드의 반환 타입은 voidㄴ여야 한다.
  • 포인트컷이 되는 메소드의 코드 내용은 비워둔다.
  • @Around 어드바이스에서는 포인트컷을 직접 지정해도 되지만, 포인트컷 시그니처를 사용해도 된다.
  • private , public 같은 접근 제어자는 내부에서만 사용하면 private 을 사용해도 되지만,
    다른 애스팩트에서 참고하려면 public 을 사용해야 한다.

스프링 AOP 구현3 - 어드바이스 추가

  • 이번에는 조금 복잡하다.
  • 로그를 출력하는 기능에 추가로 실제 트랜잭션을 적용하는 것 같은 예제 코드도 추가해보자.

트랜잭션 기능

  • 핵심 로직 실행 직전에 트랜잭션을 시작
  • 핵심 로직 실행
  • 핵심 로직 실행에 문제가 없으면 커밋
  • 핵심 로직 실행에 예외가 발생하면 롤백

애스팩트

  • 앞서 배웠던 것처럼 포인트컷은 여러 개의 조건을 함께 사용해도 된다.
  • 논리 연산자 사용
    • AND : &&
    • OR : ||
    • NOT : ~
package com.example.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Slf4j
@Aspect
public class AspectV3 {
    //com.example.app 패키지와 하위 패키지
    @Pointcut("execution(* com.example.app..*(..))")
    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();
    }
    
    //com.example.app 패키지와 하위 패키지 이면서 클래스 이름 패턴이 *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());
        }
    }
}

테스트 생성

  • 아까의 AopTest에서 이번에는 AspectV2 대신에 AspectV3를 적용해보자.
@Import(AspectV3.class)

스프링 AOP 구현4 - 포인트컷 참조

  • 이번에는 포인트컷을 별도의 파일로 분리해보자.
  • 포인트컷을 공용으로 사용하기 위해 별도의 외부 클래스에 모아두어도 된다.
    • 대신에 외부에서 호출할 때는 포인트컷의 접근 제어자를 public 으로 열어두어야 한다.

포인트컷

package com.example.aop;

import org.aspectj.lang.annotation.Pointcut;
public class Pointcuts {
    //com.example.app 패키지와 하위 패키지
    @Pointcut("execution(* com.example.app..*(..))")
    public void allOrder(){}

    //타입 패턴이 *Service
    @Pointcut("execution(* *..*Service.*(..))")
    public void allService(){}
    
    //allOrder && allService
    @Pointcut("allOrder() && allService()")
    public void orderAndService(){}
}

애스팩트

  • 패키지명을 포함한 클래스 이름과 포인트컷 시그니처를 모두 지정하면 된다.
  • 포인트컷을 여러 어드바이스에서 함께 사용할 때 이 방법을 사용하면 효과적이다.
package com.example.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;

@Slf4j
@Aspect
public class AspectV4 {
    @Around("com.example.aop.Pointcuts.allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }

    @Around("com.example.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에서 이번에는 AspectV3 대신에 AspectV4를 적용해보자.
@Import(AspectV4.class)

스프링 AOP 구현5 - 어드바이스 순서

  • 어드바이스는 기본적으로 순서를 보장하지 않는다.
  • 순서를 지정하고 싶으면 @Aspect 적용 단위로 org.springframework.core.annotation.@Order 애노테이션을 적용해야 한다.
    • 문제는 이것을 어드바이스 단위가 아니라 클래스 단위로 적용할 수 있다.
    • 그래서 하나의 애스펙트에 여러 어드바이스가 있으면 순서를 보장 받을 수 없다.
    • 애스펙트를 별도의 클래스로 분리해야 한다.
  • 순서를 별도로 지정하지 않았을 경우에는 실행 순서가 JVM이나 실행 환경에 따라 달라질 수도 있다.

애스팩트

  • 하나의 애스펙트 안에 있던 어드바이스를 LogAspect 애스팩트와 TxAspect 애스펙트로 각각 분리했다.
  • 각 애스펙트에 @Order 애노테이션을 통해 실행 순서를 적용했다.
  • @Order의 숫자가 작을 수록 먼저 실행된다.
package com.example.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;

@Slf4j
public class AspectV5 {
    @Aspect
    @Order(2)
    public static class LogAspect {
        @Around("com.example.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("com.example.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에서 이번에는 AspectV4 대신에 AspectV5를 적용해보자.
@Import({AspectV5.LogAspect.class, AspectV5.TxAspect.class})

스프링 AOP 구현6 - 어드바이스 종류

  • 어드바이스는 단순히 @Around 하나만 있는게 아니라 여러 가지 종류가 있다.
  • 종류
    • @Around
      • 메서드 호출 전후에 수행
      • 가장 강력한 어드바이스
      • 조인 포인트 실행 여부 선택
      • 반환 값 변환
      • 예외 변환 가능
    • @Before
      • 조인 포인트 실행 이전에 실행
    • @AfterReturning
      • 조인 포인트가 정상 완료후 실행
    • @AfterThrowing
      • 메서드가 예외를 던지는 경우 실행
    • @After
      • 조인 포인트가 정상 또는 예외에 관계없이 실행 (= finally)

애스팩트

  • @Around는 어드바이스 중에서 가장 강력한 기능을 가지고 있다.
    • 다만 그 기능이 강력하다는 것은 다양한 기능이 몰려있다는 것을 의미하기도 한다.
  • 다른 어드바이스들은 각각의 기능은 @Around보다 약하긴 해도
    @Around의 기능을 역할에 맞게 분리해서 사용할 수 있다는 장점이 있다.
package com.example.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 AspectV6 {
    @Around("com.example.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("com.example.aop.Pointcuts.orderAndService()")
    public void doBefore(JoinPoint joinPoint) {
        log.info("[before] {}", joinPoint.getSignature());
    }

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

    @AfterThrowing(value = "com.example.aop.Pointcuts.orderAndService()", throwing = "ex")
    public void doThrowing(JoinPoint joinPoint, Exception ex) {
        log.info("[ex] {} message={}", joinPoint.getSignature(), ex.getMessage());
    }

    @After(value = "com.example.aop.Pointcuts.orderAndService()")
    public void doAfter(JoinPoint joinPoint) {
        log.info("[after] {}", joinPoint.getSignature());
    }
}

테스트 생성

  • 아까의 AopTest에서 이번에는 AspectV5 대신에 AspectV6를 적용해보자.
@Import({AspectV6.class})

참고 정보 획득

  • 모든 어드바이스는 org.aspectj.lang.JoinPoint를 첫번째 파라미터에 사용할 수 있다.
    • 필수는 아니라서 파라미터를 생략해도 된다.
    • 단, @Around는 ProceedingJoinPoint을 사용해야 한다.
    • 참고로 ProceedingJoinPoint는 org.aspectj.lang.JoinPoint의 하위 타입이다.
  • JoinPoint 인터페이스의 주요 기능
    • getArgs()
      • 메서드 인수를 반환합니다.
    • getThis()
      • 프록시 객체를 반환합니다.
    • getTarget()
      • 대상 객체를 반환합니다.
    • getSignature()
      • 조언되는 메서드에 대한 설명을 반환합니다.
    • toString()
      • 조언되는 방법에 대한 유용한 설명을 인쇄합니다.
  • ProceedingJoinPoint 인터페이스의 주요 기능
    • proceed()
      • 다음 어드바이스나 타켓을 호출한다.
  • 또한 @AfterReturning은 returning으로, @AfterThrowing은 throwing으로 값을 설정하는 부분이 있다.
    • 이 부분에 설정한 이름과 파라미터의 이름이 동일해야지 결과값이나 예외에 대한 정보를 받을 수 있다.

어드바이스 종류

@Around

  • 메서드의 실행의 주변에서 실행된다.
  • 메서드 실행 전후에 작업을 수행한다.
  • 가장 강력한 어드바이스
    • 조인 포인트 실행 여부 선택
      • joinPoint.proceed()를 통한 호출 여부 선택을 의미한다.
    • 전달 값 변환
      • joinPoint.proceed(args[])를 통해 파라미터를 변경할 수 있는 것을 의미한다.
    • 반환 값 변환
    • 예외 변환
    • 트랜잭션 처럼 try ~ catch~ finally 모두 들어가는 구문 처리 가능
  • 어드바이스의 첫 번째 파라미터는 ProceedingJoinPoint를 사용해야 한다.
  • proceed()를 통해 대상을 실행한다.
  • proceed()를 여러번 실행할 수도 있음(재시도)
  • ProceedingJoinPoint.proceed() 를 호출해야 다음 대상이 호출된다.
    • 만약 호출하지 않으면 다음 대상이 호출되지 않는다.

@Before

  • 조인 포인트 실행 전
  • @Around와 다르게 작업 흐름을 변경할 수는 없다.
  • @Before는 메서드 종료시 자동으로 다음 타켓이 호출된다.
    • 물론 예외가 발생하면 다음 코드가 호출되지는 않는다.

@AfterReturning

  • 메서드 실행이 정상적으로 반환될 때 실행
  • returning 속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야 한다.
  • returning 절에 지정된 타입의 값을 반환하는 메서드만 대상으로 실행한다.
    • 부모 타입을 지정하면 모든 자식 타입은 인정된다.
  • @Around와 다르게 반환되는 객체를 변경할 수는 없다.
    • 반환 객체를 변경하려면 @Around를 사용해야 한다.
    • 반환 객체를 조작하는 것은 가능하다.

@AfterThrowing

  • 메서드 실행이 예외를 던져서 종료될 때 실행
  • throwing 속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야 한다.
  • throwing 절에 지정된 타입과 맞는 예외를 대상으로 실행한다.
    • 부모 타입을 지정하면 모든 자식 타입은 인정된다.

@After

  • 메서드 실행이 종료되면 실행된다. (= finally)
  • 정상 및 예외 반환 조건을 모두 처리한다.
  • 일반적으로 리소스를 해제하는 데 사용한다.

어드바이스 내부의 실행 순서

  • 스프링은 5.2.7 버전부터 동일한 @Aspect 안에서 동일한 조인포인트의 우선순위를 정했다.
  • 어드바이스가 적용되는 순서
    1. @Around
    2. @Before
    3. @After
    4. @AfterReturning
    5. @AfterThrowing
  • 물론 @Aspect 안에 동일한 종류의 어드바이스가 2개 있으면 순서가 보장되지 않는다.
    • 이런 경우에는 @Aspect를 분리하고 @Order를 적용하자.

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.