이 글은 간단한 구현 예제만을 포함하고 있습니다. AOP에 대한 이론적인 부분은 기존에 포스팅한

https://lob-dev.tistory.com/entry/Spring-AOP와-요청-인터셉트-개념

을 참고해주시길 바랍니다.

 


프로젝트 구성

Web과 Lombok을 추가합니다. (해당 예제에서는 Web 관련 로직을 사용하진 않고, 통합 테스트만 작성합니다. )

추가적으로

implementation 'org.springframework.boot:spring-boot-starter-aop'

의존성을 추가합니다.

 

우선 GreetingAspect를 통해서 Aspect를 적용할 수 있는 각 시점들을 확인해보겠습니다.

  • Spring AOP는 xml를 통한 기본적인 설정 방식과 AspectJ에서 제공하는 Annotation을 통해 적용할 수 있습니다.

 

 

GreetingService

해당 서비스는 메서드가 호출되었음을 알 수 있도록 간단하게 작성합니다.

  • AfterReturning과 AfterThrowing을 따로 호출하기 위해 bool 값을 통해 분기 처리합니다.
@Slf4j
@Service
public class GreetingService {

    public void greeting(boolean condition) {
        log.info("GreetingService.greeting");
        if(condition) {
            throw new RuntimeException();
        }
    }
}

개인적인 편의를 위해서 테스트 코드는 ApplicationTest class에서 작성합니다.

@SpringBootTest
class TestApplicationTest {

    //...
    
    @Test
    void greeting() {
        greetingService.greeting(false);
    }
}

이제 이 코드를 실행하면 GreetingService.greeting이 Console에 출력되는 것을 확인할 수 있습니다.

 

이제 GreetingAspect를 작성합니다.

 

 

GreetingAspect

@Around는 LoggingAspect를 작성해볼 때 사용해보는 것으로 하고 다른 Advice Annotation들만 사용합니다.

@Slf4j
@Aspect
@Component
public class GreetingAspect {

    @Before(value = "execution( public void aop.domain.GreetingService.*(*) )")
    public void beforeAdvice() {
        log.info("GreetingAspect.BeforeAdvice");
    }

    @After(value = "execution( public void aop.domain.GreetingService.*(*) )")
    public void afterAdvice() {
        log.info("GreetingAspect.AfterAdvice");
    }

    @AfterReturning(value = "execution( public void aop.domain.GreetingService.*(*) )")
    public void afterReturningAdvice() {
        log.info("GreetingAspect.AfterReturningAdvice");
    }

    @AfterThrowing(value = "execution( public void aop.domain.GreetingService.*(*) )", throwing = "exception")
    public void afterThrowingAdvice(RuntimeException exception) {
        log.info("GreetingAspect.AfterThrowingAdvice"+ exception.getClass());
    }
}

GreetingAspect 역시 앞선 서비스와 같이 log만 출력합니다. 이제 앞선 테스트를 다시 실행해보면

INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.BeforeAdvice
GreetingService.greeting
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterAdvice
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterReturningAdvice

이렇게 출력되는 것을 확인할 수 있습니다.

  • execution 내부에 정의된 내용은 public 접근 지시자를 가지고, Return 타입이 void이며, 하나의 인자를 가지는 GreetingService의 모든 메서드를 대상으로 한다는 의미입니다.
    • 해당 코드에서는 메서드 명을 작성하거나 GreetingService.* 등으로도 설정할 수 있습니다.

 

 

그럼 앞선 Service 메서드가 Exception을 던지도록 한 다음 AfterThrowing이 정상적으로 호출되는지 확인해보겠습니다. ApplicationTest에 하나의 Test 메서드를 추가합니다

@Test
void greeting_throw() {
    assertThrows(RuntimeException.class, () -> greetingService.greeting(true));
}
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.BeforeAdvice
GreetingService.greeting
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterAdvice
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterThrowingAdviceclass java.lang.RuntimeException

AfterReturning과 AfterThrowing은 각각 Aspect target이 호출된 후 정상, 비정상(exception) 여부에 따라 동작하는 Advice입니다.

 

