공통 로직과 서비스 로직 분리 해보기. #3 스프링 AOP, AspectJ 문법

BaekGwa·2024년 9월 9일
0

Spring

목록 보기
6/9

공통 로직과 서비스 로직

이전까지, 빈 후처리기와 @Aspect Annotation을 사용해서 로직을 분기 처리하고, 자동 생성, 수정 되도록 만들었습니다.
이전 글

  • 이번에는, 스프링에서의 AOP 개념에 대해서 알아보고, 적용해보독 하겠습니다.

AOP?

  • AOP는 Aspect Oriented Programming, 한글로 해석하면 관점 지향 프로그래밍 입니다.
  • 즉, 애플리케이션을 바라보는 관점을 하나하나의 기능에서, 관심사 관점으로 보는 것 입니다.
  • 사실, 이전에 적용해본 @Aspect가 이러한 기능을 제공하는 Annotation 입니다.
  • 또한, 이전에 계속 만들어본, LoggerAdvice, LoggerAdviceAspect 또한, 기능(Advice), 적용대상(Pointcut)을 포함하고 있어, 하나의 Aspect 라고 볼 수 있습니다.
  • 또한, AOP는, OOP를 대체하기 위한 것이 아닌, OOP의 부족한 부분을 보조하는 목적으로 개발 되었습니다.
  • Spring 에서는 AOP를 구현하기 위한 프레임워크로 AspectJ를 차용해서 일부 적용 하였습니다.

AspectJ 프레임워크

  • Spring에서 채용한 AspectJ 프레임워크는, 다음과 같은 특징을 가집니다.
  • 자바 프로그래밍 언어에 대한 완벽한 관점 지향 확장.
  • 횡단 관심사의 깔끔한 모듈화
    • 오류 검사 및 처리
    • 동기화
    • 성능 최적화(캐싱)
    • 모니터링 및 로깅

AOP의 적용 방법

  • AOP를 적용하기 위한 방법에는 3가지가 있습니다.
    1. 컴파일 시점
    2. 클래스 로딩 시점
    3. 런타임 시점(프록시) (핵심 키워드)
  • 각각에 대해서 가볍게 집고 넘어가도록 하겠습니다.

컴파일 시점

  • 말 그대로, 컴파일 시점에 부가 기능 로직을 적용대상을 참조하여 추가합니다.
  • 이말은, .java 소스 코드를 .class 파일로 만드는 컴파일 과정에서, 컴파일러가 기능 로직을 추가하는 것 입니다.
    • 실제로, 컴파일 시점에 AOP를 적용한 .class디컴파일해보면, 기능 코드가 실제 코드에 추가되는 모습을 볼 수 있습니다.
  • 이렇게 원본 로직에 부가 기능 로직이 추가되는 것을 위빙 (Weaving) 이라고 합니다.

단점으로는, 부가 기능을 적용하려면, 특별한 컴파일러도 필요하고 복잡합니다.


클래스 로딩 시점.

  • 컴파일 된 후, java를 실행하면 JVM 내부에서 클래스 로더가 .class파일을 목적에 따라 메모리에 업로드 하게 됩니다.
  • 이때, Aspect를 참조하여 JVM에 올릴 때, .class를 조작해서 업로드하게 됩니다.
  • 이 또한, 위빙을 적용 한 것 입니다.

마찬가지로 단점으로는, 자바를 실행할 때, 특별한 옵션을 적용해서 클래스 로더 조작기를 지정해야 합니다. 이는 복잡하고 운영하기 어려운 부분이 있습니다.


런타임 시점

  • 사실 이전까지 적용해본 코드가 모두 런타임 시점이라고 볼 수 있습니다. 프록시 방식의 AOP 적용
  • 컴파일도 완료되었고, 클래스 로더에서 클래스 또한 다 메모리에 올라간 상태이기 때문에, 조작할 수 없습니다.
  • 대신, 스프링 컨테이너의 도움을 받고 프록시, DI, 빈 후처리기 같은 개념들을 총 집합해서 적용 합니다.

단점으로는, 일부 AOP 기능이 제약이 될 수 있습니다.


