S E P H ' S

[Spring] 5. Spring AOP - 총정리 (2) 본문

Programing & Coding/Spring

[Spring] 5. Spring AOP - 총정리 (2)

yoseph0310 2023. 5. 22. 21:55

Spring AOP

Spring AOP에 대한 이해를 위해 지난 포스트 에서는 AOP를 달성하기 위해 사용한 디자인 패턴을 알아보면서 어떤 식으로 개선을 해왔는지 알아봤다. 하지만 결국 마지막 정리 부분에서 말했듯이 프록시 패턴이나 데코레이터 패턴을 사용하더라도 부가 기능을 도입하고자 하는 클래스의 개수만큼 필요한 클래스를 만들어야 한다는 문제점이 있었다. 이를 동적 프록시 적용 (JDK 동적 프록시, CGLIB, ProxyFactory) 으로 해결할 수 있다고 했는데 이를 위해서는 Spring AOP 용어 이해가 필요하다. 


AOP 란?

AOP는 로직을 볼때, 핵심적인 관점과 부가적인 관점을 나누어 보고 그 관점을 기준으로 모듈화를 한다고 했었다. 다음 그림을 보자.

 

그림과 같이 클래스 A, B, C에서 공통적으로 나타나는 색 블록들은 중복되는 메소드나 필드, 코드 등을 의미한다. 이때 만약 클래스 A의 초록색 부분을 수정해야 한다면 클래스 B, C의 부분도 일일이 찾아 수정해야 하는 불편함이 있다. 이는 SOLID 원칙(단일 책임 원칙)을 위배하며 유지보수를 어렵게 만든다. 이렇게 코드 상에서 계속 반복적으로 사용되는 부분들을 흩어진 관심사(CrossCutting Concerns)라고 한다.

 

OOP : 비즈니스 로직의 모듈화
   - 모듈화의 핵심 단위는 비즈니스 로직

AOP : 인프라 혹은 부가기능의 모듈화
   - 대표적인 예시 : 모니터링 및 로깅, 동기화, 오류 검사 및 처리, 성능 최적화(캐싱) 등
   - 각각의 모듈들의 주 목적 외에 필요한 부가적인 기능들

 

AOP 에서 각 관점을 기준으로 로직을 모듈화 한다는 것은 흩어진 관심사를 모듈화 하겠다는 것이다. 그림과 같이 Aspect X, Y, Z와 같이 모듈화로 코드를 구성해둔 다음, 필요한 곳에서 사용하면 되는 것이다. 간단히 한 줄로 요약하자면 "AOP는 공통 기능을 재사용하는 기법"이다. AOP의 장점은 다음과 같다.

 

  • 애플리케이션 전체에 흩어진 공통 기능이 하나의 장소에서 관리되어 유지보수가 좋다.
  • 핵심 로직과 부가 기능의 명확한 분리로, 핵심 로직은 자신의 목적 외에 사항들에는 신경쓰지 않는다.

 

AOP 적용 방식

3. Spring 핵심 3대요소 (IoC/DI, AOP, PSA) 에서 짧게 다뤘었다. AOP의 적용방식은 크게 3가지가 있다.

 

  • 컴파일 시점
    • .java 파일을 컴파일러를 통해 .class를 만드는 시점에 부가 기능 로직을 추가하는 방식
    • 모든 지점에 적용 가능
    • AspectJ가 제공하는 특별한 컴파일러를 사용해야 하기 때문에 특별한 컴파일러가 필요한 점과 복잡하다는 단점이 있음
  • 클래스 로딩 시점
    • .class 파일을 JVM 내부의 클래스 로더에 보관하기 전에 조작하여 부가 기능 로직을 추가하는 방식
    • 모든 지점에 적용 가능
    • 특별한 옵션과 클래스 로더 조작기를 지정해야 하므로 운영하기 어려움
  • 런타임 시점
    • 스프링이 사용하는 방식
    • 컴파일이 끝나고 클래스 로더에 이미 다 올라가 자바가 실행된 다음에 동작하는 런타임 방식
    • 실제 대상 코드는 그대로 유지되고 프록시를 통해 부가 기능이 적용
    • 프록시는 메소드 오버라이딩 개념으로 동작하기 때문에 메소드에만 적용. 스프링 빈에서만 AOP를 적용 가능
    • 특별한 컴파일러나, 복잡한 옵션, 클래스 로더 조작기를 사용하지 않아도 스프링만 있으면 AOP를 적용할 수 있기 때문에 스프링 AOP는 런타임 방식을 사용함