이제 @Around advice를 사용하여 위에서 작성한 greeting 메서드의 실행 시간에 관한 로깅을 남겨보도록 하겠습니다.

 

 

LoggingAspect

LoggingAsepct는 execution 표현식을 사용하지 않고 Annotation 방식으로 사용할 생각이므로, 우선 Logging이라는 Annotation Interface를 정의합니다.

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

이는 point cut 표현식 중 @annotation()을 통해 지정할 수 있습니다.

@Slf4j
@Aspect
@Component
public class LoggingAspect {

    @Around(
            value = "@annotation(aop.annotation.Logging)",
            argNames = "joinPoint"
    )
    public Object logging(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("LoggingAspect.logging");

        final Signature signature = joinPoint.getSignature();
        final String className = signature.getDeclaringTypeName();

        final StopWatch watch = new StopWatch();
        watch.start();
        Object result = joinPoint.proceed();
        watch.stop();

        log.info("logging : component = {} : execution time(ms) = {}", className, watch.getTotalTimeMillis());
        return result;
    }
}

joinPoint를 통해 Signature interface를 가져오면 이를 통해 Aspect target 메서드의 여러 정보들을 가져올 수 있습니다. 그리고 Spring Utils에서 제공하는 StopWatch를 통해 시간을 측정할 수 있습니다.

  • joinPoint.proceed()을 통해 Aspect target의 메서드를 호출한 다음 return 값이 넘어왔을 때 StopWatch를 정지시켜 처리에 걸린 시간을 측정합니다.

이제 Logging과 권한 검사를 테스트할 도메인 모델인 Account와 AccountService를 작성합니다.

 

 

Account

@Getter
@ToString
public class Account {
    private final String username;
    private final String password;
    private final String email;
    private final String role;

    public Account(String username, String password, String email, String role) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
    }
}

Account는 간단한 도메인 모델이므로 별도의 설명을 달지는 않겠습니다.

 

 

AccountService

@Slf4j
@Service
public class AccountService {

    @Logging
    public void userRoleCheck(Account account) {
        log.info("{}", account.toString());
    }

    public void adminRoleCheck(Account account) {
        log.info("{}", account.toString());
    }
}

단순한 도메인 모델인 Account를 구현하였고, Service 로직은 인자로 넘어온 account를 로깅하는 로직만 작성하였습니다.

 

이제 테스트 메서드를 추가하고 실행해봅니다.

@Logging
public void userRoleCheck(Account account) {
    log.info("{}", account.toString());
}

------

@Test
void account() {
    final Account account = new Account("lob", "password", "coffeescript@kakao.com", "USER");
    accountService.userRoleCheck(account);
}
INFO 37028 --- [main] aop.domain.AccountService : Account{username='lob', password='password', email='coffeescript@kakao.com', role='USER'}
INFO 37028 --- [main] aop.aspect.LoggingAspect  : logging : component = aop.domain.AccountService : execution time(ms) = 19

정상적으로 결과가 나오는 것을 확인할 수 있습니다.

 

이제 마지막으로 RoleCheck Annotation과 이를 통해 부여된 Role을 기준으로 exception을 발생시키는 Aspect를 작성해보겠습니다.

 

 

RoleCheck

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RoleCheck {
    String value() default "ADMIN";
}

RoleCheck 어노테이션에는 하나의 String Field를 정의하여 값을 지정할 수 있도록 정의합니다.

 

 

RoleCheckAspect

RoleCheckAspect는 JoinPoint를 통해 메서드 인자를 가져오고, Annotation 정보와 비교하여 권한 검사를 진행합니다.

@Slf4j
@Aspect
@Component
public class RoleCheckAspect {

    @Before(value = "@annotation(aop.annotation.RoleCheck)")
    public void roleCheckByExecution(JoinPoint joinPoint) {
        log.info("RoleCheckAspect.roleCheckByExecution");

        final MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        final Method method = signature.getMethod();

        final RoleCheck annotation = method.getAnnotation(RoleCheck.class);
        final String methodRole = annotation.value();
        log.info("method role = {}", methodRole);

        final Account account = (Account) joinPoint.getArgs()[0];
        final String accountRole = account.getRole();
        log.info("account role = {}", accountRole);

        if (methodRole.equals(accountRole)) {
            log.info("request success : method = {} : username = {} : email = {} : role = {}",
                    method.getName(), account.getUsername(), account.getEmail(), accountRole);
            return;
        }

        log.info("request failed : method = {} : username = {} : email = {} : role = {}",
                method.getName(), account.getUsername(), account.getEmail(), accountRole);
        throw new RuntimeException("권한 부족");
    }
}