용어 정리

  • 조인 포인트 (Join point)
    • AOP를 적용할 수 있는 지점
    • 스프링 AOP는 프록시 방식을 사용하므로, 조인 포인트는 항상 메소드 실행 지점으로 제한됩니다.
  • 포인트 컷 (Pointcut)
    • 조인 포인트 중, 어드바이스가 적용 될 위치를 선별하는 기능
    • EX) AOP를 적용할 수 있는 조인 포인트 중, 포인트 컷을 통해 AOP를 적용한다.
  • 타겟 (Target)
    • 부가 기능을 받을 객체.
    • 한개의 객체에 10개의 기능(메서드)가 있고, 그 중, 하나만 포인트 컷에 걸려도 그 객체는 타겟 이라고 판단 할 수 있습니다.
  • 어드바이스 (Advice)
    • 부가 기능 그 자체
    • Around, Before, After 와 같은 다양한 어드바이스가 있습니다.
  • 애스팩트 (Aspect)
    • 어드바이스 + 포인트 컷을 모듈화 한 것
    • @Aspect
    • 여러 어드바이스와 포인트 컷이 함께 존재
  • 어드바이저 (Advisor)
    • 하나의 어드바이스와 하나의 포인트 컷으로 구성
  • 위빙
    • 포인트 컷으로 결정한 타겟의 조인 포인트에 어드 바이스르 적용하는 것

스프링 AOP 구현

  • 사실 이전까지 알아본 @Aspect만 잘 사용하여도, 스프링 AOP를 구현하는데 있어서는 문제가 없습니다.
  • 단, AspectJ의 문법에 대해서 알고, 다양한 어드바이스를 적용에 대해서 알아보고 적재 적소에 사용할 수 있으면 충분합니다.
  • 하나의 예제 코드에 다양한 요구사항을 적용해보며, 스프링 AOP를 구현해보도록 하겠습니다.

예제 코드

예제에 사용한 코드 : Github

  • 가상의 Cart 서비스 입니다.
  • post method로, /items url 요청을 하면, body에 값을 전달하여 인메모리에 저장한다.
  • loadAllCart()를 실행하면, 가지고 있는 모든 아이템을 반환한다.

요구사항 1)

  • cart 도메인에 해당하는 controller, respotiroy, service 로직은 모두 실행 전 후로 로그를 남긴다.
  • 기존 코드는 로깅 기능이 추가되어도 변경이 없어야 한다.

코딩

예제에 사용한 코드 : Github
baekgwa.springaop.global.aop.AspectV1 참조

  • 기존 코드는 변경이 없어야 하니, 프록시를 고려해보며, 기왕이면 @Aspect를 한번 사용해 보겠습니다.
  • 메서드 또한, 제한할 필요가 없고, 패키징에 맞게만 pointcut을 설정하면 될 것 같습니다.
@Aspect
@Slf4j
public class AspectV1 {

    @Around("execution(* baekgwa.springaop.web..*(..))")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[START] : {}", joinPoint.getSignature());
        Object result = joinPoint.proceed();
        log.info("[E N D] : {}", joinPoint.getSignature());

        return result;
    }
}
  • 다음과같이 @Aspect를 클래스에 추가해주고, @Around를 통해 pointcut을 선정해 줍니다.
  • 만들어진 메서드의 파라미터는 ProceedingJoinPoint Interface를 받아와야 합니다.
  • joinPoint로부터 Signature(), 메타데이터 정보 등등을 받을 수 있고, proceed()를 통해 타겟 클래스의 타켓 매서드를 실행할 수 있습니다.

결과 확인해보기

  • 요구사항에 모두 만족하였습니다.
  • 추후에 메서드가 추가되어도 같은 패키지면 해당 로그기능이 문제없이 동작 할것입니다.

추가사항

  • 다음과 같이 @Around에 포인트컷을 지정해도 되지만, 포인트 컷을 분리해서 따로 사용할 수도 있습니다.
  • 내부 메서드를 사용해서 만들어도 되고, 외부에서 만들어서 경로를 넣어줘도 됩니다.
    • 재활용성을 극대화 시켜, 모듈화를 이뤄낼 수 있습니다.
public class WebPointcut {
    @Pointcut("execution(* baekgwa.springaop.web..*(..))")
    public void allMethod() {}
}
@Aspect
@Slf4j
public class AspectV1 {

