Spring 핵심 - AOP 개념 및 적용하기

1. AOP란?


Spring의 핵심 개념 중 하나인 DI가 애플리케이션 모듈들 간의 결합도를 낮춘다면, AOP(Aspect-Oriented Programming)는 애플리케이션 전체에 걸쳐 사용되는 기능을 재사용하도록 지원하는 것 입니다. Aspect-Oriented Programming이란 단어를 번역하면 관점(관심) 지향 프로그래밍 이 됩니다. 프로젝트 구조를 바라보는 관점을 바꿔보자는 의미입니다.

그림1

각각의 Service의 핵심기능에서 바라보았을 때 User과 Order는 공통된 요소가 없습니다. 하지만 부가기능 관점에서 바라보면 이야기가 달라집니다.

그림2

부가기능 관점에서 바라보면 각각의 Service의 getXX 메서드를 호출하는 전후에 before과 after라는 메서드가 공통되는 것을 확인할 수 있습니다.
기존에 OOP에서 바라보던 관점을 다르게 하여 부가기능적인 측면에서 보았을때 공통된 요소를 추출하자는 것입니다. 이때 가로(횡단) 영역의 공통된 부분을 잘라냈다고 하여, AOP를 크로스 컷팅(Cross-Cutting) 이라고 부르기도 합니다.

  • OOP : 비즈니스 로직의 모듈화
    • 모듈화의 핵심 단위는 비즈니스 로직
  • AOP : 인프라 혹은 부가기능의 모듈화
    • 대표적인 예 : 로깅, 트랜잭션, 보안 등
    • 각각의 모듈들의 주 목적 외에 필요한 부가적인 기능들

간단하게 한줄로 AOP를 정리해보자면, AOP는 공통된 기능을 재사용하는 기법 입니다.
OOP에선 공통된 기능을 재사용하는 방법으로 상속이나 위임을 사용합니다. 하지만 전체 애플리케이션에서 여기저기 사용되는 부가기능들은 상속이나 위임으로 처리하기에는 깔끔한 모듈화가 어렵습니다. 그래서 등장한 것이 AOP입니다.
AOP의 장점은 다음과 같습니다.

  • 애플리케이션 전체에 흩어진 공통 기능이 하나의 장소에서 관리된다는 점
  • 다른 서비스 모듈들이 본인의 목적에만 충실하고 그외 사항들은 신경쓰지 않는다는 점


2. AOP 주요 개념


Target

부가기능을 부여할 대상(클래스)을 의미합니다.
위의 예시에서는 Service들을 의미합니다. 바로 아래서 설명하는 Aspect가 적용되는 대상을 의미합니다.

Aspect

객체지향 모듈을 오브젝트라 부르는 것과 비슷하게 부가기능 모듈을 Aspect라고 부르며, 핵심 기능에 부가되어 의미를 갖는 특별한 모듈 입니다.
Aspect 안에는 부가기능을 정의하는 Advice Advice를 어디에 적용할지 결정하는 PointCut이 있습니다.
참고로 AOP라는 뜻 자체가 애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 Aspect라는 모듈을 만들어 설계하고 개발하는 방법을 뜻합니다.

Advice

Aspect에서 실질적으로 어떤 일을 해야할지에 대한 부가기능을 담은 구현체를 의미합니다.
Advice는 Target Object에 종속되지 않기 때문에 순수하게 부가기능에만 집중할 수 있습니다. Advice는 Aspect가 ‘무엇’을 ‘언제’할지를 정의하고 있습니다.

JoinPoint

Advice가 적용될 위치를 의미합니다.
다른 AOP 프레임워크와 달리 Spring에서는 메서드 JoinPoint만 제공 하고 있습니다. 따라서 Spring 프레임워크에서 JoinPoint라면 Advice가 적용되는 메서드라고 생각하면 됩니다.
타 프레임워크에서는 예외 발생할 경우, 필드값이 수정될 경우 등도 지원하고 있습니다.

PointCut

부가기능이 적용될 대상(메서드)를 선정하는 방법을 의미합니다.
Advice를 적용할 JoinPoint를 선별하는 기능을 정의한 모듈입니다. JoinPoint의 상세한 기능을 정의하는 것이라고 생각하면 됩니다.

