kiwi

AOP(Aspect-Oriented-Programming)

by 키위먹고싶다

aspect

핵심기능과 부가기능을 분리하는 것이다. 

부가 기능을 핵심 기능에서 분리하고 한 곳에서 관리하도록 하며 해당 부가 기능을 어디에 적용할지 선택하는 기능도 만들었는데 이렇게 부가 기능과 부가 기능을 어디에 적용할지 선택하는 기능을 합해서 하나의 모듈로 만들었는데 이것이 바로 aspect이다. 

 

@Asepct를 떠올리자. 스프링이 제공하는 어드바이저도 어드바이스(부가기능)와 포인트컷(적용대상)을 가지고 있어 개념상 하나의 aspect이다. 

 

aspect는 관점이라는 뜻으로 애플리케이션을 바라보는 관점을 하나하나의 기능에서 횡단관심사 관점으로 보는것이다. 이렇게 aspect를 사용한 프로그래밍 방식을 관점지향 프로그래밍 AOP(Aspect-Oriented-Programming)이라고 한다.

 

AOP는 OOP를 대체하기 위한 것이 아니라 횡단 관심사를 깔끔하게 처리하기 어려운 OOP의 부족한 부분을 보조하는 목적으로 개발되었다.  

 

AOP의 대표적인 구현으로 AspectJ 프레임워크가 있지만 스프링에서 지원하는 AOP는 AspectJ의 문법을 사용하고 AspectJ가 제공하는 기능의 일부분만 제공한다. 

 

 


AOP 적용 방식

 

핵심기능과 부가기능이 코드상 완전히 분리되어서 관리될때,  실제 로직에 부가기능 로직을 추가하는 방법은 3가지가 있다. 

 

컴파일시점, 클래스 로딩시점, 런타임 시점(프록시)

 

  • 컴파일 시점 : 자바 컴파일러가 java 소스 코드를 바이트 코드인 .class파일을 만들때 부가 기능 로직을 추가하는 것인데 이때 AspectJ가 제공하는 특별한 컴파일러를 사용해야 한다. 컴파일 된 .class를 디컴파일하면 aspect관련 호출 코드가 들어간다. 핵심기능코드가 컴파일 되면 부가기능코드가 이 주변에 붙는다고 생각해보자. 이때  AspectJ컴파일러가 aspect를 확인해서 해당 클래스가 적용 대상인지 먼저 확인하고, 적용 대상인 경우에 부가 기능 로직을 적용한다. 이때 원본 로직에 부가 기능 로직이 추가되는 것을 위빙이라고 한다. 컴파일 시점에 부가 기능을 적용하려면 특별한 컴파일러(AspectJ컴파일러)가 필요하며 복잡하다는 단점이 있다.

 

  • 클래스 로딩 시점 : 자바를 실행하면 자바언어는 .class파일을 JVM내부의 클래스 로더에 보관한다. 이때 중간에서 .class파일을 조작한 다음 JVM에 올릴 수 있다. 자바 언어는 .class를 JVM에 저장하기 전에 조작할 수 있는 기능을 제공한다. 이 시점에 에스팩트를 적용하는 것을 로드 타임 위빙이라고 한다. 로드타임위빙은 자바를 실행할 때 틀별한 옵션인 javaagent를 통해 클래스 로더 조작기를 지정해야 하는데, 이 부분이 번거롭고 운영하기 어렵다.

 

  • 런타임 시점 : 런타임 시점은 컴파일도 다 끝나고 클래스 로더에 클래스도 다 올라가서 이미 자바가 실행되고 난 다음을 말한다.(main메서드). 그래서 자바 언어가 제공하는 범위 내에서 부가 기능을 적용해야 한다. 스프링컨테이너의 도움을 받고 프록시와 DI, 빈 포스트 프로세서와 같은 기능의 도움을 받아야 한다. 이렇게 하면 최종적으로 프록시를 통해 스프링 빈에 부가 기능을 적용 할 수 있다. 프록시를 사용하기 때문에 AOP기능에 일부 제약이 있지만 특별한 컴파일러나 자바를 실행할 때 복잡한 옵션과 클래스 로더 조작기를 설정하지 않아도 된다. 스프링만 있으면 AOP를 얼마든지 적용할 수 있다. 

 