Spring AOP는 AspectJ 문법을 차용하고 프록시 방식의 AOP를 제공한다. Spring에서는 AspectJ가 제공하는 어노테이션이나 관련 인터페이스만 사용하고 실제로 AspectJ가 제공하는 컴파일, 로드 타임 위버 등은 사용하지 않는다. 따라서 Spring AOP는 AspectJ를 직접 사용하는 것은 아니다.

또한 Spring AOP와 AspectJ는 목적이 다르다. Spring AOP는 개발자가 마주한 공통적인 문제를 해결하고자 Spring IoC를 통해 간단한 AOP 구현이 목적이라면 AspectJ는 완전한 AOP를 제공하는 것이 목적인 AOP 기술이다.

 

 

AOP 용어

  • Join Point
    • 추상적인 개념으로 advice가 적용될 수 있는 모든 위치를 말한다.
    • 메소드 실행 시점, 생성자 호출 시점, 필드 값 접근 시점 등등
    • Spring AOP는 프록시 방식을 사용하므로 Join Point는 항상 메소드 실행 시점이다.
  • Pointcut
    • Join Point 중에서 Advice가 적용될 위치를 선별하는 기능
    • Spring AOP는 프록시 기반이므로 Join Point가 메소드 실행 시점 뿐이고 Pointcut도 메소드 실행 시점뿐이다.
  • Target
    • Advice의 대상이 되는 객체
    • Pointcut으로 결정이 된다.
  • Advice
    • 실질적인 부가 기능 로직을 정의하는 곳
    • 특정 Join Point에서 Aspect에 의해 취해지는 조치
  • Aspect
    • Advice + Pointcut을 모듈화 한것. 흩어진 관심사를 모듈화 한것
    • @Aspect와 같은 의미이다.
  • Advisor
    • Spring AOP 에서만 사용되는 용어로 Advice + Pointcut 한쌍.
  • Weaving
    • Pointcut으로 결정한 타겟의 Join Point에 Advice를 적용하는 것
  • AOP 프록시
    • AOP 기능을 구현하기 위해 만든 프록시 객체
    • Spring에서 AOP 프록시는 JDK 동적 프록시 또는 CGLIB 프록시이다.
    • Spring AOP 의 기본 값은 CGLIB 프록시이다.

 

JDK 프록시와 CGLIB 프록시에 대해서는 밑에서 조금 더 자세하게 다뤄보도록 할 것이다.

 

 

 

Aspect

 

Spring은 빈을 등록할 때, 빈 후처리기에서 빈 객체를 조작하거나 새로운 빈 객체로 바꿔서 리턴 받은 빈을 컨테이너에 등록하게 된다. 예를 들어서 Controller 하나를 빈 후처리기에 넣으면 Controller를 타겟으로 하는 프록시 객체를 리턴한다. 그리고 컨테이너에는 프록시 객체가 컨트롤러로서 등록이 된다.

 

더 자세하게 말하자면 모든 Adviosr 빈을 조회하고 Pointcut으로 타겟들을 매칭해보면서 프록시 적용 대상인지 판단하고 대상이라면 프록시를 빈으로 등록하게 된다. 이때 Adviosr를 더욱 쉽게 구현할 수 있는 @Aspect 어노테이션이 있다. Spring에서 이를 사용하려면 다음과 같은 의존성을 추가해야 한다.

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

 

해당 의존성을 추가하게 되면 자동 프록시 생성기 (AnnotationAwareAspectJAutoProxyCreator)를 사용할 수 있게 되고, 이것이 Advisor 기반으로 프록시를 생성하는 역할을 한다. 이와 더불어, 자동 프록시 생성기는 @Aspect를 보고 Advisor로 변환해서 저장하는 작업을 수행한다.

 

 

자동 프록시 생성기 - @Aspect 어드바이저 생성