    //case1) 직접 pointcut 작성하여 적용
//    @Around("execution(* baekgwa.springaop.web..*(..))")
    //case2) 외부에서 pointcut 만들어서 주입. 재활용 가능한 장점이 있음. (모듈화)
    @Around("baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[START] : {}", joinPoint.getSignature());
        Object result = joinPoint.proceed();
        log.info("[E N D] : {}", joinPoint.getSignature());

        return result;
    }
}
  • 단, 사용할때, 접근제한자를 외부/내부에 맞게 public/private 설정을 잘 해줘야하고, 반환 타입은 반드시 void 타입으로 반환해야 합니다.
  • 다양한 Aspect를 운영하게 될 때엔, 중복되는 pointcut이 나올 경우, 해당 방법을 토앻 모듈화를 시키는 것이 유지보수 측면에서 큰 도움이 될 듯 합니다.

요구사항 2)

  • 기존
    • cart 도메인에 해당하는 controller, respotiroy, service 로직은 모두 실행 전 후로 로그를 남긴다.
    • 기존 코드는 로깅 기능이 추가되어도 변경이 없어야 한다.
  • 추가 사항
    • Service 로직에는 트랜젝션 관리를 진행해 주세요.
      • 트랜잭션 정책은 아직 정해지지 않았으니, 간단하게 로그 처리만 우선 해주세요.

코딩

예제에 사용한 코드 : Github
baekgwa.springaop.global.aop.AspectV2 참조

  • 기존 요구사항은 그대론데, 새로운 어드바이스를 적용해야 할 일이 생겼습니다.
  • 트랜잭션 관리는, 트랜잭션시작, 커밋, 롤백등이 포함되어야 하는데, 내부 정책이 정해지지 않았으니 우선 로그로 잘 동작하는지 확인해보겠습니다.

  • 다음과 같이, 새로운 Pointcut을 하나 만들어 주도록 하겠습니다.
    • Pointcut을 위한 AspectJ문법은 뒤에서 자세히 다루겠습니다.
  • 해당 메서드의 이름은, ~~web 패키징에 포함된 모든 클래스 중, Service가 포함된 클래스의 모든 메서드에 적용한다 라는 뜻 입니다.
public class WebPointcut {
    @Pointcut("execution(* baekgwa.springaop.web..*(..))")
    public void allMethod() {}

    @Pointcut("execution(* baekgwa.springaop.web..*Service*.*(..))")
    public void transactionOnlyService() {}
}

  • 그리고 다음과 같이, 트랜잭션의 목적에 맞게 commit, rollback, 리소스 정리 코드를 작성해 줍니다.
  • 그리고, pointcut 조건은 && 연산으로 처리하여 둘다 참일때 실행되도록 하였습니다.
~~~기존코드~~~
@Around("baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod() &&"
            + "baekgwa.springaop.global.aop.pointcuts.WebPointcut.transactionOnlyService()")
    public Object transaction(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            log.info("[Transaction Start] : {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[Transaction Commit] : {}", joinPoint.getSignature());
            return result;
        } catch (IllegalStateException e) {
            log.info("[Transaction Rollback] : {}", joinPoint.getSignature());
            throw e;
        } finally {
            log.info("[Transaction Resource Clear] : {}", joinPoint.getSignature());
        }
    }
}

결과 확인해보기

  • 짝짝짝!
  • 모든 요구사항 조건이 완벽하게 수행되었습니다.

요구사항 3)

  • 기존
    • cart 도메인에 해당하는 controller, respotiroy, service 로직은 모두 실행 전 후로 로그를 남긴다.
    • 기존 코드는 로깅 기능이 추가되어도 변경이 없어야 한다.
    • Service 로직에는 트랜젝션 관리를 진행해 주세요.
  • 추가 사항
    • 트랜잭션 관리는, 로그 기능이 포함되어서는 안됩니다! 수정 부탁드립니다!

코딩

예제에 사용한 코드 : Github
baekgwa.springaop.global.aop.AspectV3 참조

  • 마지막 성공한 로그를 확인 해 보니, 로그가 실행되고 트랜잭션 관리가 시작되었습니다.
  • 날카로운 지적을 받았습니다. 우선순위를 지정해서 문제를 회피해보도록 하겠습니다.

  • 우선순위를 지정하기 위해서는 @Order Annotation을 사용하면 됩니다!
  • 하지만, 아래처럼 메서드 단위에 지정하게되면 재대로 된 동작이 되지 않습니다.