정리하면 컴파일시점과 클래스 로딩시점은 실제 대상코드에 aspect를 통한 부가기능 호출코드가 포함되며 AspectJ를 직접 사용한다는 것이고, 런타임시점은 실제 대상코드는 그대로 유지하면면서 프록시를 통해 부가 기능이 적용되므로 항상 프록시를 통해야 부가 기능을 사용할 수 있다. 스프링 AOP는 이 방식을 사용한다.

 

 

AOP적용 위치

Aop는 메서드 실행위치 뿐만 아니라 생성자, 필드 값 접근, static메서드 접근, 메서드 실행 등 다양한 적용가능 지점이 있다. 이때 aop적용 가능 지점을 조인 포인트라고 한다. AsepctJ를 사용하는 컴파일 시점과 클래스 로딩 시점에 AOP를 적용 할때는 바이트코드를 실제로 조작하기 때문에 해당 기능을 모든 지점에 다 적용할 수 있다. 그러나 프록시 방식을 사용하는 스프링 AOP는(런타임 시점) 메서드 실행 지점에만 AOP를 적용할 수 있다. 프록시는 메서드 오버라이딩 개념으로 동작하기 때문에 생성자나 static메서드, 필드 값 접근에는 프록시 개념이 적용 될 수 없으므로 스프링 AOP의 조인 포인트는 메서드 실행으로 제한된다. 프록시 방식을 사용하는 스프링 AOP는 스프링 컨테이너가 관리할 수 있는 스프링 빈에만 AOP를 적용 할 수 있다. 

 

 

스프링은 AspectJ를 직접 사용하지 않고 AspectJ의 문법을 차용하며 프록시 방식의 AOP를 적용한다. 프록시를 통해 메서드를 실행하는 시점에만 AOP가 적용된다. 

 

 


 

AOP 용어 정리

 

  • 조인 포인트 : 어드바이스가 적용 될 수 있는 위치, 메소드 실행, 생성자 호출, 필드 값 접근, static 메서드 접근 같은 프로그램 실행중 지점, AOP를 적용할 수 있는 모든 지점이다. 스프링 AOP는 프록시 방식을 사용하므로 조인 포인트는 항상 메소드 실행 지점으로 제한된다. 
  • 포인트 컷 : 조인 포인트 중에서 어드바이스가 적용될 위치를 선별하는 기능이며 AspectJ표현식을 사용해서 지정한다. 스프링 AOP는 메서드 실행지점만 포인트컷으로 선별 가능
  • 타겟 : 어드바이스를 받는 객체이며 포인트컷으로 결정한다. 
  • 어드바이스 : 부가 기능이며 특정 조인포인트에서 Asepct에 의해 취해지는 조치이다. Around, Before, After과 같은 다양한 종류의 어드바이스가 있다. 
  • Asepct : 어드바이스 + 포인트컷을 모듈화 한 것이다. @Aspect를 생각하면 되며 여러 어드바이스와 포인트 컷이 함께 존재한다.
  • 위빙 : 포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용하는 것을 말한다. 위빙을 통해서 핵심 기능 코드에 영향을 주지 않고 부가 기능을 추가 할 수 있다. AOP 적용을 위해 애스팩트를 객체에 연결한 상태이며 3가지 시점으로 적용할 수 있다.
  • AOP프록시 : AOP기능을 구현하기 위해 만든 프록시 객체이며 스프링 AOP프록시는 JDK동적 프록시나 CGLIB프록시이다. 

 


 

스프링 AOP 구현

 

build.gardle에 추가하기. 

implementation 'org.springframework.boot:spring-boot-starter-aop'   //직접 추가
//테스트에서 lombok 사용
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'

 

 

OrderRepository.java

package hello.aop.order;

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";
    }

}

 

OrderService.java

package hello.aop.order;

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

}

 

 

Test코드를 만들자. 