자동 프록시 생성기에 의해 @Aspect에서 Advisor로 변환된 Advisor는 @Aspect Advisor 빌더 내부에 저장된다. 다음은 자동 프록시 생성기에 의해 생성된 Advisor는 기존 로직에서 어느 시점에 끼어는지 확인해보겠다.

 

 

자동 프록시 생성기 - @Aspect 어드바이저 적용

 

  1. 스프링 빈 대상이 되는 객체를 생성한다. (@Bean, 컴포넌트 스캔 대상)
  2. 생성된 객체를 빈 저장소에 등록하기 전, 빈 후처리기에 전달한다.
  3. 모든 Advisor 빈을 조회한다.
  4. @Aspect Advisor 빌더 내부에 저장된 모든 Advisor를 조회한다.
  5. 3,4 에서 조회한 Advisor에 포함되어 있는 포인트 컷을 통해서 클래스와 메소드 정보를 매칭하면서 프록시 적용 대상 여부를 판단한다.
  6. 여러 Advisor의 하나라도 포인트컷의 조건을 만족한다면 프록시를 생성하고 프록시를 빈 저장소로 반환한다.
  7. 만약 프록시 생성대상이 아니라면 들어온 빈 그대로 빈 저장소로 반환한다.
  8. 빈 저장소는 객체를 받아서 빈으로 등록한다.
@Aspect는 컴포넌트 스캔이 되는 어노테이션은 아니다. 따라서 스프링 빈으로 등록해줘야 한다.
1. @Bean으로 수동 등록
2. @Component로 컴포넌트 스캔 사용으로 자동 등록
3. @Import 를 사용하여 파일 추가

 

 

Advice

Advice는 흩어진 관심사를 모듈화한 것이자 Aspect와 pointcut을 모듈화 한 것이다. 또한 실질적으로 프록시에서 수행하게 되는 로직을 정의하게 되는 곳이다. Spring에서는 Advice에 대한 5가지 어노테이션을 제공한다. 어노테이션은 메소드에 붙이게 되는데 해당 메소드는 Advice의 로직을 정의하게 되고 어노테이션의 종류에 따라 포인트컷에 지정된 대상 메소드에서 Advice가 실행되는 시점을 정할 수 있다. 또한 속성값으로 Pointcut을 지정할 수 있다.

 

  • @Around
    • 뒤의 4가지 어노테이션을 모두 포함하는 어노테이션
    • 메소드 호출 전호 작업 명시 가능
    • 조인 포인트 실행 여부 선택 가능
    • 반환값 자체를 조작 가능
    • 예외 자체를 조작가능
    • Join Point를 여러번 실행 가능(재시도)
  • @Before
    • Join Point 실행 이전에 실행(실제 target 메소드 수행 전에 실행)
    • 입력값 자체는 조작 불가능
    • 입력값 내부에 setter 같은 수정자가 있다면 내부값은 수정 가능
  • @AfterReturning
    • Join Point가 정상 완료 후 실행 (실제 target 메소드 수행 완료 후 실행)
    • 반환값 자체는 조작 불가능
    • 반환값 내부에 setter같은 수정자가 있다면 내부값은 수정 가능
  • @AfterThrowing
    • 메소드가 예외를 던지는 경우 실행(실제 target 메소드가 예외를 던지는 경우 실행)
    • 예외 조작 불가능
  • @After
    • Join Point의 정상, 예외 동작과 무관하게 실행(실제 target 메소드가 정상적 수행을 하든 예외를 던지든 수행 이후에 무조건 실행)

 

Advice 종류

@Around

@Slf4j
@Aspect
public class AspectAdvice {
	
    @Around("execution(* com.example.mvc.order..*(..))")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
    	try {
            // @Before 수행
            log.info("[트랜잭션 시작] {}", joinPoint.getSignature);
            // @Before whdfy
            
            // Target (원 객체) 메소드 호출
            Object result = joinPoint.proceed();
            // Target (원 객체) 메소드 종료
            
            // @AfterReturning 수행
            log.info("[트랜잭션 커밋] {}", joinPoint.getSignature);
            // @AfterReturning 종료
            
            // 값 반환
            return result;
        } catch (Exception e) {
            // @AfterThrowing 수행
            log.info("[트랜잭션 롤백] {}", joinPoint.getSignature);
            throw e;
            // @AfterThrowing 종료
        } finally {
            // @After 수행
            log.info("[리소스 릴리즈] {}", joinPoint.getSignature);
            // @After 종료
        }
        
    }

}

 