@Aspect
@Slf4j
public class AspectV3 {

    @Order(1)
    @Around("baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        ~~~
    }

    @Order(2)
    @Around("baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod() &&"
            + "baekgwa.springaop.global.aop.pointcuts.WebPointcut.transactionOnlyService()")
    public Object transaction(ProceedingJoinPoint joinPoint) throws Throwable {
        ~~~
    }
}

  • 이유는, Order는, 클래스에 붙였을 때 유효하게 되어있기 때문입니다.
  • 따라서 이문제를 회피하기 위해서는 Aspect 별로 class를 따로 만들어서 관리해야 합니다.
  • 이번에는 Innner Class를 사용하여 이 문제를 회피하도록 하겠습니다.
public class AspectV3 {

    @Aspect
    @Slf4j
    @Order(1)
    public static class DoLog {
        @Around("baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod()")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[START] : {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[E N D] : {}", joinPoint.getSignature());

            return result;
        }
    }

    @Aspect
    @Slf4j
    @Order(2)
    public static class Transaction {

        @Around("baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod() &&"
                + "baekgwa.springaop.global.aop.pointcuts.WebPointcut.transactionOnlyService()")
        public Object transaction(ProceedingJoinPoint joinPoint) throws Throwable {
            try {
                log.info("[Transaction Start] : {}", joinPoint.getSignature());
                Object result = joinPoint.proceed();
                log.info("[Transaction Commit] : {}", joinPoint.getSignature());
                return result;
            } catch (IllegalStateException e) {
                log.info("[Transaction Rollback] : {}", joinPoint.getSignature());
                throw e;
            } finally {
                log.info("[Transaction Resource Clear] : {}", joinPoint.getSignature());
            }
        }
    }
}
  • Bean 등록또한, 두개 다 따로 해줘야 합니다
@SpringBootApplication
public class SpringAopApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringAopApplication.class, args);
    }
    ~~기존 코드~~
    @Bean
    public AspectV3.DoLog doLog() {
        return new AspectV3.DoLog();
    }
    @Bean
    public AspectV3.Transaction transaction() {
        return new AspectV3.Transaction();
    }
}

결과 확인해보기

Complete!

고생하셨습니다! 모든 요구사항을 통과한 아름다운 AOP 기술을 적용 하였습니다.

추가적인 궁금증

  • 그런데, 만약 이렇게 중간에 Target 실행이 중간에 들어가지 않고 마지막에 들어가거나, 처음에만 들어가도 되면 어떻게 될까요? 그때도 @Around 를 통해 구현을 해야될까요?
  • 예를들어, 시작할때만 로그를 남기는 AOP를 적용하려고 하면 어떻게 하는게 좋을까요?
  • Spring 은 개발자가 원하는 모든것을 준비해두었습니다.

다양한 Advice

  • 맨처음 살짝 언급 하였듯이 다양한 Advice가 존재 합니다. 나열해보겟습니다.
  • @Around : 타겟 호출 전, 후, 조인 포인트 실행 여부, 반환 값 반환, 예외 처리 등 모든것이 가능
  • @Before : 조인 포인트 실행 전
  • @AfterReturning : 조인 포인트 실행 후, 정상 완료일 시
  • @AfterThrowing : 조인 포인트 실행 후, 예외 발생 시
  • @After : 조인 포인트 실행 후. @AfterReturning + @AfterThrowing

사용해보기

  • 앞서 사용한 AspectV3.Transaction 를 각각에 Advice로 구분해서 사용해보도록 하겠습니다.
  • 먼저, AspectV3.Transaction의 코드를 각각의 영역으로 나누어 주석처리를 통해 확인해 보겠습니다.
@Aspect
    @Slf4j
    @Order(2)
    public static class Transaction {

        @Around("baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod() &&"
                + "baekgwa.springaop.global.aop.pointcuts.WebPointcut.transactionOnlyService()")
        public Object transaction(ProceedingJoinPoint joinPoint) throws Throwable {
            try {
                log.info("[Transaction Start] : {}", joinPoint.getSignature()); //@Before()
                Object result = joinPoint.proceed(); //JoinPoint
                log.info("[Transaction Commit] : {}", joinPoint.getSignature()); //@AfterReturning()
                return result;
            } catch (IllegalStateException e) {
                log.info("[Transaction Rollback] : {}", joinPoint.getSignature()); //@AfterThrowing()
                throw e;
            } finally {
                log.info("[Transaction Resource Clear] : {}", joinPoint.getSignature()); //@After()
            }
        }
    }
  • 이를 각각의 역할에 맞게 Advice를 구분하여 처리해보도록 하겠습니다.
