AOP로 로그찍기 - woowa-turkey/miniprojects-2019 GitHub Wiki

아래 글은 제가 AOP 공부하면서 노션에 정리한 날 것 그대로입니다.

참고 : https://www.eclipse.org/aspectj/

참고 2 : https://docs.spring.io/spring-framework/docs/current/spring-framework-reference/core.html#aop-using-aspectj

참고 3: https://jojoldu.tistory.com/71

@AspectJ 스타일은 어노테이션으로 aspect를 표현하는 자바 클래스를 의미한다. Spring은 AspectJ 5를 기준으로 사용하고 AOP를 사용한다. Spring boot에서 사용하려면 org.springframework.boot:spring-boot-starter-aop를 의존성으로 추가해야한다. 의존성으로 추가하면 자동으로 @EnableAspectJAutoProxy가 추가된다.

설정

Spring에서 @AspectJ를 지원하기 위해서는 설정이 필요하다. @Configuration빈 클래스에 @EnableAspectJAutoProxy 어노테이션을 매핑해주어야한다.

    @Configuration
    @EnableAspectJAutoProxy
    public class AppConfig {
    	// ...
    }

Aspect 선언

@AspectJ를 사용하도록 설정하면 @AspectJ 어노테이션이 매핑된 클래스는 Appication Context 내부에 정의된 모든 빈들에 대해 적용할 수 있다.

    import org.aspectj.lang.annotation.Aspect;
    
    @Aspect
    public class NotVeryUsefulAspect {
    	// ...
    }

위와 같이 Aspect를 처리할 클래스를 @Aspect 어노테이션을 붙여 정의할 수 있다. Aspect는 다른 일반 클래스처럼 메서드와 필드를 가질 수 있다. 또한 pointcut, advice, introduction을 가질 수 있다.

Autodetecting Aspect

@Aspect로는 자동으로 Spring 컨테이너가 찾아내기엔 부족하다. 이를 보충하기 위해서 @Component를 추가해주거나 @Aspect와 함께 @Component를 사용한 커스텀 어노테이션을 만들어 사용할 수 있다.

다른 Aspect와 함께 Aspect가 Advice 되는 경우?

Spring AOP에서는 다른 Aspect에서 Aspect의 타겟이 될 수 없다. 이는 @Aspect 어노테이션은 Auto-proxying의 대상에서 제외하기 때문이다.

Pointcut 선언

pointcut은 일반 메서드로 정의할 수 있다. 메서드를 정의함과 동시에 @Pointcut어노테이션으로 preticate를 정의할 수 있다. 이때 pointcut의 리턴 타입은 반드시 void여야한다.

    @PointCut("excution(* transfer(..))")
    private void anyOldTransfer {}

위와 같이 pointcut을 정의할 수 있다. 위 pointcut은 모든 접근자이면서 어떤 파라미터를 가지는 transfer 메서드에 대해 aspect를 적용한다는 의미이다.

지원하는 지정자

  • execution

접근제한자, 리턴타입, 인자타입, 클래스와 인터페이스, 메서드명, 파라미터 타입, 예외타입 등을 전부 조합가능한 지정자.

메서드 시그니처대로 {접근제한자} {메서드 명} (파라미터 타입) 과 같은 형태로 @Pointcut을 정의할 수 있다.

        @Pointcut("execution(* edu.pkch.mvcedu.controller.*.* (..))")

위와 같이 pointcut을 설정하면 edu.pkch.mvc.edu.controller패키지에 있는 모든 클래스의 메서드에 해당 pointcut이 설정된 Advice를 적용한다는 의미이다.

        @Around("controllerPointcut()")
        private Object requestAndResponseLogging(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("request to {} with {}", joinPoint.getSignature(), joinPoint.getArgs());
            joinPoint.proceed();
            log.info("response end");
        	  return null;
        }

위와 같이 Advice 어노테이션에 pointcut을 정의한 메서드 명()으로 적용할 수 있다.

  • within

특정 타입으로 한정하는 pointcut. 즉, 클래스와 인터페이스까지 지정하는 경우를 의미

        @Pointcut("within(edu.pkch.mvcedu.controller.*)")
        
        @Pointcut("within(edu.pkch.mvcedu.controller..)")