이제 AccountService 메서드에 @RoleCheck Annotation을 붙이고 테스트를 다시 실행합니다.

@Logging
@RoleCheck(value = "USER")
public void userRoleCheck(Account account) {
    log.info("{}", account.toString());
}

------

INFO 37028 --- [main] aop.aspect.RoleCheckAspect : method role = USER
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : account role = USER
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : request success : method = userRoleCheck : username = lob : email = coffeescript@kakao.com : role = USER
INFO 37028 --- [main] aop.domain.AccountService  : Account{username='lob', password='password', email='coffeescript@kakao.com', role='USER'}
INFO 37028 --- [main] aop.aspect.LoggingAspect   : logging : component = aop.domain.AccountService : execution time(ms) = 19

Aspect가 정상 동작하는 것을 확인할 수 있습니다.

 

이제 반대의 경우를 간단히 테스트해보겠습니다.

@RoleCheck
public void adminRoleCheck(Account account) {
    log.info("{}", account.toString());
}

------

@Test
void account_throw() {
    final Account account = new Account("lob", "password", "coffeescript@kakao.com", "USER");
    assertThrows(RuntimeException.class, () -> accountService.adminRoleCheck(account));
}
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : method role = ADMIN
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : account role = USER
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : request failed : method = adminRoleCheck : username = lob : email = coffeescript@kakao.com : role = USER

AccountService의 메서드는 호출되지 않고 Exception이 발생하였음을 확인할 수 있습니다.

 

 

이론을 제외하고 단순한 구현 코드만을 작성하다보니 코드 블럭과 "~다"만 가득한 글이 만들어졌네요.. ㅋㅋ;

해당 글과 관련하여 궁금하신 부분은 댓글을 통해 문의해주시길 바랍니다.

 

 

추천, 참고 링크

 

 

 

이 글은 특정 구현에 종속되는 내용을 제외한 이론 위주의 정리 글입니다.

 

AOP (Aspect-Oriented Programming) 란?


AOP는 프로그래밍 개발 사상 중 하나이며, Spring Framework을 구현한 주요한 개념입니다.

 

저는 이 개념을 애플리케이션 내부의 컴포넌트들에 존재하는 비즈니스 로직이 아닌 보일러 템플릿 코드들을 (저는 개인적으로 인프라 로직이라고 명명하여 부릅니다.) 모아 응집시켜 각각의 컴포넌트로 분리하는 것으로 이해하고 있으며, 개발자들은 이를 활용하여 애플리케이션 서비스에는 비즈니스 로직들만을 남겨 개발 유지보수 경험을 향상시킵니다.

 

 

인프라 로직?


Application 전 영역에서 나타날 수 있는, 중복될 수 있으며 비즈니스가 아닌 로직을 의미합니다.

  • 성능 검사, Flag 처리(활성화, 비활성화)
  • 로거 - 로깅
  • 알림
  • 예외처리
  • 인증 - 인가
  • 트랜잭션 처리
  • 의존성 주입

등 실제 도메인에서 필요한 비즈니스 로직이 아닌 것들을 의미합니다.

 

AOP는 OOP를 대체하는 것이 아니라 보완하는 성격의 프로그래밍 사상입니다. OOP는 클래스를 이용하여 역할에 맞게 로직을 응집하고, 가시하게끔 하는 것이고 AOP는 그런 클래스들을 바라보는 거시적인 시점에서 좀 더 역할에 맞게끔 로직들을 분리해냄으로써 컴포넌트의 결합성을 떨어트리고 재사용 가능케하는 것이기 때문입니다.

 

즉 "AOP와 OOP 중 무엇이 좋냐" 라고 비교하는 것은 잘못된 질문이라고 생각합니다.

 