@Around의 속성은 Pointcut을 명시하는 곳이다. 나머지 4개의 어노테이션을 모두 포함한다고 했는데 주석을 확인하면 각 어노테이션이 어느 시점에 적용되는지 확인할 수 있다. @Around 어노테이션을 제외한 나머지 4개의 어노테이션은 @Around 어노테이션의 기능을 Target 메소드 실행 전, 후, 예외, 후의 무조건 실행되는 부분으로 분리한 것이다.

 

모든 Advice 어노테이션은 첫번째 파라미터로 org.aspectj.lang.JoinPoint를 사용할 수 있는데 @Around만 예외적으로 JoinPoint의 하위 타입인 Proceeding.JoinPoint를 사용한다. JoinPoint 인터페이스의 주요 기능은 다음과 같다.

 

  • getArgs() : 메소드 인수 반환
  • getThis() : 프록시 객체 반환
  • getTarget() : 대상 객체 반환
  • getSignature() : 클라이언트가 호출한 메소드의 정보가 저장된 Signature (리턴타입, 이름, 매개변수) 객체를 리턴
  • toString() : 클라이언트가 호출한 메소드에 대한 유용한 설명 인쇄

 

@Around와 나머지 4개 어노테이션의 차이점

1. proceed 메소드 활용 여부

Proceeding.JoinPoint의 주요기능에는 위의 주요 기능에서 다음 advice나 타겟을 호출하는 proceed() 메소드가 추가된다. 이는 @Around와 나머지 4개의 어노테이션을 분리한 이유와 관련되어 있다.

 

위 코드 중 joinPoint.proceed() 메소드로 Target 메소드를 호출하는 코드가 있다. 나머지 4개의 어노테이션 코드에서 확인하겠지만 @Around를 제외한 나머지 4개의 어노테이션들은 JoinPoint를 인자로 받아서 사용하고 proceed를 호출하지 않는다. 

 

즉 @Around의 경우 Proceeding.JoinPoint를 인자로 받아 타겟 메소드를 실행하는 proceed 메소드를 반드시 적어야 target 메소드를 호출한다. 하지만 나머지 4개의 어노테이션은 proceed를 명시하지 않아도 알아서 호출한다. 따라서 개발자가 실수로 proceed 코드를 작성하지않아 발생할 수 있는 실수를 방지할 수 있다. 또한 의도를 분명히 할 수 있음이다. 


2. 입력, 반환값 조작 가능 여부

 

@Around는 입력, 반환값 자체를 다른 객체로 조작이 가능하나 나머지 4개의 어노테이션은 조작할 수 없다.

 

 

@Before

 

@Before는 조인 포인트 실행 전(타겟 메소드 실행 전)에 작업을 수행한다.

@Before("execution(* com.example.mvc.order..*(..))")
public void doBefore(JoinPoint joinPoint) {
	log.info("[before] {}", joinPoint.getSignature());
}

@Around와 달리 proceed 코드 없이 정의한 로직이 수행된 후 자동으로 target 메소드를 호출한다.

 

 

@AfterReturning

 

@AfterReturning은 조인 포인트가 정상적으로 실행되고 값을 반환할 때 실행된다. (타겟 메소드가 예외가 아닌 정상값을 반환할 때)

@AfterReturning(value = "execution(* com.example.mvc.order..*(..))", returning = "result")
public void doReturn(JoinPoint joinPoint, Object result) {
	log.info("[return] {} return={}", joinPoint.getSignature(), result);
}

다른 어노테이션과는 다르게 속성값으로 returning이 추가되었다. 이 부분에는 Target 메소드가 반환하는 변수명을 적고, advice 메소드의 인자로 변수명을 일치시키면 해당 값을 가져와 사용할 수 있다.

 