전자 pointcut은 controller 패키지 아래의 클래스와 인터페이스가 가진 모든 메서드를 매칭하는 반면 후자는 controller 아래의 모든 하위 패키지까지 범위를 확대한다.

  • @within

주어진 어노테이션을 사용하는 타입으로 선언된 메서드를 지칭

  • this

bean reference가 this에서 주어진 인스턴스인 join point를 매칭

  • target

target object가 this에서 주어진 인스턴스인 join point를 매칭

여기서 this와 target의 차이는 this는 정의한 타입이 자기 자신일때의 의미이고 target은 정의한 타입을 부르는 object의 타입이 일치하는 경우를 의미한다.

this vs target 차이 : https://stackoverflow.com/questions/11924685/spring-aop-target-vs-this

  • @target

실행한 object의 클래스가 어노테이션에서 정의한 타입과 일치한 메서드를 지칭

  • args

인자가 주어진 타입의 인스턴스인 join point를 지칭

        @Around("loggingForException() && args(exception)")
        public Object exceptionLogging(final ProceedingJoinPoint pjp, Exception exception) throws Throwable {
            setLogger(exception.getClass());
            Object result = pjp.proceed();
            log.error("errorMessage: {}", exception.getMessage());
        
            return result;
        }

위와 같이 args 표현식을 사용하여 해당 이름의 인자를 파라미터로 받을 수 있다.

  • @args

실제 런타임에서 넘겨진 인자가 주어준 타입의 어노테이션을 가지는 join point를 지칭

  • @annotation

주어진 어노테이션을 가지는 join point를 지칭

위 지정자 중 execution@annotation을 가장 많이 씀

위 지정자 이외에도 AspectJ에서 지원하는 지정자가 많지만 Spring에서 지원하지 않는다. Spring에서는 메서드를 대상으로만 AOP를 지원하기 때문이다. 다만 Spring AOP에서만 지원하는 pointcut도 있다.

  • bean

지정한 빈 이름과 일치하는 join point를 지칭

위와 같이 지정한 pointcut&&(AND), ||(OR), !(NOT) 과 같은 논리연산자와 조합하여 지정할 수 있다. 또한 pointcut을 조합한 또다른 pointcut을 지정할 수 있다.

    @Pointcut("execution(public * *(..))")
    private void anyPublicOperation() {} 
    
    @Pointcut("within(com.xyz.someapp.trading..*)")
    private void inTrading() {} 
    
    @Pointcut("anyPublicOperation() && inTrading()")
    private void tradingOperation() {}

아래는 다양한 pointcut과 해당 pointcut의 적용을 받는 joinpoint대상이다.

공식문서 참고 : https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#aop-pointcuts-examples

Advice

advice는 pointcut 표현식에 일치하는 join point에 적용하는 로직이다. 즉, AOP에서 관리하는 공통 관심사를 의미한다. Advice에는 Before, AfterReturning, After, AfterThrowing, Around가 있다.

  • @Before

일치하는 join point의 실행 전에 적용하는 Advice이다.

        @Before("execution(* edu.pkch.mvcedu.controller.*.* (..))")

다음과 같이 pointcut을 설정하여 정의할 수 있다. 이때 미리 정의한 pointcut메서드가 있다면 그 메서드로 설정할 수도 있다.

  • @AfterReturning

    메서드가 return을 실행한 직후에 적용하는 Advice이다.

        @AfterReturning("execution(* edu.pkch.mvcedu.controller.*.* (..))")

AfterReturning의 경우는 메서드에서 리턴값이 나온 이후이므로 Advice에서 리턴값을 받아 추가적인 작업을 할 수 있다. @AfterReturning의 attribute 중 returning을 설정하여 파라미터로 받아주면 된다.

        @AfterReturning(
        	pointcut = "execution(* edu.pkch.mvcedu.service.UserService.create(..))",
        	returning = "user"
        )
        public void doCreateCheck(Object user) {
        	// ...
        }