@Slf4j
@SpringBootTest
public class AopTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void aopInfo() {
        log.info("isAopProxy, orderService={}", AopUtils.isAopProxy(orderService)); //단순히 AOP 프록시가 적용 됐나 안됐나 true false
        log.info("isAopProxy, orderRepository={}", AopUtils.isAopProxy(orderRepository));
    }

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

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

aopInfo()메서드 실행결과 :

"isAopProxy, orderService=false"

"isAopProxy, orderRepository=false"

 

success()메서드 실행결과 :

"[orderService] 실행"

"[orderRepository] 실행"

 

단순히 프록시가 사용된것이 아니라는 것과 orderService.orderItem(String itemId)과 orderRepository.save(String itemId)이 차례대로 실행된 것을 알 수 있다. 

 

 

그럼 이제 프록시를 적용해보자!

 

AspectV1.java

@Slf4j
@Aspect
public class AspectV1 {

    @Around("execution(* hello.aop.order..*(..))")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
        return joinPoint.proceed();
    }
}

 

 

@Around("execution(* hello.aop.order..*(..))")

이것은 포인트 컷이 된다. 그리고 doLog메서드는 어드바이스가 된다. 

 

 

Around애노테이션의 패키지 지정 문법을 보면 hello.aop.order를 포함한 그 하위 패키지가 AOP적용 되상이 되도록 했다. 현재 패키지 아래에 있는 OrderService와 OrderRepository클래스의 모든 메서드들은 AOP적용 대상이 된다. 프록시를 통하는 메서드만 적용 되상이 되는것이다. 스프링 AOP를 사용할때 @Aspect, @Around를 사용하는데 AspectJ가 제공한다. (실제 AspectJ를 사용하는것이 아니라 build.gradle에 추가 했기 때문에 스프링 AOP관련 기능과 aspectjweaver.jar기능도 사용할 수 있는 것이다)

 

 

@Aspect는 애스팩트라는 표식이지 컴포넌트 스캔이 되는 것이 아니다. 그래서 AsepctV1을 AOP로 사용하려면 스프링 빈으로 등록해야 한다. 

 

스프링 빈을 사용할때 @Bean과 @Component을 사용하지만 @Import를 사용할 수 있다. 

 

 

@Slf4j
@SpringBootTest
@Import(AspectV1.class) //Import 사용하면 스프링 빈으로 등록한다.
public class AopTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void aopInfo() {
        log.info("isAopProxy, orderService={}", AopUtils.isAopProxy(orderService)); //단순히 AOP 프록시가 적용 됐나 안됐나 true false
        log.info("isAopProxy, orderRepository={}", AopUtils.isAopProxy(orderRepository));
    }

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

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

테스트 코드에 AspectV1을 임포트 했다. 그럼 스프링 빈으로 등록된 것이다. 

 

그렇다면 실행 결과를 보자.

 

aopInfo()메서드 실행결과 :

"isAopProxy, orderService=true"

"isAopProxy, orderRepository=true"

 

success()메서드 실행결과 :

"[log] void hello.aop.order.OrderService.orderItem(String)"

"[orderService] 실행"

"[log] String hello.aop.order.OrderRepository.save(String)"

"[orderRepository] 실행"

 

 

프록시가 적용되었으므로 false가 true로 변경되고 로그도 잘 추가 되었다. (스프링 AOP적용 완료!!)

 

 

포인트 컷 추가 - AspectV2

@Slf4j
@Aspect
public class AspectV2 {

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

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
        return joinPoint.proceed();
    }
}

@V1에서는 @Around에 포인트 컷 표현식을 직접 넣을 수 있지만 @Pointcut을 사용해서 별도로 분리 할 수 있다.

 

메서드의 이름과 파라미터를 합쳐서 포인트컷 시그니처라고 한다. void로 return 하며 코드 내용은 비워둔다. 포인트컷 시그니처는 allOrder()이다. 여기서는 private을 사용했지만 다른 애스팩트에서 참고 하려면 public을 사용해야 한다.

 

//@Import(AspectV1.class) //Import 사용하면 스프링 빈으로 등록한다.
@Import(AspectV2.class)