주의할 점은 returning 값을 받는 인자의 타입이 해당 리턴 값의 부모타입 혹은 같은 타입이어야만 해당 Advice가 동작한다. 타입이 부모 혹은 동일 타입이 아니라면 Advice 자체가 동작하지 않는다.

 

 

@AfterThrowing

 

@AfterThrowing은 타겟 메소드 실행이 예외를 던져서 종료될 때 실행된다.

@AfterThrowing(value = "execution(* com.example.mvc.order..*(..))", throwing = "ex")
public void doThrowing(JoinPoint joinPoint, Exception ex) {
	log.info("[ex] {} message={}", joinPoint.getSignature(), ex.getMessage());
}

@AfterReturning과 비슷하게 throwing 속성이 추가되고 advice 메소드 인자에 변수명을 일치시켜 받아 사용할 수 있다.

 

 

@After

 

@After는 타겟 메소드의 실행이 종료되면 무조건 실행된다. try catch 문의 finally와 같다.

 

 

어노테이션 동작 순서

 

동일한 @Aspect 안에서는 위와 같은 우선순위로 동작한다.

즉, 동일한 @Aspect 안에서 여러 개의 Advice가 존재하는데 타겟 메소드가 여러 Advice의 대상이 될 경우 다음과 같이 동작한다.

 

Around -> Before -> AfterThrowing -> AfterReturning -> After -> Around

 

Advice 순서 지정하기

 

어노테이션의 동작 순서는 정의되더라도 같은 어노테이션에 대한 동작 순서는 보장되지 않는다.

따라서 @Aspect 적용 단위로 @Order 어노테이션을 지정해야 한다. Advice 단위가 아닌, @Aspect 클래스 단위로만 지정이 가능하다.

따라서 하나의 Aspect 안에 여러 Advice가 존재한다면 순서를 보장할 수 없으므로 별도의 클래스로 분리해야한다.

 

@Slf4j
public class AspectOrder {
	
    @Aspect
    @Order(1)
    public static class TxAspect {
    	@Around("~~~.aop.Pointcuts.orderAndService()")
        public Object doTx(ProceedingJoinPoint joinPoint) throws Throwable {
        	// ...
        }
    }
    
    @Aspect
    @Order(2)
    public static class LogAspect {
    	@Around("~~~.aop.Pointcuts.allOrder()")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        	// ...
        }
    }
    
}

내부 static class가 아니라 따로 클래스를 분리해도 상관없다.

 

 

포인트컷 분리

 

@Slf4j
@Aspect
public class AspectAdvice {
    
    @Around("execution(* com.~~~.~~~.order..*(..))")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
    	// ...
    }
    
}

Advice 어노테이션의 속성으로 Pointcut을 명시해서 사용했었다. 이렇게 어노테이션마다 포인트컷을 명시할 수 있지만 보통 분리해서 한 곳에서 만들어두고 가져다 사용한다.

 

@Slf4j
@Aspect
public class Aspect {

    // 포인트 컷 분리 (1)
    // com.~~.~~.order 패키지와 하위 패키지
    @Pointcut("execution(* com.~~.~~.order..*(..))")
    private void allOrder() {}
    
    // 분리된 포인트 컷 적용
    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable{
    	// ...
    }
    
    // 분리된 포인트 컷 적용    
    @Around("allOrder()")
    public Object anotherJob(ProceedingJoinPoint joinPoint) throws Throwable{
    	// ...
    }
	
    // 포인트 컷 분리 (2)
    // ex : 클래스 이름 패턴 *Service
    @Pointcut("execution(* *..*Service.*(..))")
    private void allService() {}
    
    // 포인트 컷 조합
    @Around("allOrder() && allService()")
    public Object anotherJob(ProceedingJoinPoint joinPoint) throws Throwable {
    	// ...
    }
}

 

하나의 Aspect 안에서 여러개의 Advice를 정의하는데 Pointcut으로 사용하는 것이 같다면 private으로 뽑아두고 사용할 수 있다. 또한 조건 연산자로 조합하여 사용할 수 있다.

 

package com.~~.~~.order.aop;

public class Pointcuts {
    @Pointcuts("execution(* com.~~.~~.order..*(..))")
    public void allOrder() {}
}