위와 같이 returning에 파라미터로 리턴값을 받을 변수명을 설정하여 받을 수 있다. 리턴값을 Object로 받고 있는데 pointcut에서 리턴 값을 *로 어떤 리턴값이라도 받을 수 있도록 명시하였기 때문에 Object로 받는다.

  • @AfterThrowing

pointcut으로 설정한 join point가 exception에 의해 종료하는 경우 적용하는 Advice이다. 이 경우도 throw된 Exception 클래스를 인자로 전달받아 사용할 수 있다. @AfterThrowing어노테이션의 atturibute 중 throwing에 설정하여 파라미터로 받아주면 된다.

        @AfterThrowing(
        	pointcut = "execution(* edu.pkch.mvcedu.service.UserService.create(..))",
        	throwing = "ex"
        )
        public void unauthorizedException(UnAuthorizedException ex) {
        	// ...
        }
  • @After

pointcut으로 설정한 join point의 실행이 모두 종료된 후에 적용하는 Advice이다. 주로 사용한 resource를 닫아주거나 이와 비슷한 목적으로 사용한다.

        @After("execution(* edu.pkch.mvcedu.service.UserService.create(..))")
        public void doReleaseLock() {
        	// ...
        }
  • @Around

사실 위 모든 어노테이션에서 할 수 있는 작업을 @Around에서 할 수 있다. 이때 주의할 점은 반드시 첫번째 파라미터에는 ProceedingJoinPoint 타입이 있어야한다.

        @Around("controllerPointcut()")
        private Object requestAndResponseLogging(ProceedingJoinPoint joinPoint) throws Throwable {
        		log.info("request to {} with {}", joinPoint.getSignature(), joinPoint.getArgs());
            ResponseEntity<String> result = null;
            try {
                result = (ResponseEntity<String>) joinPoint.proceed();
                log.info("response code {}", result.getStatusCode());
                log.info("result {}", result);
            } catch (Exception ex) {
                ex.printStackTrace();
            }
            return result;
        }

위와 같이 AOP를 사용하여 로그를 찍는다고 가정한다. 이때 joinpoint의 proceed()메서드가 기준이 된다. proceed는 대상이 되는 join point를 실행하는 메서드이다. proceed 이전의 로직은 @Before와 동일하며 이후는 @After와 동일하다.

또한 proceed메서드로 리턴값을 받을 수 있으므로 이는 @AfterReturning과 같다. 그리고 catch문 내부에서 로직에서 발생하는 Exception에 접근할 수 있으므로 @AfterThrowing을 대체할 수 있다.

※ 주의점!

Around 어드바이스를 사용할 때 반드시 최종 결과를 리턴해주어야한다. AOP가 기존 요청의 결과를 하이재킹하는 형식을 사용하므로 최종 결과를 리턴하지 않는다면 최종 응답이 아무것도 없게된다.

참고 : https://www.mkyong.com/spring/spring-aop-examples-advice/

Introduction

참고 : https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#aop-introductions

AOP Introduction이란 대상이 되는 클래스 / 인터페이스에 설정한 인터페이스의 구현체를 구현한다고 선언하는 것이다. Introduction은 @DeclareParents로 구현할 수 있다.

    @Aspect
    public class UsageTracking {
    
        @DeclareParents(value="com.xzy.myapp.service.*+", defaultImpl=DefaultUsageTracked.class)
        public static UsageTracked mixin;
    
        @Before("com.xyz.myapp.SystemArchitecture.businessService() && this(usageTracked)")
        public void recordUsage(UsageTracked usageTracked) {
            usageTracked.incrementUseCount();
        }
    
    }

구현체의 인터페이스는 @DeclareParents가 붙어있는 필드의 타입에 따라 결정된다. 이때 중요한 것은 위 @Before Advice에서 Introduction으로 선언한 타입 UsageTracked를 사용한다는 점이다.

추측 : Introduction이라는 것은 위와 같이 실제 코드에서는 선언한 필드를 사용하는 것이 아닌 AOP에서 사용한다는 의미라고 이해함

실제 AOP로 로그찍은 예시

@Aspect
@Component
public class LoggingAspect {

    private static Logger log;