이러한 AOP는 방문자, 데코레이터, 프록시 패턴 등을 통해 적용할 수 있습니다.

 

 

AOP의 개념?


  • Aspect : 비즈니스 로직을 제외한 부가 기능에 대한 코드들을 응집시켜 컴포넌트로 만든 것입니다.
  • Target : Aspect를 적용할 대상을 의미합니다. (Class, Method)
  • Advice : 어느 시점에 Aspect를 적용할지 결정하는 것을 의미합니다.
  • JoinPoint : Advice가 적용될 수 있는 위치들, 즉 Method 진입 지점, 생성자 호출 시점, 객체 동작 시점이나 필드에서 값을 꺼낼 때 등 적용 가능한 다양한 상황을 의미합니다.
  • PointCut : 실제 Advice가 적용될 지점을 설정합니다.

 

 

 

Spring AOP?


Spring AOP는 Spring에서 기본적으로 사용할 수 있는 Dynamic Proxy 기반의 AOP 구현체입니다.

  • JDK Dynamic proxy, CGLIB API 통해 동작합니다.
  • Spring Container에 등록되는 Bean들에만 적용 가능합니다.

해당 라이브러리의 목적은 모든 AOP 스펙을 제공하기보다는 기능을 간편하게 적용하면서 메서드 래밸의 중복 코드의 제거와 객체 간의 강결합을 해결하기 위함입니다.

 

 

DK Dynamic Proxy와 CGLIB가 사용되는 시점?


JDK Dynamic Proxy

대상 객체가 최소 하나의 인터페이스를 구현하였을 경우 사용합니다.

 

JDK Dynamic Proxy의 문제점

Advise 대상이든 아니든 모든 Method Call 마다 reflection API의 invoke를 진행하게 됩니다.

  • 즉 invoke를 우선 진행하고 Advise 유무를 판단합니다.

 

CGLIB

대상 객체가 인터페이스를 가지지 않았을 경우 사용합니다.

  • 인터페이스를 가져도 사용할 수는 있습니다 aop:config의 proxy-target-class를 true로 설정하면 됩니다.
  • 대상 객체가 정의한 모든 메서드를 프록시 하여야하는 경우 사용합니다. 하지만 final 지시자는 Override 할 수 없으므로 Advice 할 수 없습니다.

CGLIB의 문제점

  • 성능면에서 JDK 에 비해 우수하나 final method, class 은 Advice 할 수 없습니다.
  • 버전 별로 API가 급변함으로 호환성이 좋지 않습니다. 그렇기에 하이버네이트와 같은 프레임워크들은 특정 버전을 내장하여 개발됩니다.

 

AOP Weaving


Compile-Time Weaving : AspectJ
컴파일 시에 소스코드를 받아 바이트코드 변환할 때 Aspect를 적용합니다. ( .java → .class )

  • 기존 Java Compiler를 확장한 AspectJ Compiler 라는 것을 사용하게 됩니다.
  • 컴파일 시에 바이트 코드 조작을 통해 구현부에 코드를 직접 삽입하여 위빙을 수행합니다.
  • 해당 방법의 경우 Lombok, MapStruct 과 같은 Compile 시 간섭하는 라이브러리와 충돌이 일어날 수 있다고 합니다.
  • 위빙 방식 중에서 제일 빠른 퍼포먼스를 보여줍니다.

 

Post-Compile Weaving (Binary Weaving) : AspectJ
이미 컴파일된 클래스 파일에 바이트코드를 삽입하여 Weaving을 적용하는 방식입니다. (.class → .jar)

 

 

Class-Load Time Weaving : AspectJ
Class Loader가 클래스를 로딩할 때 바이트코드를 삽입하여 Weaving 합니다. (객체가 메모리에 올라갈 때)

  • Spring Container 에 객체가 로드되기 전에, 객체 정보를 핸들링함으로 성능이 저하됩니다.
  • JVM에서 제공하는 agent를 통해서 기능을 지원받아 적용합니다.

 