@Slf4j
@Aspect
public class Aspect {
    @Around("com.~~.~~.order.aop.Pointcuts.allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throw Throwable {
    	// ...
    }
}

 

만약 포인트컷을 공통적으로 많은 곳에서 사용한다면 클래스로 따로 빼서 사용할 수도 있다. 위처럼 따로 포인트컷을 외부로 뽑아두고 사용할 경우, 사용하는 Aspect에서는 해당 포인트컷이 위치한 패키지명.클래스명.메소드명 형식으로 명시하면 된다.

 

 

Pointcut

포인트컷은 Advice가 적용될 위치를 선별하는 기능이다.

Spring AOP는 프록시 기반이기 때문에 메소드만 적용 가능하므로 어느 메소드에 적용할 것인지 명시하는 것이라고 봐도 무방하다.

포인트컷 지시자의 종류는 여러가지이지만 실질적으로는 execution과 @annotaion만 사용하게 된다.

 

Pointcut 종류

execution

실질적으로 가장 많이 사용한다.

execution(접근제어자? 반환타입 선언타입?메소드이름(파라미터) 예외?)
  • ? 가 붙은 것들은 생략이 가능하다. (접근제어자, 선언타입, 예외)
  • * 패턴으로 모든 타입 허용을 표현한다.
  • ..을 통해 모든 타입 허용과 파라미터 수가 상관없다는 것을 표현한다.
  • 기본적으로 상위 타입을 명시하면 하위 타입도 적용이 되지만, 하위 타입에만 메소드를 명시하는 경우 매칭은 불가능하다.
  • 파라미터 타입의 경우 정확해야만 매칭된다. -> 부모타입을 허용하지 않는다.

 

within

within은 타입이 매칭되면 그 안에 모든 메소드가 매칭된다.

execution에서 타입부분만 사용하는 것과 같다.

차이점으로는 상위타입으로 하위타입 매칭이 불가하다. 정확히 타입이 맞아야 동작한다.

 

 

args

파라미터 타입으로 매칭한다.

execution은 파라미터 타입이 정확하게 일치해야 했지만, args는 부모타입의 경우, 하위타입 매칭이 가능하다. 

다만 args는 단독으로 사용하면 안된다.

args의 경우 스프링은 모든 스프링 빈에 AOP를 적용하려고 시도하기 때문에 스프링 내부에서 사용하는 빈 중에서 final로 지정된 빈들도 있어 오류가 발생할 수 있다.

 

@target, @within

  • @target : 자신의 클래스와 자신의 모든 부모클래스의 모든 메소드에 적용한다.
  • @within : 자신의 클래스의 모든 메소드에만 적용한다.
  • 둘다 타입에 있는 어노테이션으로 AOP 적용 여부를 판단한다.

@target과 @within도 args와 같은 이유로 단독으로 사용하면 안된다.

 

 

@annotation

@annotation은 메소드가 주어진 어노테이션을 갖고 있는 경우 적용된다.

@Target의 경우는 어노테이션이 붙어있는 클래스였는데 @annotation은 메소드이다.

 

bean

스프링 빈의 이름으로 AOP 적용 여부를 지정한다. 스프링에서만 사용할 수 있는 특별한 지시자이다.

 

this, target

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

  • this는 스프링 빈 객체 (스프링 AOP 프록시)를 대상으로 매칭한다.
  • target은 Target 객체 (스프링 AOP 프록시가 가르키는 실제 대상)를 대사으로 매칭한다.
  • 둘다 * 패턴 사용 불가.
  • 둘다 적용 타입 하나를 정확하게 지정
  • 부모타입 허용.

정리

용어가 정말 많아 포스팅하기 두려워진게 오랜만이었다. 정성스럽게 정리를 잘 하신 분들이 정말 대단하다고 느껴지는 부분이었다. 추가로 동적 프록시 적용에 대해서도 다뤄볼 예정이다.

 

 


출처

 

Spring - AOP 총정리

Spring의 핵심 개념 중 하나인 DI가 애플리케이션 모듈들 간의 결합도를 낮춘다면, AOP(Aspect-Oriented Programming)는 핵심 로직과 부가 기능을 분리하여 애플리케이션 전체에 걸쳐 사용되는 부가 기능을

velog.io