    @Pointcut("execution(* com.wootecobook.turkey.*.controller..*Controller.*(..))")
    public void loggingForController() {
    } // 1

    @Pointcut("execution(* com.wootecobook.turkey..*Advice.*handleException(..))")
    public void loggingForException() {
    } // 2

    @Around("loggingForController()")
    public Object controllerLogging(final ProceedingJoinPoint pjp) throws Throwable {
        setLogger(pjp.getSignature().getDeclaringType());
        log.info("request by {}, args: {} ", pjp.getSignature(), pjp.getArgs());
        Object requestResult = pjp.proceed();
        log.info("response {}", requestResult);

        return requestResult;
    } // 3

    @Around("loggingForException() && args(exception)")
    public Object exceptionLogging(final ProceedingJoinPoint pjp, Exception exception) throws Throwable {
        setLogger(exception.getClass());
        Object result = pjp.proceed();
        log.error("errorMessage: {}", exception.getMessage());

        return result;
    } // 4

    private void setLogger(Class<?> clazz) {
        log = LoggerFactory.getLogger(clazz);
    }
}

아래 번호는 위 주석에 달린 번호에 대한 부연설명입니다.

1

execution(* com.wootecobook.turkey.*.controller..*Controller.*(..))

execution AOP 표현식으로 com.wootecobook.turkey 패키지 아래의 (한 뎁스 아래의 패키지 모두) 모든 패키지 에서 controller 패키지의 하위 모든 패키지에 있는 Controller로 끝나는 클래스의 모든 메서드를 대상으로 지정하겠다는 의미이다. 이때 (..)로 파라미터의 갯수, 종류는 상관없음을 표시하였다.

※ 참고

  • aop 표현식에서 .과 ..의 차이 (패키지 표현에서)

.은 한 뎁스 아래의 패키지 만을 가리킨다.

반면 ..은 한 뎁스 아래 뿐만 아니라 그 하위 패키지 모두를 가리킨다. 즉, 해당 패키지 하위에 있는 모든 패키지를 의미한다.

2.

execution(* com.wootecobook.turkey..*Advice.*handleException(..))

위와 비슷한 방식으로 이해

3.

@Around("loggingForController()")

Around 방식으로 loggingForController pointcut을 사용한다는 의미이다.

public Object controllerLogging(final ProceedingJoinPoint pjp) throws Throwable {

around 방식은 반드시 ProceedingJoinPoint를 첫번째 파라미터로 받아야만한다.

setLogger(pjp.getSignature().getDeclaringType());
log.info("request by {}, args: {} ", pjp.getSignature(), pjp.getArgs());
Object requestResult = pjp.proceed();
log.info("response {}", requestResult);

setLogger는 해당 컨트롤러의 class를 Logger에서 표시하기 위해 사용한다. ProceedingJoinPoint에는 호출되는 메서드에 대한 시그니처 정보, 인자, 불리는 메서드의 소스코드 위치 등의 정보를 가지고 있다.

Around 방식에서 Aspect의 대상이 되는 메서드의 호출은 ProceedingJoinPointproceed 메서드 호출이 기준이된다.

proceed 메서드는 해당 메서드의 리턴값을 Object로 반환한다. 위 예시에서 요청과 응답에 대한 로그를 작성하기 위해 proceed 전후로 log.info로 로그를 작성하고 있다.

return requestResult;

마지막으로 ProceedingJoinPoint.proceed메서드의 결과를 반환해야 해당 메서드의 결과가 제대로 반환된다.

4.

위 3번 예시와 대부분 비슷하다. 다만 args를 사용한 점이 다르다.

@Around("loggingForException() && args(exception)")

마찬가지로 Around 방식을 사용했다. 2번의 loggingForException 포인트컷을 사용하면서 하나의 조건을 더 주었다. args로 exception이라는 인자를 가지는 메서드를 대상으로 지정하였다.

public Object exceptionLogging(final ProceedingJoinPoint pjp, Exception exception) throws Throwable {

이렇게 args를 사용하면 인자로 해당 exception 인자를 Aspect에서도 사용할 수 있다.

⚠️ **GitHub.com Fallback** ⚠️