Advance I/Spring

22.10.24

kinggora 2022. 10. 24. 20:21

bean

스프링 전용 포인트컷 지시자, 빈의 이름으로 지정한다.

 

  • 스프링 빈의 이름으로 AOP 적용 여부를 지정한다.  스프링에서만 사용할 수 있는 특별한 지시자이다.
  • bean(orderService) || bean(*Repository)
  • * 과 같은 패턴을 사용할 수 있다.
@Slf4j
@SpringBootTest
@Import(BeanTest.BeanAspect.class)
public class BeanTest {

    @Autowired
    OrderService orderService;

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

    @Aspect
    static class BeanAspect {
        @Around("bean(orderService) || bean(*Repository)")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[bean] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }
    }
}

 

OrderService , *Repository(OrderRepository) 의 메서드에 AOP가 적용된다.

매개변수 전달

포인트컷 표현식을 사용해서 어드바이스에 매개변수를 전달할 수 있다.

this, target, args,@target, @within, @annotation, @args

 

사용 방법

@Before("allMember() && args(arg,..)")
public void logArgs3(String arg) {
   log.info("[logArgs3] arg={}", arg);
}

 

  • 포인트컷의 이름과 매개변수의 이름을 맞추어야 한다. 여기서는 arg 로 맞추었다.
  • 타입이 메서드에 지정한 타입으로 제한된다. 여기서는 메서드의 타입이 String 으로 되어 있기 때문에 다음과 같이 정의되는 것으로 이해하면 된다. ex. args(arg,..) -> args(String,..)

 

@Slf4j
@SpringBootTest
@Import(ParameterTest.ParameterAspect.class)
public class ParameterTest {

    @Autowired
    MemberService memberService;

    @Test
    void success(){
        log.info("memberService Proxy={}", memberService.getClass());
        memberService.hello("helloA"); //프록시 호출시 넘어온 파라미터를 어드바이스에서 사용
    }

    @Aspect
    static class ParameterAspect {
        @Pointcut("execution(* hello.aop.member..*.*(..))")
        private void allMember() {
        }

        @Around("allMember()")
        public Object logArgs1(ProceedingJoinPoint joinPoint) throws Throwable {
            Object[] args1 = joinPoint.getArgs();
            log.info("[logArgs1] {}, arg={}", joinPoint.getSignature(), args1);
            return joinPoint.proceed();
        }

        @Around("allMember() && args(arg,..)")
        public Object logArgs2(ProceedingJoinPoint joinPoint, Object arg) throws Throwable {
            log.info("[logArgs2] {}, arg={}", joinPoint.getSignature(), arg);
            return joinPoint.proceed();
        }

        @Before("allMember() && args(arg,..)")
        public void logArgs3(String arg) { //파라미터가 String 이 아니면 매칭조차 안됨
            log.info("[logArgs3] arg={}", arg);
        }

        @Before("allMember() && this(obj)")
        public void thisArgs(JoinPoint joinPoint, MemberService obj) {
            log.info("[this] {}, obj={}", joinPoint.getSignature(), obj.getClass());
        }

        @Before("allMember() && target(obj)")
        public void targetArgs(JoinPoint joinPoint, MemberService obj) {
            log.info("[target] {}, obj={}", joinPoint.getSignature(), obj.getClass());
        }

        @Before("allMember() && @target(annotation)")
        public void atTarget(JoinPoint joinPoint, ClassAop annotation) {
            log.info("[@target] {}, obj={}", joinPoint.getSignature(), annotation);
        }

        @Before("allMember() && @within(annotation)")
        public void atWithin(JoinPoint joinPoint, ClassAop annotation) {
            log.info("[@within] {}, obj={}", joinPoint.getSignature(), annotation);
        }

        @Before("allMember() && @annotation(annotation)")
        public void atAnnotation(JoinPoint joinPoint, MethodAop annotation) {
            log.info("[@annotation] {}, obj={}", joinPoint.getSignature(), annotation.value());
        }
    }
}

 

  • logArgs1 : joinPoint.getArgs()[0] 와 같이 매개변수를 전달 받는다.
  • logArgs2 : args(arg,..) 와 같이 매개변수를 전달 받는다.
  • logArgs3 : @Before 를 사용한 축약 버전이다. 추가로 타입을 String 으로 제한했다.
  • this : 프록시 객체를 전달 받는다. (스프링 컨테이너에 저장된 객체, 이때는 프록시 객체이다.)
  • target : 실제 대상 객체를 전달 받는다.
  • @target , @within : 타입의 애노테이션을 전달 받는다. - @Target(ElementType.TYPE) 인 애노테이션
  • @annotation : 메서드의 애노테이션을 전달 받는다. 여기서는 annotation.value() 로 해당 애노테이션의 값을 출력하는 모습을 확인할 수 있다. - @Target(ElementType.METHOD) 인 애노테이션

@annotation은 애노테이션의 값을 출력하기 위해 간혹 사용

this, target

this : 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트

target : Target 객체(스프링 AOP 프록시가 가르키는 실제 대상)를 대상으로 하는 조인 포인트

 