AopTest에서 AspectV1.class를 주석처리하고 V2를 임포트하여 빈으로 등록해서 테스트 메서드를 실행하면

V1을 적용했을때와 결과가 같다. 

 

 

 

어드바이스 추가 - AspectV3

@Slf4j
@Aspect
public class AspectV3 {

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

    //클래스 이름 패턴이 *Service
    @Pointcut("execution(* *..*Service.*(..))")
    public void allService(){}

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
        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());
        }
    }
}

 

설명하자면 allOrder() 포인트컷은 hello.aop.order패키지와 하위 패키지 대상으로 하고 doTransction()포인트컷은 타입 이름 패턴이 *Service를 대상으로 한다. 

 

결과적으로 OrderService에는 doLog()와 doTransction()어드바이스가 적용되며 OrderRespositoy에는 doLog()어드바이스가 적용된다. 

 

AopTest.java를 수정하자.

@Import(AspectV3.class)

 

AopTest의 success()의 실행결과는 다음과 같다. 

 

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

 

[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적용 전 :

클라이언트 -> orderSerivce.orderItem() -> orderRepository.save()

 

AOP적용 후 :클라이언트 -> doLog()-> doTransction() -> orderService.orderItem() -> doLog() -> orderRepositoy.save()

 

 

포인트컷 참조 - AspectV4Pointcut

 

참조할 포인트컷 클래스 외부로 빼기. 

public class PointCuts {

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

    //클래스 이름 패턴이 *Service
    @Pointcut("execution(* *..*Service.*(..))")
    public void allService(){}

    @Pointcut("allOrder() && allService()")
    public void orderAndService() {}
}

 

이때 주의점은 외부에서 사용해야 하므로 private이 아니라 public 임. 

 

AspectV4Pointcut.java

@Slf4j
@Aspect
public class AspectV4Pointcut {

    @Around("hello.aop.order.aop.PointCuts.allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
        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.java변경

//@Import(AspectV3.class)
@Import(AspectV4Pointcut.class)

 

실행결과는 그 전과 같다. 

 

 

어드바이스 순서

 

@Aspect적용 단위로 @Order애노테이션을 적용해야 한다. 주의 할 점은 이것은 메서드 단위가 아니라 클래스 단위로 적용할 수 있다는 점이다. 하나의 애스팩트에 여러 어드바이스가 있으면 순서를 보장 받을 수 없으므로 애스팩트를 별도의 클래스로 분리해야 한다. 

 

AspectV5Order.java

@Slf4j
@Aspect
public class AspectV5Order {

    @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()); //join point 시그니처
            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());
            }
        }
    }

}

하의 애스팩트 안에 있던 어드바이스를 각각의 애스팩트로 분리하고 애스팩트에 @Order를 통해 순서를 적용했다. 숫자가 작을수록 먼저 실행된다. 

 

AopTest.java 변경

//@Import(AspectV4Pointcut.class)
@Import({AspectV5Order.LogAspect.class, AspectV5Order.TxAspect.class})

 

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)

 

실행결과를 보면 아까와 다르게 트랜잭션 어드바이스가 먼저 실행되고 로그 어드바이스가 실행된다. 

 

 

어드바이스 종류

 

AspectV6Advice.java

@Slf4j
@Aspect
public class AspectV6Advice {

    @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("hello.aop.order.aop.PointCuts.orderAndService()")
    public void deBefore(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);
    }

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


}

 

 

AopTest.java 임포트 수정

//@Import({AspectV5Order.LogAspect.class, AspectV5Order.TxAspect.class})
@Import(AspectV6Advice.class)

 

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)

 

 

@Around 만 있어도 모든 기능을 수행할 수 있는데 어드바이스가 존재하는 이유가 있다. 

@Around는 항상 joinPoint.proceed()를 호출해야 하는데 만약 호출하지 않으면 타겟이 호출되지 않으므로 큰 버그가 발생하지만 @Before같은 경우 호출하지 않아도 버그가 생기지 않는다. 

 

 

 


포인트 컷

 

포인트컷 지시자는 'execution'과 같은 표현식을 포함한다. 

 

 

execution과 WhitIn 의 차이

