프로그래머스 데브코스 중간 프로젝트가 끝났습니다. 그래서 최종 프로젝트가 되기 전에 스프링 강의 때 배웠던 내용을 복습할 겸 강의 중에서 제일 이해하기 어려웠던 AOP에 대해서 공부하고 정리해보고자 합니다. 그럼 출발~
AOP ( Aspect Oriented Programming )
AOP는 관점 지향 프로그래밍입니다. 쉽게 말해 어떤 로직을 기준으로 핵심 기능과 부가기능으로 나누고 그 관점을 기준으로 각각을 모듈화 하겠다는 것입니다. 글보다는 간단한 코드 예시로 알아보도록 하겠습니다.
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public Order orderItem(String itemId) {
Order order = new Order(itemId);
return orderRepository.save(order);
}
}
위와 같이 아주 간단하게 주문을 하는 코드가 있습니다. 만약 이 상태에서 프로젝트의 요구사항으로 모든 메소드의 실행 시간을 로그로 남겨야 한다면 어떻게 해야 할까요? ( 억지 요구사항 이해 부탁드립니다 ㅎ.. )
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final OrderRepository orderRepository;
public Order orderItem(String itemId) {
long startTime = System.currentTimeMillis();
Order order = new Order(itemId);
Order result = orderRepository.save(order);
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("orderItem(). resultTime = {}", resultTime);
return result;
}
}
위처럼 정말 단순하게 실행 시간을 측정하고 로그를 남기는 코드를 메소드에 포함시키는 방법이 있을 겁니다. 하지만, 이러면 주문을 하는 핵심 기능과 시간을 측정하는 부가 기능이 하나의 코드 안에 공존하게 됩니다. 그리고 만약에 메서드가 100개라면? 100개의 메서드 모두 수정을 해야 할까요? 생각만 해도 너무 비효율적입니다.
이런 문제를 해결하기 위해 부가 기능을 수행하는 AOP를 적용할 수 있습니다.
AOP 적용 - 프록시 패턴
현재는 클라이언트가 OrderService에게 요청을 하는 구조입니다. OrderService가 실행 시간도 측정하고 주문도 하는 상태이죠. 이 상태에서 부가 기능을 담당하는 클래스를 하나 추가해보겠습니다.
중간에 프록시(Proxy)라는 것이 생겼습니다. Proxy는 '대리인'이라는 뜻으로 여기서는 실행 시간 측정 기능을 대신해줍니다.
결과적으로 프록시는 시간을 측정하고 로그 남기는 부가 기능을 담당하고 핵심 기능은 원래의 서비스 클래스(이하 타겟)가 담당하게 됩니다. 아래 코드 참고~
@Slf4j
@RequiredArgsConstructor
public class OrderServiceProxy{
private final OrderService target;
public Order orderItem(String itemId) {
long startTime = System.currentTimeMillis();
Order result = target.orderItem(itemId); // 핵심기능은 타겟에게 요청
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("orderItem(). resultTime = {}", resultTime);
return result;
}
}
그렇다면 원래의 서비스 코드에서는 시간 측정, 로그와 관련된 코드는 없애고 맨 처음과 같이 핵심 로직만 남겨놓을 수 있습니다.
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public Order orderItem(String itemId) {
Order order = new Order(itemId);
return orderRepository.save(order);
}
}
하지만, 이렇게 하면 클라이언트는 서비스 클래스에 요청을 하는 것이 아니라 프록시에 요청을 해야 하는 모양이 됩니다. 클라이언트가 프록시의 존재를 알 필요 없이 AOP를 사용하는 방법은 없을까요??
스프링이 AOP를 만드는 2가지 방법
1. 인터페이스 구현 방식
스프링의 핵심 기능 중에는 DI (Dependency Injection) 이라는 것이 있습니다. 클라이언트는 인터페이스에만 알고 있지만 실제로 스프링은 해당 타입에 맞는 bean을 주입해주는 것이죠. 이걸 응용하면 클라이언트는 MeberService에만 알고 있는 상태를 유지하면서 AOP를 적용할 수 있습니다. (클라이언트는 실제 주입되는 bean이 MemberServiceImpl 인지 MemberServiceProxy인지 전혀 알 필요 없는 상태이죠)
@RequiredArgsConstructor
@Slf4j
public class MemberServiceProxy implements MemberService{
private final MemberService target;
public Member save(String memberId) {
long startTime = System.currentTimeMillis();
Member result = target.save(memberId);
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("orderItem(). resultTime = {}", resultTime);
return result;
}
}
MemberServiceProxy는 MemberService를 구현하였고, 필드로 target을 가지고 있습니다. 실행 시간을 측정하는 부가 기능은 프록시에서 담당하고 핵심 기능은 타겟에게 위임하는 것입니다.
이 상태에서 MemberServiceImpl 대신에 MemberServiceProxy를 빈으로 등록을 해보겠습니다.
@Configuration
public class MemberConfig {
@Bean
public MemberService memberService(){
MemberServiceImpl target = new MemberServiceImpl(memberRepository());
return new MemberServiceProxy(target);
}
@Bean
public MemberRepository memberRepository(){
MemberRepositoryImpl target = new MemberRepositoryImpl();
return new MemberRepositoryProxy(target);
}
}
MemberService와 MemberRepository 모두 구체 클래스(target)를 만든 다음에, 프록시 클래스를 만들어줍니다. 최종적으로 빈으로 등록하는 것은 이 프록시 객체입니다!!
런타임 시에 의존 관계는 다음과 같습니다.
클라이언트 입장에서는 주입받은 빈이 프록시인지 아닌지 모르고 단순히 인터페이스에만 의존하여 요청을 보내는 것입니다. 하지만, 실제 런타임 시에는 프록시가 먼저 요청을 받아서 실행 시간과 관련된 작업을 한 뒤에 타겟에게 핵심 기능을 요청하는 흐름입니다.
2. 구체클래스 상속 방식
구체 클래스 상속도 인터페이스 방식과 마찬가지로 DI를 이용한 방식입니다.
@Slf4j
public class OrderServiceProxy extends OrderService{
private final OrderService target;
public OrderServiceProxy(OrderRepository orderRepository, OrderService target) {
super(null);
this.target = target;
}
public Order orderItem(String itemId) {
long startTime = System.currentTimeMillis();
Order result = target.orderItem(itemId); // target 호출
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("orderItem(). resultTime = {}", resultTime);
return result;
}
}
프록시는 구체 클래스를 상속받으면서 동시에 필드로도 가지고 있어야 합니다. 핵심 기능은 타겟에게 위임해야 하니까요. 그럼 인터페이스 방식과 마찬가지로 빈을 등록할 때 OrderService가 아닌 OrderServiceProxy를 등록해주면 됩니다.
@Configuration
public class OrderConfig {
@Bean
public OrderService orderService() {
OrderService target = new OrderService(orderRepository());
return new OrderServiceProxy(null, target);
}
@Bean
public OrderRepository orderRepository() {
OrderRepository target = new OrderRepository();
return new OrderRepositoryProxy(target);
}
}
그럼 런타임 시에 의존 관계는 다음과 같습니다.
위 코드에서 Service와 Repository 모두 프록시를 빈으로 등록하였습니다. 따라서, 클라이언트는 코드 상으로는 OrderService만 알고 있지만 실제로 주입되는 빈은 OrderServiceProxy인 것입니다. 그러면 또 마찬가지로 프록시에서 부가기능을 수행하고 핵심 기능은 타겟에게 위임하는 형태입니다. Repository도 마찬가지로 작동하게 됩니다.
여기까지 프록시가 중간에서 부가기능을 담당한다는 것을 알아보았습니다. 정리해보면 클라이언트 입장에서는 인터페이스에 의존하거나 (인터페이스 구현 방식) 부모 클래스에 의존하고 있지만 (구체 클래스 상속 방식) 실제로 주입되는 빈은 프록시인 것이죠. 그렇다면 프록시를 적용할 클래스마다 인터페이스를 새로 구현하거나 구체 클래스를 상속받아서 새로운 프록시를 만들어줘야 할까요? 클래스가 100개면 어떡하죠???...
다행히도 스프링은 프록시를 만들고 빈으로 등록하는 작업을 모두 대신해줍니다. 개발자가 할 일은 오로지 하나!
어떤 부가기능을 수행할 것인지 정하고 (어드바이스), 그 부가기능을 어떤 클래스 혹은 메서드에 적용할 것인지 필터링하는 것이죠 (포인트컷)
스프링이 제공하는 편리한 AOP 사용법
사용법을 알아보기 전에 먼저 용어 정리를 하고 넘어가겠습니다.
타겟 (Target)
- 핵심 기능을 담고 있는 모듈로서 부가기능을 부여할 대상
조인 포인트 (Join Point)
- 어드바이스가 적용될 수 있는 위치
- 타겟 객체가 구현한 인터페이스의 모든 메서드
포인트 컷 (Pointcut)
- 어드바이스를 적용할 타겟의 메서드를 선별하는 정규표현식
- 포인트컷 표현식은 execution으로 시작하고 메서드의 Signature를 비교하는 방법을 주료 이용함
어드바이스 (Advice)
- 어드바이스는 타겟의 특정 조인 포인트에 제공할 부가기능
애스펙트 (Aspect)
- 애스펙트 = 어드바이스 + 포인트컷
- Spring에서는 Aspect를 빈으로 등록해서 사용합니다.
스프링에서 AOP를 사용하기 위해서는 아래와 같이 @Aspect 어노테이션을 붙여 이 클래스가 Aspect를 나타내는 클래스라는 것을 명시하고 @Component를 붙여 스프링 빈으로 등록해줘야 합니다.
@Aspect
@Component
@Slf4j
public class LogAspect {
@Around("execution(* hello.aop..*.*(..))")
public Object logAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
Object result = joinPoint.proceed(); // 핵심 기능은 타겟에게 위임
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("orderItem(). resultTime = {}", resultTime);
return result;
}
}
@Around 어노테이션은 타겟 메서드를 감싸서 특정 어드바이스를 실행한다는 의미입니다. 위 코드의 어드바이스는 타겟 메서드가 실행된 시간을 측정하기 위한 로직을 구현하였습니다. 추가적으로 "execution(* hello.aop..*.*(..))"이 의미하는 바는 hello.aop 패키지와 그 하위 패키지에 있는 모든 클래스와 모든 메서드에 이 애스펙트를 적용하겠다는 의미입니다.
이렇게 개발자는 애스펙트만 만들어서 빈으로 등록하면 스프링이 다른 작업들을 모두 대신해줍니다. 먼저, 포인트컷 표현식을 보고 일치하는 클래스들은 프록시를 만들어서 빈으로 등록해주고, 런타임 시에 포인트컷에 일치하는 메서드들은 애스펙트에 정의해놓은 어드바이스 로직을 실행하고 타겟의 메서드를 호출하게 됩니다.
이 글에서는 AOP를 적용할 때, 프록시가 왜 필요한지 프록시를 어떻게 만들어야 하는지 간략하게 알아보았습니다. 실제로 스프링에서 애스펙트를 만들 때 @Around 외에도 @Before, @After 등 다양한 애노테이션이 존재합니다. execution 안에 들어가는 건 AspectJ의 포인트컷 표현식인포인트컷 표현식도 클래스 signature를 비교하는 방법 외에도 annotation기반으로 하는 등 더 복잡한 방법이 많이 있습니다. 구체적인 사용 방법들은 더 좋은 블로그 글들을 참조해 주시기 바랍니다!!
참조
https://engkimbs.tistory.com/746
https://atoz-develop.tistory.com/entry/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-AOP-%EA%B0%9C%EB%85%90-%EC%9D%B4%ED%95%B4-%EB%B0%8F-%EC%A0%81%EC%9A%A9-%EB%B0%A9%EB%B2%95
https://www.baeldung.com/spring-aop
https://gmoon92.github.io/spring/aop/2019/04/20/jdk-dynamic-proxy-and-cglib.html
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard
https://www.baeldung.com/java-reflection
프로그래머스 데브코스 스프링 강의
'Spring' 카테고리의 다른 글
Spring에서의 예외 처리 및 에러 페이지 (1) | 2021.09.16 |
---|---|
Parameter를 원하는 대로~♪♫ : ArgumentResolver 정리 (0) | 2021.09.09 |