이전 게시글에서 AOP에 대해 간단하게 정리했는데 실제 예제등에 대해 부족한점이 있는 것 같아 내용을 보충하고자 해당 게시글을 작성하게 되었다.
2024.05.06 - [개발/springboot] - Spring 의 AOP - 공통 소스 파일을 분리하여 관리할 수 있는 방법
JoinPoint 와 ProceedingJoinPoint
- JoinPoint
@Advice가 적용되는 시점을 의미하므로, JoinPoint 인터페이스는 호출되는 대상 객체, 메서드, 전달파라미터 목록에 접근할 수 있는 메소드를 제공한다.
Signature getSignature() | 호출되는 메서드에 대한 정보 |
Object getTarget() | 대상 객체 |
Object[] getArgs() | 파라미터 목록 |
Signature | 호출되는 메서드와 관련된 정보를 제공함 |
String getName() | 메서드 이름 |
String toLongName() | 메서드를 완전하게 표현한 문장 ( 반환타입, 파라미터 타입 ) |
String getArgs() | 파라미터 목록 |
- ProceedingJoinPoint
JoinPoint를 상속받은 객체로 Around advice ( 이하 @Around ) 에서만 지원 되는 JoinPoint 이다.
@Around를 사용할 때 ProceedingJoinPoint를 첫 번째 파라미터로 전달받는다. 이 때 ProceedingJoinPoint.proceed()를 통해 Target 메소드의 실행을 제어할 수 있다.
아래는 ProceedingJoinPoint 내부 소스 모습이다.
package org.aspectj.lang;
import org.aspectj.runtime.internal.AroundClosure;
public interface ProceedingJoinPoint extends JoinPoint {
void set$AroundClosure(AroundClosure var1);
default void stack$AroundClosure(AroundClosure arc) {
throw new UnsupportedOperationException();
}
Object proceed() throws Throwable;
Object proceed(Object[] var1) throws Throwable;
}
일반적으로 ProceedingJoinPoint 사용시 proceed()가 함께 오게 되는데, 그 이유는 무엇일까?
이것을 이해하려면 @Around가 실행되는 흐름을 알아야 한다.
먼저 클라이언트의 요청이 발생한다. 이 때 ( Spring AOP 프레임워크뿐만 아니라 대부분의 AOP 프레임워크에서 클라이언트의 요청을 인터셉터한 후 알맞은 Advice 가 실행되는 형태로 구성되어 있음 ) 지정된 @Around가 이 요청을 인터셉트하고 실행되어야 하는 비즈니스 로직(핵심로직) 수행 전 수행되어야 하는 Aspect 를 실행한다.
이 다음에 비즈니스 로직을 호출해주는 역할을 하는 것이 바로 proceed() 이다. @Around가 클라이언트 요청을 가로챘기 때문에, proceed()를 호출해주지 않는다면 클라이언트가 원래 요청한 비즈니스 로직이 호출될 수 없기 때문이다.
이 때 클라이언트가 요청한 비즈니스로직이 무엇인지에 대한 정보를 Spring 컨테이너가 가지고 있다가 @Around 의 메소드로 넘겨줘야 하는데, 이 정보를 가지고 있는 것이 ProceedingJoinPoint 객체가 하는 일이다.
따라서 proceed() 메소드의 호출 전 후로, 비즈니스 로직의 메소드 실행 전 후가 나뉘어진다.
일반적으로 return 시 proceed() 호출 후 리턴되는 Object를 반환하는데, 이 객체에는 비즈니스 메소드가 실행한 후의 결과 값들이 담겨 있다.
아래 코드를 보자.
먼저 공통적으로 실행되어야 하는 내용을 @Aspect 를 통해 지정하고 @Advice 로 @Around를 지정했다.
또한 @Around 내에 annotation을 지정하여 클라이언트가 @LocalExcutionTime 에 대해 호출할 경우 LocalExcutionTime 메서드가 실행될 수 있도록 설정했다.
@Slf4j
@Component // @Configuration 으로 사용 권장
@Aspect // aspectj 에서 제공, 횡단 관심사 클래스임을 표기함
public class LogAspect {
@Around("@annotation(LocalExcutionTime)") // Advice의 한 종류로 Aspect의 실패 여부와 상관없이 전,후로 실행되도록하는 Advice. 파라미터로 pointCut 전달 필요
// pointCut은 Aspect가 적용될 joinpoint를 정의 함
public Object LocalExcutionTime(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
Object proceed = proceedingJoinPoint.proceed();
stopWatch.stop();
log.info(stopWatch.prettyPrint());
return proceed;
}
}
클라이언트가 호출하는 비즈니스 로직을 log.info 로 가정하고 "/test1" 을 호출할 수 있도록 간단한 컨트롤러를 작성했다.
@RestController
@Slf4j
public class AopSample {
@LocalExcutionTime
@GetMapping("/test1")
public String test1 () {
log.info("비즈니스로직이 호출됩니다.");
return "test1";
}
실행 결과는 다음과 같다.
2024-05-15T21:34:40.354+09:00 INFO 50657 --- [pojo] [nio-8080-exec-5] com.example.pojo.LogAspect : proceed 호출 전
2024-05-15T21:34:40.355+09:00 INFO 50657 --- [pojo] [nio-8080-exec-5] com.example.pojo.AopSample : 비즈니스로직이 호출됩니다.
2024-05-15T21:34:40.361+09:00 INFO 50657 --- [pojo] [nio-8080-exec-5] com.example.pojo.LogAspect : StopWatch '': 0.001288292 seconds
----------------------------------------
Seconds % Task name
----------------------------------------
0.001288292 100%
2024-05-15T21:34:40.362+09:00 INFO 50657 --- [pojo] [nio-8080-exec-5] com.example.pojo.LogAspect : proceed 호출 후
아래는 잘 안보여서 로그 내용만 다시 캡쳐한 내용이다.
만약 proceed() 를 호출하지 않으면 클라이언트가 요청한 비즈니스 로직이 실행되지 않는다.
2024-05-15T21:42:27.411+09:00 INFO 50705 --- [pojo] [nio-8080-exec-1] com.example.pojo.LogAspect : proceed 호출 전
2024-05-15T21:42:27.419+09:00 INFO 50705 --- [pojo] [nio-8080-exec-1] com.example.pojo.LogAspect : StopWatch '': 0.000552959 seconds
----------------------------------------
Seconds % Task name
----------------------------------------
0.000552959 100%
2024-05-15T21:42:27.420+09:00 INFO 50705 --- [pojo] [nio-8080-exec-1] com.example.pojo.LogAspect : proceed 호출 후
Dynamic Proxy 와 CGLIB Proxy
먼저 둘의 차이를 이해하려면 Proxy 라는 단어에 대한 이해가 필요하다.
- proxy
클라이언트가 호출하는 타겟(객체)를 대신해 요청 받는 대리인으로 타겟은 자신의 기능에만 집중하고 부가기능은 프록시에게 위임한다.
쉽게 말해, 프록시는 대리인의 역할을 한다고 볼 수 있다.
이러한 프록시 객체는 직접 생성해서 사용할 수 있지만 그렇게 되면 굉장히 번거로워진다. ( 한 객체를 생성할 때마다, 한 개의 Bean 을 생성하고 등록할 때 마다 프록시 객체를 하나하나 개발자가 생성해줘야 하기 때문이다. ) 따라서 Spring에서는 프록시 객체를 자동으로 생성하고 관리하는 기능을 제공하는데, 이것을 Dynamic Proxy ( 동적 프록시 ) 라고 표현한다.
또한 이러한 프록시 객체는 인터페이스의 유무에 따라 Dynamic Proxy 와 CGLIB 프록시로 나뉜다.
여기서 의미하는 인터페이스의 유무는 프록시 객체 생성을 위해 java.lang.reflect.Proxy 클래스에서 제공하는 newProxyInstance() 호출시 Target Object 값을 반드시 인터페이스로 등록해야 함을 의미한다.
좋은 예시를 잘 정리해둔 글이 있어 참고 링크로 첨부한다.
https://inpa.tistory.com/entry/JAVA-☕-누구나-쉽게-배우는-Dynamic-Proxy-다루기
CGLiB는 인터페이스가 아니라 상속을 기반으로, 즉 클래스를 기반으로 프록시 객체를 생성한다. 이전 글에서 타겟의 생성자를 두번 호출하고 default 생성자가 필요하다고 적었는데, 4.0 version Objensis 라이브러리가 포함되며 최신 springboot 버전에서는 default 생성자가 필요하지 않고, 타겟의 생성자도 한번만 호출 된다.
이렇게 설명하면 두 프록시 객체의 차이가 잘 안와닿는다.
예시를 들어보자. 구버전 Spring에서 @Service DI시 일반적으로 인터페이스를 DI 하지 Implements 된 완전한 클래스 ( concrete class )를 DI 하지 않았던 것을 기억할 것이다. 그 이유는 spring 에서 default로 Dynamic Proxy를 사용하고 있었기 때문이다. Dynamic Proxy 에서 기본으로 인터페이스에 대해서만 프록시 객체를 자동으로 생성하기 때문에, 인터페이스에 대해서 DI를 진행한 것이다.
하지만 최근 Springobot 코드에서는 class 에 대해 DI 를 수행한다.
아래는 최근 작성한 코드 예시 일부 이다.
@Service
@Transactional
@Slf4j
@RequiredArgsConstructor
public class HomeService {
private final HomeRepository homeRepository;
public List<Recipe> recipeSearch(RecipeSearchDTO recipeSearch) {
return homeRepository.findByData(recipeSearch);
}
}
사실 이 내용이 필요하다고 느낀 이유는, 비단 AOP 뿐만 아니라 아무렇지 않게 스프링에서 제공하는 애노테이션을 사용하여 DI 를 실행할 때, 왜 그렇게하는지를 알아야 오류를 방지할 수 있다고 생각하기 때문이다.
AOP 를 구성해보자 !
이전 포스팅에서도 그렇고 간단하게 뭐가 있다만 적고 실제 예제 코드를 작성하지 않은 것 같아서 간단하게 소개하고자 한다.
1. AOP 라이브러리를 추가
implementation 'org.springframework.boot:spring-boot-starter-aop'
2. @SpringbootApplication 위에 @EnableAspectJAutoProxy를 추가한다.
해당 애노테이션을 사용하면 AspectJ AOP 프레임워크를 사용할 수 있도록 지원해준다. AspectJ AOP 프레임워크는 Java에서 컴파일 시점 또는 런타임 시점에 적용할 수 있는 다양한 AOP 기능을 제공한다. 그런데 사실 Springboot 에서는 @EnableAspectJAutoProxy를 사용하지 않아도 내부에서 자동으로 AOP 프록시 빈을 등록하고 AOP를 사용할 수 있게 지원한다. 즉 Springboot 에서 내부적으로 자동 프록시 활성화를 지원한다 !
이것은 @EnableAspectJAutoProxy의 proxyTargetClass=true가 default 로 설정되고 CGLIB proxy 가 자동으로 활성화 되는 것을 의미한다.
아래는 실제 @EnableAspectJAutoProxy 의 코드 전체다. 여기서 default가 false라 의문일 수 있는데, 해당 애노테이션 자체는 Spring boot가 아니라 Spring에 해당하기 때문에 기본값이 false 로 설정되어 있는 것이다.
실제로 springoboot 환경에서는 true로 실행 된다.
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import({AspectJAutoProxyRegistrar.class})
public @interface EnableAspectJAutoProxy {
boolean proxyTargetClass() default false;
boolean exposeProxy() default false;
}
따라서 나는 따로 작성하진 않았다. AOP설정을 따로 섬세하게 할게 아니라면 Springboot에서 제공하는 자동 프록시 활성화를 사용해도 큰 문제가 없다.
3. 공통적인 횡단 관심사에 해당하는 내용을 @Aspect 를 통해 설정한다.
@Slf4j
@Component // @Configuration 으로 사용 권장
@Aspect // aspectj 에서 제공, 횡단 관심사 클래스임을 표기함
public class LogAspect {
}
4. 실질적으로 수행되는 로직인 @Advice를 추가하고, @Pointcut을 통해 범위를 지정한다.
@Advice 속성으로 사용할수도 있지만, @Pointcut을 따로 두는 방법도 있다.
사실 따로 분리해서 관리하는게 권장되는 방법이다.
- @Advice 속성으로 범위를 지정한 경우
import org.springframework.util.StopWatch;
@Slf4j
@Component \
@Aspect // aspectj 에서 제공, 횡단 관심사 클래스임을 표기함
public class LogAspect {
@Around("@annotation(LocalExcutionTime)") // Advice의 한 종류로 Aspect의 실패 여부와 상관없이 전,후로 실행되도록하는 Advice. 파라미터로 pointCut 전달 필요
// pointCut은 Aspect가 적용될 joinpoint를 정의 함
public Object LocalExcutionTime(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
log.info("proceed 호출 전 ");
Object proceed = proceedingJoinPoint.proceed();
stopWatch.stop();
log.info(stopWatch.prettyPrint());
log.info("proceed 호출 후 ");
return null;
//return proceed;
}
}
- @Pointcut을 통해 범위를 분리한 경우
@Pointcut("@annotation(LocalExcutionTime)")
public void pointcut() {}
//@Around("@annotation(LocalExcutionTime)") // Advice의 한 종류로 Aspect의 실패 여부와 상관없이 전,후로 실행되도록하는 Advice. 파라미터로 pointCut 전달 필요
@Around("pointcut()")
// pointCut은 Aspect가 적용될 joinpoint를 정의 함
public Object LocalExcutionTime(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
log.info("proceed 호출 전 ");
//Object proceed = proceedingJoinPoint.proceed();
stopWatch.stop();
log.info(stopWatch.prettyPrint());
log.info("proceed 호출 후 ");
return null;
//return proceed;
}
위 내용은 pointcut 사용시 @annotation 지시자를 사용하는 방법으로 작성했는데 포인트컷 지시자와 표현식은 다양하다.
cf. pointcut 지시자와 표현식 종류
- execution : 메소드 실행 조인 포인트를 매칭한다. 스프링 AOP에서 가장 많이 사용하고, 기능도
복잡하다. - within : 특정 타입 내의 조인 포인트를 매칭한다.
- args : 인자가 주어진 타입의 인스턴스인 조인 포인트
- this : 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트
- target : Target 객체(스프링 AOP 프록시가 가르키는 실제 대상)를 대상으로 하는 조인 포인트
- @target : 실행 객체의 클래스에 주어진 타입의 애노테이션이 있는 조인 포인트
- @within : 주어진 애노테이션이 있는 타입 내 조인 포인트
- @annotation : 메서드가 주어진 애노테이션을 가지고 있는 조인 포인트를 매칭
- @args : 전달된 실제 인수의 런타임 타입이 주어진 타입의 애노테이션을 갖는 조인 포인트
- bean : 스프링 전용 포인트컷 지시자, 빈의 이름으로 포인트컷을 지정한다.
예제에서는 @Around 만 사용했지만 더 많은 @Advice가 존재한다.
cf. @Advice 종류
@Before
어드바이스 타겟 메서드가 호출되기 전에 어드바이스 기능을 수행함
@After
타겟 메서드의 결과에 상관없이 완료되면 어드바이스 기능을 수행함
@AfterReturning(정상적 반환 이후)
어드바이스 타겟 메서드가 성공적으로 리턴 후에 기능을 수행함
@AfterThrowing(예외 발생 이후)
어드바이스 타겟 메서드 수행 중 예외를 던지면 기능을 수행함
여기서 말하는 타겟 메서드는 클라이언트가 호출한 비즈니스 로직에 해당한다.
@AfterThrowing 와 @AfterReturning 으로 예시를 들어보자.
TestAOP() 커스텀 애노테이션을 사용해서 @AfterThrowing 과 @AfterReturning을 선언했다.
@Pointcut("@annotation(TestAOP)")
public void TestAOP() {}
@AfterThrowing("TestAOP()")
public void afterThrowingTest(JoinPoint joinPoint) {
Signature signature = joinPoint.getSignature();
log.info("throwing .. " + signature.getName());
}
@AfterReturning("TestAOP()")
public void afterReturningTest(JoinPoint joinPoint) {
Signature signature = joinPoint.getSignature();
log.info("returning .. " + signature.getName());
}
@TestAOP
@GetMapping("/test2")
public void test2 () {
throw new NullPointerException("hello world!");
}
임의로 NullPointerException을 발생시키는 컨트롤러를 추가하고 "/test2"를 호출하면 ( 클라이언트 요청에 해당 - Advice Target Method ) 로그에는 다음과 같이 출력 된다.
2024-05-15T23:40:07.103+09:00 INFO 51493 --- [pojo] [nio-8080-exec-1] cohttp://m.example.pojo.LogAspect : throwing .. test2
'Dev > Springboot' 카테고리의 다른 글
스프링 빈 라이프 사이클 ( Spring Bean Life Cycle ) - Bean Scope, Bean Profile, Bean 초기화 (0) | 2024.05.16 |
---|---|
Spring 의 PSA - 웹 기술 스택을 간편하게 변경할 수 있는 방법 (0) | 2024.05.06 |
Spring의 DI - 코드를 유연하고 단순하게 하는 방법 (0) | 2024.05.06 |
Spring 의 AOP - 공통 소스 파일을 분리하여 관리할 수 있는 방법 (0) | 2024.05.06 |
HttpUrlConnection을 사용하여 외부URL Data Scrapping 하기 (0) | 2024.03.14 |