public class AspectV4 {

    @Aspect
    @Slf4j
    @Order(2)
    public static class Transaction {

        @Before("baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod() &&"
                + "baekgwa.springaop.global.aop.pointcuts.WebPointcut.transactionOnlyService()")
        public void doTransactionBefore(JoinPoint joinPoint){
            log.info("[Transaction Start] : {}", joinPoint.getSignature());
        }

        @AfterReturning(value = "baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod() &&"
                + "baekgwa.springaop.global.aop.pointcuts.WebPointcut.transactionOnlyService()", returning = "result")
        public void doTransactionAfterReturning(JoinPoint joinPoint, Object result){
            log.info("[Transaction Commit] : {}", joinPoint.getSignature()); //@AfterReturning()
            log.info("[AfterReturning Result] = {}", result);
        }

        @AfterThrowing(value = "baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod() &&"
                + "baekgwa.springaop.global.aop.pointcuts.WebPointcut.transactionOnlyService()", throwing = "ex")
        public void doTransactionAfterThrowing(JoinPoint joinPoint, Exception ex){
            log.info("[Transaction Rollback] : {}", joinPoint.getSignature());
            log.info("[AfterThrowing Result] = {}", ex.getMessage());
        }

        @After("baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod() &&"
                + "baekgwa.springaop.global.aop.pointcuts.WebPointcut.transactionOnlyService()")
        public void doTransactionAfter(JoinPoint joinPoint) {
            log.info("[Transaction Resource Clear] : {}", joinPoint.getSignature());
        }
    }
}

결과 확인해보기


Advice 별 구조 확인 해 보기

  • 다음과 같은 사진대로 각각의 Advice는 진행 됩니다.

Around 쓰면 되는거 아니야?

  • 맞습니다. @Around를 사용하면 모든 처리가 가능합니다. 그것도 하나로 간편하게 처리가 가능합니다.
  • 그럼에도, 다양한 어드바이스가 존재하는 이유는 무엇일까요?
  • 만약 아래의 코드같이 사용한 Aspect가 있습니다. 문제점은 무었일까요?
@Around("baekgwa.springaop.global.aop.pointcuts.WebPointcut.allMethod() &&"
                + "baekgwa.springaop.global.aop.pointcuts.WebPointcut.transactionOnlyService()")
        public Object transaction(ProceedingJoinPoint joinPoint) throws Throwable {
                log.info("[Transaction Start] : {}", joinPoint.getSignature());
            }
        }
    }
  • 문제점은, 간단합니다. Target을 호출하지 않았습니다.
    • 프록시 객체가 생성되었는데, 추가 기능만 실행하고 실제 로직은 실행하지 않는다면 큰 문제가 발생하게 되겠죠?
  • 해당 코드는 간단하게 @Before로 대체하여 사용하게 될 경우, 문제없이 작동됩니다.
  • 이와같이, @Around는 실수에 따라 큰 버그가 발생할 위험이 있습니다.
  • 또한, 각 Annotation 에 따라, 코드를 해석하기에 편해집니다.
    • 코드를 읽기 편해진다면, 유지보수성이 조금이라도 올라갑니다.

To do...

  • 원래는, 이번에 최대한 마무리 짓고 싶었지만, AspectJ 문법을 넣기에는 글이 너무 길어질 것 같아 잘라서 올릴 예정입니다...
  • 사실 겉핥기 식으로 간단하게만 살펴보고 있는 과정인데도 꽤 정리할 양이 많네요..
  • 다음에는 다양한 AspectJ 문법을 알아보도록 하겠습니다.
    • 이를 통해 Annotation 을 통해 AOP를 적용하는 방법또한 적용하여 예제를 만들어 보도록 하겠습니다.
profile
현재 블로그 이전 중입니다. https://blog.baekgwa.site/

0개의 댓글