Proxy

Target을 감싸서 Target에 들어오는 요청을 대신 받아주는 Warpping 오브젝트입니다.
Client에서 Target을 호출하게 되면 Target이 아닌 Target을 감싸고 있는 Proxy가 호출되어, Target 메서드 실행 전에 전처리, Target 메서드 실행 후 후처리를 실행시킬 수 있도록 구성되어 있습니다.
AOP에서 Proxy는 호출을 가로챈 후, Advice에 등록된 기능을 수행 후 Target 메서드를 호출합니다.

Introduction

Target 클래스에 코드 변경없이 신규 메서드나 멤버변수를 추가하는 기능을 의미합니다.

Weaving

지정된 객체에 Aspect를 적용해서 새로운 Proxy 객체를 생성하는 과정을 의미합니다.
예를 들면 A라는 객체에 트랜잭션 Aspect가 지정되어 있다면, A라는 객체가 실행되기전 커넥션을 오픈하고 실행이 끝나면 커넥션을 종료하는 기능이 추가된 Proxy 객체가 생성됩니다. 이 Proxy 객체가 앞으로 A 객체가 호출되는 시점에서 사용됩니다. 이때의 프록시객체가 생성되는 과정을 Weaving이라 생각하시면 됩니다. Spring AOP는 런타임에서 프록시 객체가 생성 됩니다.


3. 사용법


@Aspect
@Component
public class TestAop {
    @Around("execution(* com.gjgs.modules.users.services.interfaces.UserService.getUser(..))")
    public void adviceMethod(ProceedingJoinPoint joinPoint){    
        Object result = null;
            try {
                System.out.println("AOP 시작");
                result = proceedingJoinPoint.proceed();
                System.out.println("AOP 종료");
            } catch (){
                ...
            }
    }
}

@Around는 Advice로서 Aspect가 ‘무엇을’, 언제’ 할지를 정의하는 부분에서 ‘언제’ 라는 시점을 정의한 것입니다. 아래와 같이 5가지 타입이 존재합니다.

  • @Before
    • Advice 타겟 메서드가 호출되기 전에 Advice 기능을 수행
  • @After
    • Target 메서드의 결과와 관계없이(성공,예외와 관계없이) Target 메서드가 완료되면 Advice 기능을 수행
  • @AfterReturning(정상적 반환 이후)
    • Target 메서드가 성공적으로 결과값을 반환 후에 Advice 기능을 수행
  • @AfterThrowing (예외 발생 이후)
    • Target 메서드가 수행 중 예외를 던지게 되면 Advice 기능을 수행
  • @Around(메서드 실행 전후)
    • Target 메서드를 감싸서 Target 메서드 호출 전과 호출 후에 Advice 기능을 수행
    • Around의 경우, 인자로 ProceedingJoinPoint를 받아서 proceed 메서드를 실행시켜야만 합니다. proceed는 실제 Target 메서드를 실행하는 것이라고 보면 되고 앞 뒤에 메서드 실행 전, 후에 진행할 작업을 코딩하시면 됩니다.


execution(* com.gjgs.modules.users.services.impl.UserService.getUser(..))