this , target 은 다음과 같이 적용 타입 하나를 정확하게 지정해야 한다.

this(hello.aop.member.MemberService)
target(hello.aop.member.MemberService)

 

  • * 같은 패턴을 사용할 수 없다.
  • 부모 타입을 허용한다. (인터페이스, 클래스 상속도 매칭)

this vs target

단순히 타입 하나를 정하면 되는데, this 와 target 은 어떤 차이가 있을까?

 

스프링에서 AOP를 적용하면 실제 target 객체 대신에 프록시 객체가 스프링 빈으로 등록된다.

  • this 는 스프링 빈으로 등록되어 있는 프록시 객체를 대상으로 포인트컷을 매칭한다.
  • target 은 실제 target 객체를 대상으로 포인트컷을 매칭한다.

프록시 생성 방식에 따른 차이

JDK 동적 프록시와 CGLIB를 생성하는 방식이 다르기 때문에 차이가 발생한다.

  • JDK 동적 프록시: 인터페이스가 필수이고, 인터페이스를 구현한 프록시 객체를 생성한다.
  • CGLIB: 인터페이스가 있어도 구체 클래스를 상속 받아서 프록시 객체를 생성한다.

JDK 동적 프록시

proxy: MemberService 인터페이스를 구현한 객체

target: MemberServiceImpl 인스턴스

 

1. MemberService 인터페이스 지정

  • this(hello.aop.member.MemberService) : proxy 객체를 보고 판단한다. this 는 부모 타입을 허용하기 때문에 AOP가 적용된다.
  • target(hello.aop.member.MemberService) : target 객체를 보고 판단한다. target 은 부모 타입을 허용하기 때문에 AOP가 적용된다.

2.  MemberServiceImpl 구체 클래스 지정

  • this(hello.aop.member.MemberServiceImpl) : proxy 객체를 보고 판단한다. JDK 동적 프록시로 만들어진 proxy 객체는 MemberService 인터페이스를 기반으로 구현된 새로운 클래스다. 따라서 MemberServiceImpl 를 전혀 알지 못하므로 AOP 적용 대상이 아니다.
  • target(hello.aop.member.MemberServiceImpl) : target 객체를 보고 판단한다. target 객체가 MemberServiceImpl 타입이므로 AOP 적용 대상이다.

CGLIB 프록시

proxy: MemberServiceImpl 을 상속받은 객체

target: MemberServiceImpl 인스턴스

 

1. MemberService 인터페이스 지정

  • this(hello.aop.member.MemberService) : proxy 객체를 보고 판단한다. this 는 부모 타입을 허용하기 때문에 AOP가 적용된다.
  • target(hello.aop.member.MemberService) : target 객체를 보고 판단한다. target 은 부모 타입을 허용하기 때문에 AOP가 적용된다.

2. MemberServiceImpl 구체 클래스 지정

  • this(hello.aop.member.MemberServiceImpl) : proxy 객체를 보고 판단한다. CGLIB로 만들어진 proxy 객체는 MemberServiceImpl 를 상속 받아서 만들었기 때문에 AOP 적용가 적용된다. this 가 부모 타입을 허용하기 때문에 포인트컷의 대상이 된다.
  • target(hello.aop.member.MemberServiceImpl) : target 객체를 보고 판단한다. target 객체가 MemberServiceImpl 타입이므로 AOP 적용 대상이다.

 

프록시를 대상으로 하는 this 의 경우 구체 클래스를 지정하면 프록시 생성 전략에 따라서 다른 결과가 나올 수 있다.

 

/**
 * application.properties
 * spring.aop.proxy-target-class=true CGLIB (스프링 부트 기본값)
 * spring.aop.proxy-target-class=false JDK 동적 프록시
 */
@Slf4j
@SpringBootTest(properties = "spring.aop.proxy-target-class=true")
@Import(ThisTargetTest.ThisTargetAspect.class)
public class ThisTargetTest {

    @Autowired
    MemberService memberService;

    @Test
    void success(){
        log.info("memberService proxy={}", memberService.getClass());
        memberService.hello("helloA");
    }

    @Aspect
    static class ThisTargetAspect {

        @Around("this(hello.aop.member.MemberService)")
        public Object doThisInterface(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[this-interface] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }

        @Around("target(hello.aop.member.MemberService)")
        public Object doTargetInterface(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[target-interface] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }

        @Around("this(hello.aop.member.MemberServiceImpl)")
        public Object doThisImpl(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[this-impl] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }

        @Around("target(hello.aop.member.MemberServiceImpl)")
        public Object doTargetImpl(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[target-impl] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }

    }
}

 

  • properties = {"spring.aop.proxy-target-class=false"} : application.properties 에 설정하는 대신에 해당 테스트에서만 설정을 임시로 적용한다. 이렇게 하면 각 테스트마다 다른 설정을 손쉽게 적용할 수 있다.
  • spring.aop.proxy-target-class=false : 스프링이 AOP 프록시를 생성할 때 JDK 동적 프록시를 우선 생성한다. 물론 인터페이스가 없다면 CGLIB를 사용한다.
  • spring.aop.proxy-target-class=true : 스프링이 AOP 프록시를 생성할 때 인터페이스가 있던 없던 CGLIB 프록시를 생성한다. 참고로 이 설정을 생략하면 스프링 부트에서 기본으로 CGLIB를 사용한다. 이 부분은 뒤에서 자세히 설명한다.