@Test
@DisplayName("타켓의 타입에만 직접 적용, 인터페이스를 선정하면 안된다.")
void withinSuperTypeFalse() {
    pointcut.setExpression("within(hello.aop.member.MemberService)");
    assertThat(pointcut.matches(helloMethod,
            MemberServiceImpl.class)).isFalse();
}

@Test
@DisplayName("execution은 타입 기반, 인터페이스를 선정 가능.")
void executionSuperTypeTrue() {
    pointcut.setExpression("execution(* hello.aop.member.MemberService.*(..))");
    assertThat(pointcut.matches(helloMethod,
            MemberServiceImpl.class)).isTrue();
}

 

WhitIn은 타입에 직접 적용되기 때문에 부모 타입을 허용하지 않고 정확하게 자기 자신만 포함시킨다는 점에서 차이가 있다. 

 

 

args

인자가 주어진 타입의 인스턴스의 조인 포인트로 매칭

 

execution과 args의 차이점

@Test
void argsVsExecution() {
    //Args
    assertThat(pointcut("args(String)")
            .matches(helloMethod, MemberServiceImpl.class)).isTrue();
    assertThat(pointcut("args(java.io.Serializable)")
            .matches(helloMethod, MemberServiceImpl.class)).isTrue();
    assertThat(pointcut("args(Object)")
            .matches(helloMethod, MemberServiceImpl.class)).isTrue();
    //Execution
    assertThat(pointcut("execution(* *(String))")
            .matches(helloMethod, MemberServiceImpl.class)).isTrue();
    assertThat(pointcut("execution(* *(java.io.Serializable))") //매칭 실패
            .matches(helloMethod, MemberServiceImpl.class)).isFalse();
    assertThat(pointcut("execution(* *(Object))") //매칭 실패
            .matches(helloMethod, MemberServiceImpl.class)).isFalse();
}

 

execution은 파라미터 타입이 정확하게 매칭되어야 하지만 args는 부모 타입을 허용한다. 

 - 참고 : String은 Object, java.io.Serializable의 하위 타입이다. 

 

 

@taget vs @Within

 

target은 인스턴스의 모든 메서드를 조인포인트로 적용한다.

Within은 해당 타입 내에 있는 메서드만 조인 포인트로 적용한다. 

 

 

@annotation

- 메서드가 주어진 애노테이션을 가지고 있는 조인 포인트를 매칭

 

@args

- 전달된 실제 인수의 런타임 타입이 주어진 타입의 애노테이션을 갖는 조인포인트

 

bean

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

 

매개변수 전달

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

 

 

this vs target

@Before("allMember() && this(obj)") //프록시 객체(실제 빈에 저장된 애) - MemberServiceImpl$$EnhancerBySpringCGLIB$$530288f9
public void thisArgs(JoinPoint joinPoint, MemberService obj) {
    log.info("[this]{}, obj={}", joinPoint.getSignature(), obj.getClass());
}

@Before("allMember() && target(obj)")   //실제 객체(프록시를 호출하는 객체) - memberServiceImpl
public void targetArgs(JoinPoint joinPoint, MemberService obj) {
    log.info("[target]{}, obj={}", joinPoint.getSignature(), obj.getClass());
}

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

taget은 실제 'taget객체'를 대상으로 포인트컷을 매칭한다. 

 

 

 


실무 주의사항

 

프록시와 내부 호출 - 문제

 

스프링은 프록시 방식의 AOP를 사용하는데 프록시 방식의 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다. 

왜냐하면 프록시를 통해서 대상 객체(Target)을 호출하는데 이렇게 해서 프록시가 어드바이스를 먼저 호출하고, 이후에 대상 객체를 호출하기 때문이다. 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 어드바이스도 호출되지 않는다. 

 

스프링 AOP를 적용할 때 대상객체 대신에 프록시를 스프링 빈으로 등록하며 스프링은 의존관계 주입시 항상 프록시 객체를 주입한다. 프록시 객체가 주입되므로 대상 객체를 직접 호출하는 문제는 발생하지 않지만, 대상 객체 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다. 

 

 