Advice의 Value로 들어간 문자열을 PointCut 표현식 이라고 합니다.
PointCut의 구성은 2가지로 분류되는데 execution을 지정자 라고 부르며 이외의 뒤쪽 부분은 Target 명세 라고 합니다.
지정자는 총 9가지 타입이 있습니다. 전부 다 쓰진 않고 자주 쓰는 것은 execution과 @annotation 정도이므로 이 2개는 아래서 자세히 설명하겠습니다.

  • args()
    • 메서드 인자가 Target 명세에 포함된 타입일 경우
    • ex) args(java.io.Serializable)
      • 하나의 파라미터를 갖고, 그 인자가 Serializable 타입인 모든 메서드
  • @args()
    • 메서드의 인자가 타겟 명세에 포함된 어노테이션 타입을 갖는 경우
    • ex) @args(com.blogcode.session.User)
      • 하나의 파라미터를 갖고, 그 인자의 타입이 @User 어노테이션을 갖는 모든 메소드 (@User User user 같이 인자 선언된 메소드)
  • within()
    • execution 지정자에서 클래스/인터페이스까지만 적용된 경우로 클래스 또는 인터페이스 단위까지만 범위 지정이 가능한 지정자
    • ex) within(com.test.ex.*)
      • com.test.ex 패키지 아래의 클래스와 인터페이스가 가진 모든 메서드에 적용
    • ex) within(com.test.ex..)
      • com.test.ex 패키지 아래의 모든 하위패키지까지 포함한 클래스와 인터페이스가 가진 메서드에 적용
    • ex) within(com.test.ex.Car)
      • com.test.ex.Car 클래스가 가진 모든 메서드에 적용
  • @within()
    • 주어진 어노테이션을 사용하는 타입(클래스)에 선언된 모든 메서드
    • ex) @within(Anno)
      • Anno 어노테이션을 갖는 타입(클래스) 안에 정의된 코드와 관련된 조인포인트
  • this()
    • Target 메서드가 지정된 빈 타입의 인스턴스인 경우
    • ex) this(com.xyz.service.AccountService)
      • AccountService 인터페이스를 구현하는 모든 구현체
  • target()
    • this와 유사하지만 빈 타입이 아닌 타입의 인스턴스인 경우
  • @target()
    • Target 메서드를 실행하는 객체의 클래스가 타겟 명세에 지정된 타입의 애노테이션이 있는 경우
  • execution()
    • 접근제한자, 리턴타입, 인자타입, 클래스/인터페이스, 메소드명, 파라미터타입, 예외타입 등을 전부 조합가능한 가장 세심한 지정자
    • ex) execution(* com.blogcode.service.AccountService.*(..)
      • AccountService 인터페이스의 모든 메소드
  • @annotation()
    • Target 메서드에 특정 애노테이션이 지정된 경우
    • @annotation(org.springframework.transaction.annotation.Transactional)
      • Transactional 애노테이션이 지정된 모든 메서드

이렇게 많은 종류가 있지만 실제로는 execution과 @annotation을 주로 사용한다고 합니다.
@annotation은 아래 적용하기에서 자세히 알아보고 여기서는 execution의 표현식에 대해서 조금 더 알아보겠습니다.

execution([수식어] 리턴타입 [클래스이름].메서드이름(파라미터))
  • 수식어
    • public, private 등 수식어를 명시하지만 스프링 AOP에서는 private만 가능 (생략 가능)
  • 리턴타입
    • 리턴 타입을 명시
  • 클래스이름 및 메서드이름
    • 클래스이름과 메서드 이름을 명시 (클래스 이름은 패키지명까지 함께 명시)
  • 파라미터
    • 메서드의 파라미터를 명시
  • 표현식
    • * : 모든 값을 표현
    • .. : 0개 이상을 의미
    • execution(void set*(..))
      • 리턴 타입이 void이고 메서드 이름이 set으로 시작하며, 파라미터가 0개 이상인 메서드
    • execution(* sp.aop.service..())
      • sp.aop.service 패키지의 파라미터가 없는 모든 메서드
    • execution(* sp.aop.service...(..))
      • sp.aop.service 패키지 및 하위 패키지에 있는 파라미터가 0개 이상인 모든 메서드
    • execution(* get())
      • get으로 시작하고 1개의 파라미터를 갖는 메서드
    • execution(Integer read*(Integer, ..))
      • 리던값이 Integer이고 메서드 이름이 read로 시작하며 첫번째 파라미터 타입이 Integer이고, 1개 이상의 파라미터를 갖는 메서드


4. 적용 연습하기


PointCut 변수 사용하기

@Around("execution(* com.gjgs.modules.users.services.interfaces.UserService.getUser(..))
execution(* com.gjgs.modules.orders.services.interfaces.OrderService.getOrder(..))")

PointCut 표현식에는 AND, OR, NOT과 같은 관계연산자를 이용할 수 있습니다. 하지만 위와 같이 표현식이 계속 추가된다면 가독성과 재사용성이 매우 떨어집니다. 따라서 표현식을 변수처럼 변경하여 수정해보겠습니다.

@Aspect
@Component
public class TestAop {

    @PointCut("execution(* com.gjgs.modules.users.services.interfaces.UserService.getUser(..))")
    public void getUser(){}

    @PointCut("execution(* com.gjgs.modules.orders.services.interfaces.OrderService.getOrder(..))")
    public void getOrder(){}

    @Around("getOrder() || getUser()")
    public void adviceMethod(ProceedingJoinPoint joinPoint){    
        Object result = null;
            try {
                System.out.println("AOP 시작");
                result = proceedingJoinPoint.proceed();
                System.out.println("AOP 종료");
            } catch (){
                ...
            }
    }
}

@Pointcut 어노테이션은 애스펙트에서 마치 변수와 같이 재사용 가능한 포인트컷을 정의할 수 있습니다. 그래서 이를 이용하여 각각의 표현식을 getOrder() 메소드와 getUsers() 메소드에 담았습니다. 이렇게 될 경우 다음부터는 동일한 표현식은 미리 지정된 메소드명으로 표현식을 그대로 사용할 수 있는 것입니다.

returning 사용하기

PointCut의 종류 중 @AfterReturning, @AfterThrowing 를 사용할 경우, @After 보다 더욱 디테일하게 AOP를 구현할 수 있습니다.
@AfterReturning, @AfterThrowing 를 사용할 경우, returning을 사용하여 반환하는 값을 수정하고 반환시킬 수 있기 때문입니다.
getUser 메서드가 User 클래스를 반환한다고 가정하고 진행하겠습니다.

@Aspect
@Component
public class TestAop {

    @PointCut("execution(* com.gjgs.modules.users.services.interfaces.UserService.getUser(..))")
    public void getUser(){}

    // returning = "반환값 변수명"
    @AfterReturning(value = "getUser()", returning = "user")
    // 파라미터로 returning 값 받기
    public void adviceMethod(User user){    
        user.setName("change");
    }

    @AfterThrowing(value = "getUser()", throwing = "exception")
    public void adviceMethod2(Exception exception) throws RuntimeException {    
         //exception 으로 해당 메서드에서 발생한 예외를 가져올 수 있습니다.
    }
}

getUser 메서드는 User 클래스를 반환하는데 @AfterReturning의 returning 값으로 반환값의 변수명을 주면 AOP 메서드의 파라미터로 returning 값을 받아서 사용할 수 있습니다. 이때 이 값을 수정하게 되면 실제로 반환하는 값도 수정되게 됩니다. 여기서는 user의 이름이 change로 수정되어 반환되게 됩니다.

메서드의 이름과 파라미터 사용하기

AOP의 로직을 수행하는 과정에서 분명 Target 메서드의 파라미터를 사용해야하는 경우가 있을 것입니다. 이때는 AOP 메서드의 파라미터에 JoinPoint를 주면 됩니다.

@Aspect
@Component
public class TestAop {

    @PointCut("execution(* com.gjgs.modules.users.services.interfaces.UserService.getUser(..))")
    public void getUser(){}

    @Before("getUser()")
    public void adviceMethod(JoinPoint joinPoint){    
        
        // 메서드 명 추출 -> aspectJ 것 사용
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        // reflect 것 사용
        Method method = methodSignature.getMethod();
        System.out.println("Method Name : " + method.getName());

        // 파라미터 추출
        User user = null;
        Object[] args = joinPoint.getArgs();
        for(Object param : args) {
            if(param instanceof User) {
                user = (User) param;
            }
        }
    }
}

@annotation 사용하기

앞서 설명할 때 PointCut의 지정자로 @Annotation 과 execution을 주로 사용한다고 설명했습니다. 이번엔 @Annotation을 이용해 AOP를 구현해보겠습니다.

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

@Annotation 기반으로 AOP를 구현하기 위해서는 우선적으로 Custom 애노테이션을 만들어야합니다. Target 메서드에 붙여서 AOP의 대상으로 인식시켜야하기 때문입니다.
애노테이션을 설명하는 포스팅이 아니므로 간단하게만 정리하고 넘어가겠습니다.

  • @Documented : 문서에도 애노테이션 정보를 표기
  • @Target : 애노테이션을 적용할 위치 명시
    • ElementType.PACKAGE : 패키지 선언
    • ElementType.TYPE : 타입 선언
    • ElementType.ANNOTATION_TYPE : 어노테이션 타입 선언
    • ElementType.CONSTRUCTOR : 생성자 선언
    • ElementType.FIELD : 멤버 변수 선언
    • ElementType.LOCAL_VARIABLE : 지역 변수 선언
    • ElementType.METHOD : 메서드 선언
    • ElementType.PARAMETER : 메소드의 파라미터로 선언된 객체에서만 사용 가능
    • ElementType.TYPE_PARAMETER : 전달인자 타입 선언
    • ElementType.TYPE_USE : 타입 선언
  • @Retention : 애노테이션이 언제까지 유효할지 명시
    • RetentionPolicy.SOURCE : 컴파일 전까지만 유효 (컴파일 이후에는 사라짐)
    • RetentionPolicy.CLASS : 컴파일러가 클래스를 참조할 때까지 유효
    • RetentionPolicy.RUNTIME : 컴파일 이후에도 JVM에 의해 계속 참조가 가능 (리플렉션 사용)


public class UserServiceImpl implements UserService {
    // 생략 //
    
    @Override
    @CheckUserAuthority
    public User getUser(String nickname) {        
        return memberRepository.findByNickname(nickname);
    }
}

AOP의 Target 메서드에 위에서 만든 커스텀 애노테이션을 붙여줍니다.


@Aspect
@Component
public class TestAop {

    // 커스텀 애노테이션 명시
    @Before("@annotation(CheckUserAuthority)")
    public void adviceMethod(JoinPoint joinPoint){    
       // 로직 //
    }
}

최종적으로 Advice의 value값으로 @annotation을 주고 인자로 커스텀 애노테이션을 명시해주면 AOP를 적용할 수 있습니다.

5. 실전 적용하기


@annotation을 사용해서 AOP를 구현할 것입니다. 이번 코드는 제가 실제로 프로젝트에서 사용했던 코드로 회원가입시 추천인을 입력할 경우 리워드를 제공하는 AOP입니다. 리뷰를 작성할 때, 추천인을 입력할 때 등 메인 로직과 리워드 제공 로직을 같이 끼워넣기보다 AOP로 분리하는 것이 더 나은 코드라고 판단하여 AOP로 구현했습니다.
전체 코드를 작성하기는 어려우니 이런 맥락이구나 보시면 될 것 같습니다.
saveRecommendReward.java

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

AOP Target 메서드에 붙일 애노테이션 입니다.


LoginServiceImpl.java

// 애노테이션 생략
public class LoginServiceImpl implements LoginService {
    // 생략 //
    @Override
    @saveReward
    public LoginResponseDto saveAndLogin(SignupForm signupForm) {
        // 생략 // 
        // 회원 가입 및 로그인 처리 로직 -> JWT 토큰 반환 //
        return ...;
    }
}

saveAndLogin 메서드에 AOP를 적용하기 위해 앞서 만들어 주었던 @saveRecommendReward 애노테이션을 붙여줍니다.

RewardAspect.java

@Aspect
@Component
@RequiredArgsConstructor
public class RewardAspect {

    private final RewardService rewardService;

    @AfterReturning("@annotation(saveReward)")
    public void saveReward(JoinPoint joinPoint){
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();

        // 회원가입 과정에서 추천인 등록시 양쪽 리워드 수령 로직
        if (methodSignature.getMethod().getName().equals("saveAndLogin")
                && joinPoint.getArgs()[0] instanceof SignupForm){
            
            SignupForm signupForm = (SignupForm) joinPoint.getArgs()[0];
            
            // 추천인 입력 확인 후 리워드 제공
            checkRecommendNicknameAndSaveReward(signupForm);
        }
    }
}

위처럼 if문의 조건으로 메서드명 일치를 넣어주면 추후에 리워드 AOP를 다른 로직에 붙일 경우 else if 문을 통해 메서드명으로 분류해서 추가적인 작성이 가능합니다.



[https://jojoldu.tistory.com/71]
[https://snoopy81.tistory.com/296]


© 2021. By Backtony