참고: this , target 지시자는 단독으로 사용되기 보다는 파라미터 바인딩에서 주로 사용된다.

 

참고: 혹시 해당 내용이 잘 이해가 되지 않으면 스프링 AOP 실무 주의 사항에서 프록시 기술과 한계를 듣고 다시 보자.

 

실행 결과

spring.aop.proxy-target-class=false - JDK 동적 프록시 사용

memberService Proxy=class com.sun.proxy.$Proxy53
[target-impl] String hello.aop.member.MemberService.hello(String)
[target-interface] String hello.aop.member.MemberService.hello(String)
[this-interface] String hello.aop.member.MemberService.hello(String)

 

[this-impl] 이 출력되지 않았다.

12. 스프링 AOP - 실전 예제

예제 만들기

애노테이션 기반 AOP 적용

  • @Trace: 애노테이션으로 로그 출력하기
  • @Retry: 애노테이션으로 예외 발생시 재시도 하기 (ex. 서버와 통신이 불안정한 상태에서 조회 같은 경우 서버에 재시도해서 결과 받기)

리포지토리 코드

@Repository
public class ExamRepository {

    private static int seq = 0;

    /**
     * 5번에 1번 실패하는 요청
     */
    public String save(String itemId) {
        seq++;
        if (seq % 5 == 0) {
            throw new IllegalArgumentException("예외 발생");
        }
        return "ok";
    }
}

 

5번에 1번 정도 실패하는 저장소이다. 이렇게 간헐적으로 실패할 경우 재시도 하는 AOP가 있으면 편리하다.

서비스 코드

@Service
@RequiredArgsConstructor
public class ExamService {

    private final ExamRepository examRepository;

    public void request(String itemId) {
        examRepository.save(itemId);
    }
}

테스트 코드

@Slf4j
@SpringBootTest
@Import({TraceAspect.class, RetryAspect.class})
class ExamTest {

    @Autowired ExamService examService;

    @Test
    void test(){
        for (int i = 0; i < 5; i++) {
            log.info("client request i={}", i);
            examService.request("data" + i);
        }
    }
}

 

5번째 루프를 실행할 때 리포지토리 위치에서 예외가 발생하면서 테스트가 실패한다.

 

로그 출력 AOP - @Trace

@Trace 가 메서드에 붙어 있으면 호출 정보가 출력된다.

 

애노테이션 정의

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Trace {
}

 

애스펙트 정의

@Slf4j
@Aspect
public class TraceAspect {

    @Before("@annotation(hello.aop.exam.annotation.Trace)")
    public void doTrace(JoinPoint joinPoint){
        Object[] args = joinPoint.getArgs();
        log.info("[trace] {} args={}", joinPoint.getSignature(), args);
    }
}

 

 TraceAspect 를 스프링 빈으로 등록해야 한다.

 

AOP 적용 - @Trace 추가

@Trace
public String ExamService.save(String itemId) { ... }

@Trace
public void ExamRepository.request(String itemId) { ... }

재시도 AOP

@Retry 애노테이션이 있으면 예외가 발생했을 때 다시 시도해서 문제를 복구한다.

 

애노테이션 정의

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
    int value() default  3;
}

value() : 재시도 가능 회수

 

애스펙트 정의

@Slf4j
@Aspect
public class RetryAspect {

    @Around("@annotation(retry)")
    public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
        log.info("[retry] {} retry={}", joinPoint.getSignature(), retry);

        int maxRetry = retry.value();
        Exception exceptionHolder = null;

        for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
            try{
                log.info("[retry] try count={}/{}", retryCount, maxRetry);
                return joinPoint.proceed();
            } catch (Exception e){
                exceptionHolder = e;
            }
        }
        throw exceptionHolder; //maxRetry 까지 예외가 발생할 경우
    }
}

 

  • @annotation(retry) , Retry retry 를 사용해서 어드바이스에 애노테이션을 파라미터로 전달한다. 파라미터로 타입을 지정했기 때문에 애노테이션의 경로가 필요하지 않다.
  • retry.value() 를 통해서 애노테이션에 지정한 값을 가져올 수 있다.
  • joinPoint.proceed() 에서 예외가 발생해서 결과가 정상 반환되지 않으면 retry.value() 만큼 재시도한다.
  • maxRetry 까지 예외가 발생하면 exceptionHolder 를 AOP 밖으로 던진다. (부가 기능이 예외를 삼키면 안됨)

AOP 적용 - @Retry 추가

@Trace
@Retry(4)
public String ExamService.save(String itemId) { ... }

@Retry(value = 4) 를 적용했다. 이 메서드에서 문제가 발생하면 4번 재시도 한다.

 

참고: 스프링이 제공하는 @Transactional 은 가장 대표적인 AOP이다.