CallServiceV0.java

@Slf4j
@Component
public class CallServiceV0 {

    public void external() {
        log.info("call external");
        internal(); //내부 메서드 호출(this.internal())
    }

    public void internal() {
        log.info("call internal");
    }
}

 

CallLogAsepct.java

@Slf4j
@Aspect
public class CallLogAspect {

    @Before("execution(* hello.aop.internalcall..*.*(..))")
    public void doLog(JoinPoint joinPoint) {
        log.info("aop={}", joinPoint.getSignature());
    }
}

 

CallServiceV0Test.java

@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {

    @Autowired
    CallServiceV0 callServiceV0;

    @Test
    void external() {
        callServiceV0.external();
    }

    @Test
    void internal() {
        callServiceV0.internal();
    }
}

 

테스트 코드를 실행해보자. external()을 호출하면 결과는 

 

CallLogAspect : aop=void hello.aop.internalcall.CallServiceV0.external()
CallServiceV0 : call external
CallServiceV0 : call internal

 

CallServiceV0의 internal()전에 프록시가 호출이 안됐다. 그 이유는 external()내부에서 internal()을 직접 호출했기 때문에 프록시가 적용이 안된것이다. 

 

그렇다면 테스트 코드의 internal()을 호출해보자

 

CallLogAspect : aop=void hello.aop.internalcall.CallServiceV0.internal()
CallServiceV0 : call internal

 

internal()전에 Aspect가 적용된것을 볼 수 있다. 

 

참고로 AsepctJ프레임워크를 사용하면 해당 코드에 직접 AOP적용 코드가 바이트코드로 붙어있기 때문에 이러한 문제가 발생하지 않는다. 

 

 

 

프록시와 내부 호출 - 대안 1 자기 자신 주입

 

CallServiceV1.java

/**
 * 참고: 생성자 주입은 순환 사이클을 만들기 때문에 실패한다.
 */
@Slf4j
@Component
public class CallServiceV1 {

    private CallServiceV1 callServiceV1;

    @Autowired
    public void setCallServiceV1(CallServiceV1 callServiceV1) {
        this.callServiceV1 = callServiceV1;
    }

    public void external() {
        log.info("call external");
        callServiceV1.internal(); //외부 메서드 호출
    }

    public void internal() {
        log.info("call internal");
    }
}

 

CallServiceV1Test.java

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV1Test {

    @Autowired
    CallServiceV1 callServiceV1;

    @Test
    void external() {
        callServiceV1.external();
    }
}

 

callServiceV1을 수정자 주입 받으면 주입 받은 대상은 실제 자신이 아니라 프록시 객체이다. 따라서 internal()은 프록시 가 호출하므로 프록시를 통한 AOP를 적용할 수 있다. 

 

실행결과 : 

CallLogAspect : aop=void hello.aop.internalcall.CallServiceV1.external()
CallServiceV2 : call external
CallLogAspect : aop=void hello.aop.internalcall.CallServiceV1.internal()
CallServiceV2 : call internal

 

 

프록시와 내부 호출 - 대안 2 지연 조회

 

callServiceV2.java