Runtime Weaving : Spring AOP
실제 코드에 변형이 존재하지 않으며, 메서드 호출 시 프록시를 통해 이루어지는 방식입니다.

  • Spring Container에 객체가 로드될 때, ProxyPostProcessor와 ProxyFactoryBean을 통해 객체 정보를 생성하고 Bean으로 반환하여 컨택스트에 저장하게 됩니다. 즉 Spring Bean에게만 적용되는 것입니다.
  • 메서드 수준의 AOP 만을 지원합니다.
  • Point Cut에 대한 Advice수가 늘어날수록 성능이 떨어진다는 단점이 있습니다. (성능 퍼포먼스 상 8~35배 차이)

Spring AOP의 ProxyFactoryBean 은 설정 대상 객체의 Interface 유무에 따라 proxy를 자동 설정합니다. 있으면 JDK, 없으면 CGLIB입니다. (Boot 2.0 이후는 밑에 언급하였습니다.)

 

DefaultAdvisorAutoProxyCreator 후처리기가 추가되어 있는 경우에는 ProxyFactoryBean이 없더라도 프록시 설정을 적용할 수 있습니다. 이 빈은 어드바이저 정보를 통해 Bean을 프록시로 Wrapping 합니다.

 

 

Spring AOP와 AspectJ를 언제 사용하여야 할까?


Spring AOP

  • Spring Bean에서 메서드 실행만을 Advice하는 것이 AOP 요구사항의 전부라면 Spring AOP를 도입할만 합니다.
    • AspactJ 컴파일러나 위버 등 별도의 도입 요구사항이 존재하지 않습니다.

AspectJ

  • Spring Container에서 관리하지 않는 객체(도메인 객체 등)를 Advice 해야한다면, AspectJ를 도입하여야 합니다.
  • Self Invocation 시 @Transaction, @Caching 처리를 적용하기 위해서는 AspactJ 를 고려할만 합니다.
  • Public 이외의 메서드, 필드, 클래스 등에 Advice를 적용하고 싶은 경우 AspactJ를 고려할만 합니다.

 

Spring AOP - JDK Dynamic Proxy는 Target 메서드 호출마다 인터셉팅하는가?


Spring은 Bean을 등록할 때 Reader를 통해 읽어들여진 Bean Definition을 Parser로 해석하고 대해 PostProcessor를 통해 등록 Process가 진행되게 됩니다.

 

객체 정보에 선언적인 AOP와 Transaction 등이 적용되었다면, ProxyFactoryBean을 통해 Proxy 객체를 생성하고, 해당 객체를 ApplicationContext에 반환하게 됩니다. 그리고 business Logic에서 DI가 있어야 하였을 때, 해당 Proxy 객체를 Injection하여 Proxy를 통해 Logic을 실행하게 됩니다.

 

이러한 흐름을 가지기 때문에, 어플리케이션에서 Business Logic을 처리할 때 AOP가 적용된 모든 객체 호출은 Proxy를 통해 인터셉트되는 요청 흐름을 가지게 됩니다.

@Autowired
XxxService xxxService;

// 위의 로직은 ApplicationContext에서 발생하는 DL, DI 생략하면 밑의 코드와 같다고 볼 수 있습니다.
XxxService xxxService = (XxxService) Proxy.newProxyInstance(
        XxxService.class.getClassLoader(), new Class[]{XxxService.class},
            (InvocationHandler) (proxy, method, args) -> {
                XxxService xxxService = new DefaultXxxService();
                                Method targetMethod = null;

                                // Verification?
                                if (proxyMethodMap.containsKey(method)) {
                                        targetMethod = cacheMethodMap.get(method);
                                } else {
                                        Object invoke = method.invoke(xxxService, args);
                                        return invoke;
                              }

                // Before Proxy....

                                // Invoke
                Object invoke = targetMethod.invoke(xxxService, args);

                                // After Proxy....

                return invoke;
            });

 

추가적으로 Spring Boot 2.0 부터는 CGLIB 설정을 변경하여 기본적으로 강제하게 됩니다.  spring.aop.proxy-target-class=true 그러므로 인터페이스 유무와 상관없이 CGLIB가 사용됩니다. 

 

 

잘못된 내용은 댓글로 작성 부탁드립니다!

 

 

참고자료

+ Recent posts