/**
 * ObjectProvider(Provider), ApplicationContext를 사용해서 지연(LAZY) 조회
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV2 {

    private final ObjectProvider<CallServiceV2> callServiceProvider;

    public void external() {
        log.info("call external");

        CallServiceV2 callServiceV2 = callServiceProvider.getObject();
        callServiceV2.internal(); //외부 메서드 호출
    }

    public void internal() {
        log.info("call internal");
    }
}

 

callServiceV2Test.java

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV2Test {

    @Autowired
    CallServiceV2 callServiceV2;

    @Test
    void external() {
        callServiceV2.external();
    }
}

 

ObjectProvider는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연한다. callServiceProvider.getObject() 를 호출하는 시점에 스프링 컨테이너에서 빈을 조회한다.
여기서는 자기 자신을 주입 받는 것이 아니기 때문에 순환 사이클이 발생하지 않는다.

 

실행결과는 V1과 마찬가지로 두가지메서드에 모두 AOP가 잘 적용된다. 

 

 

프록시와 내부 호출 - 대안 3 구조 변경

 

이 방법을 권장한다.

 

CallServiceV3.java

/**
 * 구조를 변경(분리)
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {

    private final InternalService internalService;

    public void external() {
        log.info("call external");
        internalService.internal(); //외부 메서드 호출
    }

}

 

InternalService.java

@Slf4j
@Component
public class InternalService {

    public void internal() {
        log.info("call internal");
    }

}

 

CallServiceV3Test.java

 

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV3Test {

    @Autowired
    CallServiceV3 callServiceV3;

    @Test
    void external() {
        callServiceV3.external();
    }
}

내부 호출 자체가 사라지고 callService가 internalService를 호출하는 구조이다. 

이러면 자연스럽게 AOP가 적용된다. 

 

 

 

 

프록시 기술과 한계 - 타입 캐스팅

 

JDK동적 프록시는 인터페이스가 필수이고, 인터페이스를 기반으로 프록시를 생성한다.

 

proxyCastingTest.java

public class ProxyCastingTest {

    @Test
    void jdkProxy() {
        MemberServiceImpl target = new MemberServiceImpl();//구체 클래스  + 인터페이스 구현
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.setProxyTargetClass(false);    //JDK 동적 프록시

        //프록시를 인터페이스로 캐스팅 성공
        MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();

        //JDK 동적 프록시를 구현 클래스로 캐스팅 시도 실패, ClassCastException 예외 발생
        assertThrows(ClassCastException.class, () ->
        {MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;});


    }
}

스프링이 프록시를 만들때 제공하는 ProxyFactory를 사용해서 JDK동적 프록시를 만들었다. 

참고로 MemberServiceImpl클래스는 MemberService인터페이스의 자식이다. 

 

JDK동적 프록시를 인터페이스로 캐스팅 하면 성공한다. 왜냐하면 프록시 자체가 타겟 클래스가 구현한 같은 인터페이스를 기반으로 만들어지기 때문이다. 

 

그러나 구체 클래스로 캐스팅하면 ClassCastException이 난다. 왜나하면 프록시 입장에서는 인터페이스만 알지 그 인터페이스를 구현한 다른 클래스의 존재자체도 모르기 때문이다. 프록시 입장에서는 구체클래스를 전혀 알 수 없기 때문이다. 

 

 

@Test
void cglibProxy() {
    MemberServiceImpl target = new MemberServiceImpl();//구체 클래스  + 인터페이스 구현
    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.setProxyTargetClass(true);    //CGLIB 프록시

    //프록시를 인터페이스로 캐스팅 성공
    MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();

    //CGLIB 프록시를 구현 클래스로 캐스팅 시도 성공
    MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;


}

 

이번에는 CGLIB 프록시를 만들어서 테스트 해보았다.

MemberServiceImpl은 MemberService를 구현했기 때문에 당연히 MemberServiceImpl기반으로 만들어진 프록시도 MemberService인터페이스로 캐스팅 된다. 

 

그렇다면 JDK프록시에서 실패했던 구현 클래스로 캐스팅을 하면 어떻게 될까?

프록시 자체가 구체 클래스 기반으로 만들어졌기 때문에 당연히 성공한다. 

 

 

프록시 기술과 한계 - 의존관계 주입

 

 

먼저 프록시 생성을 위해 Aspect를 하나 만들자

@Slf4j
@Aspect
public class ProxyDIAspect {

    @Before("execution(* hello.aop..*.*(..))")
    public void doTrace(JoinPoint joinPoint) {
        log.info("[proxyDIAdvice] {}", joinPoint.getSignature());
    }
}

 

@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"})   //JDK 동적 프록시
@Import(ProxyDIAspect.class)
public class ProxyDITest {

    @Autowired
    MemberService memberService;

    @Autowired
    MemberServiceImpl memberServiceImpl;

    @Test
    void go() {
        log.info("memberService class={}", memberService.getClass());
        log.info("memberServiceImpl class={}", memberServiceImpl.getClass());
        memberServiceImpl.hello("hello");
    }

}

 

이 테스트 코드는 오류가 발생한다. 왜냐하면 memberService와 memberServiceImpl을 살펴보자.

프로퍼티스를 false로 해놨기 때문에 JDK프록시가 적용되는데

그렇다면 memberService와 memberServiceImpl에 주입되는것은 프록시 객체이다.

 

정확히 말하자면 memberService에는 문제가 없지만 memberServiceImpl에는 문제가 있다. 

memberServiceImpl에는 실제 클래스가 아니라 프록시가 주입되기 때문에 문제가 된다. JDK프록시는 인터페이스 타입만 알지 구현클래스 타입을 전혀 모르기때문에 캐스팅과 마찬가지로 해당 타입에 주입이 불가능하다.

 

 

그렇다면 CGLIB프록시에 구체 클래스 타입을 주입해보자

@SpringBootTest(properties = {"spring.aop.proxy-target-class=true"}) //CGLIB 프록시, 성공

설정을 true로 변경하자

 

MeberServiceImpl은 CGLIB에서는 의존관계 주입이 된다.

또한 MemberService도 당연히 (프록시 자체가 부모타입을 알기 때문에) 의존 관계 주입이 된다. 

 

 

사실 이렇게 보면 CGLIB가 좋아보이지만 DI를 생각해보자.

DI의 장점은 구체화에 의존하지 않고 추상화에 의존하여 구체 클래스를 변경할때 클라이언트 코드를 변경하지 않아도 되는 점이다. CGLIB의 단점을 살펴보자.

 

 

프록시 기술과 한계 - CGLIB

 

스프링에서 CGLIB는 구체 클래스를 상속 받아서 AOP프록시를 생성할 때 사용한다. 

그렇기 때문에 다음과 같은 문제점이 발생한다. 

 

  • 대상 클래스에 기본 생성자가 필수 : 자바에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다. CGLIB가 만드는 프록시 생성자는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출한다. 그러므로 대상 클래스에 기본 생성자를 만들어야 한다. (생성자가 하나도 없으면 자동으로 만들어짐)
  • 생성자 2번 호출 : CGLIB는 구체 클래스를 상속받는데 한번은 프록시 객체를 생성할 때 부모 클래스의 생성자를 호출하고 한번은 실제 target객체를 생성할때 이다. 
  • fianl키워드 클래스, 메서드 사용 불가 : final키워드가 클래스에 있으면 상속이 불가능하며 메서드에 있으면 오버라이딩이 불가능하다. CGLIB는 상속 기반이므로 final키워드가 있으면 프록시가 생성되지 않거나 정상 동작하지 않는다. 

 

 

프록시 기술과 한계 - 스프링의 해결책

 

CGLIB를 사용하려면 라이브러리가 별도로 필요했는데 스프링 내부에 함께 패키징 해서 별드의 라이브러리 추가 없이 CGLIB를 사용할 수 있게 되었다. 스프링 부트 2.0부터 2번 호출 문제가 해결되었다.  이제 생성자가 1번만 호출된다. 그리고 2.0부터 CGLIB를 기본으로 사용하도록 했다. 스프링 부트는 별도의 설정이 없으면 AOP를 사용할 때 proxyTargetClass=true를 설정해서 사용한다. 그래서 인터페이스가 있어도 JDK프록시가 아니라 CGLIB기반으로 프록시를 생성한다. 

 

이제 CGLIB가 기본으로 사용되기 때문에 JDK프록시에서 문제가 되었던 구체 클래스 주입이 가능하다. 물론 JDK동적 프록시를 사용하고 싶다면 설정을 통해 사용할 수 있다. final키워드의 문제는 AOP를 사용할때 사실 final을 잘 사용하지 않으므로 딱히 문제가 되지 않는다. 

 

 

'spring' 카테고리의 다른 글

cors와 spring security filter chain  (0) 2022.05.04
인증과 인가, spring security[Session], jwt[Token]  (0) 2022.04.15
빈 후처리기  (0) 2022.03.10
파일 업로드  (0) 2022.02.19
스프링 타입 컨버터  (0) 2022.02.16

블로그의 정보

kiwi

키위먹고싶다

활동하기