AOP 란?
AOP는 관점을 기준으로 다양한 기능을 분리하여 보는 프로그래밍을 의미합니다.
개발자가 프로그램의 기능을 구현할 때, 기능의 핵심적인 부분과 부가적인 부분으로 나누고, 각 부분을 분리하여 모듈화 하겠다는 의미입니다.
핵심적인 부분(관점) : 기능이 의미하는 핵심 비즈니스 로직
부가적인 부분(관점) : 핵심 로직은 아니지만, 기능에 필요한 부가적인 부분(로깅, 파일 입출력, DB 연결(JDBC) 등...)
AOP 의 목적
우리가 주문 기능을 API 형태로 구현한다고 가정해 봅시다.
주문 기능을 구현하러면 구매한 상품, 구매한 상품들의 가격, 결제 등 주문의 핵심적인 부분과 주문 API를 요청했을 때, 주문을 처리하는 데 걸리는 시간, 주문을 구성하는 여러과정을 처리하는 도중 한 가지가 실패했을 때의 트랜잭션 처리, 외부에서 API를 악의적인 목적으로 요청했을 때의 보안처리등과 같이 핵심적인 로직에서 벗어난 부가적인 부분이 있습니다.
구매 상품, 가격, 결제등 주문의 핵심적인 부분은 주문 기능에만 필요한 반면,
로깅, 트랜잭션, 보안등은 주문이라는 기능의 핵심적인 부분은 아니지만 여러 기능, 계층에 반복적으로 필요합니다.
이렇게 주문이라는 하나의 기능안에서 개발자의 관심사는 여러가지입니다.
AOP는 관심사들을 분리시킴으로써 핵심적인 부분과 부가적인 부분을 나누고, 나아가 반복적으로 필요한 부가적인 부분을 재사용할 수 있도록 구현하려는 목적을 가지고 있습니다.
위에서 이야기한 예제를 일반화하여 표현한 그림입니다.
예시를 주문으로 들었지만, 보통의 비즈니스 웹 어플리케이션이라면 비즈니스의 핵심로직이 있고, 어플리케이션 전반에 필요한 부가 로직이 있습니다. AOP에서는 이를 횡단 관심사(cross-cutting concerns)라고 표현합니다.
횡단 관심사의 코드를 핵심로직과 분리하여 코드의 간결성을 높이고 유연하고 확장이 쉬운 코드를 작성하는 것이 AOP의 목적입니다.
AOP 적용 방법
AOP를 적용하는 방식은 여러가지가 있습니다.
일반적으로 비즈니스 계층에는 기능의 핵심적인 부분을 구현하고, 부가적인 부분을 분리합니다.이렇게 분리한 코드가 구현한 기능이 실행되었을 때, 같이 실행되어야 하므로 부가적인 기능과 핵심적인 기능을 연결하는 과정이 필요합니다. 예를 들어, 주문 기능의 비즈니스 로직이 실행하기 직전과 직후에 로깅을 한다고하면, 로깅처리를 하는 코드는 주문 기능을 처리하는 메소드가 호출된 것을 알아야 로깅을 할 수 있습니다.
분리한 두 기능을 연결할 수 잇는 다양한 방법들에 대해서 알아보겠습니다.
컴파일 시점 적용
AspectJ 컴파일러가 일반 .java 파일을 컴파일 할 때 부가기능을 넣어서 .class 파일로 컴파일 해주는 것을 의미합니다. 여기서 AspectJ가 분리한 부가기능 코드와 실제 코드를 연결하는 위빙(weaving) 이라고 합니다.
A.java ▶ AOP ▶ A.class(AspectJ)
클래스 로딩 시점 적용
JVM 내의 클래스로더가 .class 파일을 메모리에 올리는 시점에 AspectJ가 파일을 가로채서 바이트 코드를 조작해 부가기능 로직을 추가하는 방식입니다.
분리된 부가기능 로직을 기능에 직접 넣어주는 방식입니다. 그래서 .java 파일과 .class 파일의 내용이 서로 다릅니다.
이렇게 바이트 코드를 조작하는 이유가 있습니다.
하나는, Spring과 같은 DI 컨테이너의 도움없이 AOP를 적용할 수 있기 때문입니다.
DI 컨테이너가 없는 환경에서도 사용할 수 있도록 하기 위함입니다.
두번째는, 다음에 소개할 런타임 시점에 AOP를 적용하는 것보다 유연하게 AOP를 적용시킬 수 있기 때문입니다.
런타임 시점에 AOP를 적용하게 되면 이미 프로그램이 실행중이므로 코드를 조작할 수 없습니다. 그래서 AOP를 적용시킬 수 있는 범위가 클라이언트가 호출하여 실행되는 메소드에 제한됩니다. 하지만 바이트 코드를 직접 조작하면 오브젝트 생성, 필드 값 변경, 스태틱 값들을 초기화시키는 등 부가기능에서 훨씬 더 많은 일을 할 수 있습니다.
런타임 시점 적용
JVM이 컴파일하고, 클래스 로더가 로당 한 후 main()메소드가 실행되어 프로그램의 시작된 런타임 시점에 AOP를 적용하는 방식입니다. 부가 기능이 구현된 공통 모듈을 프록시로 만들어서 DI로 연결된 빈 사이에 적용해 AOP를 적용하려는 메소드의 호출 과정에 관여하는 방법입니다.
이 과정에서 프록시 패턴이 적용되어 클라이언트가 메소드를 호출할 때, 프록시 객체를 호출하고 프록시 객체는 부가기능이 구현된 메소드에 메소드 요청정보를 전달합니다. 그리고 부가기능이 구현된 메소드에서 핵심기능이 구현된 메소드를 호출하는 형태로 구현됩니다.
Spring AOP
Spring AOP 에서는 런타임 시점에 AOP를 적용시키는 방법을 사용합니다.
Spring 에서 이 방법을 사용하는 이유는 컴파일 시점이나 클래스 로딩 시점에 AOP를 적용하려면 별도의 컴파일러 혹은 클래스 로더를 조작하는 일이 필요하기 때문입니다.
Spring은 컴파일이나 클래스로딩에 관여하지 않는 Java 프레임워크이므로 런타임 시점에 AOP를 적용시키는 방법을 지원하는 것이 자연스럽습니다.
AOP 에서 사용되는 용어
- Aspect : 횡단 관심사의 기능. 부가적인 기능이면서 재사용될 수 있는 부분을 모듈화 한 것
- Target : Aspect가 적용되는 곳. 핵심적인 기능이 구현된 클래스나 메소드
- Advice : Aspect에서 실질적인 기능. 부가기능 그 자체: Aspect를 언제 핵심 기능에 적용할지 구현 → JoinPoint 실행 전.후, 예외 발생후, 항상, 전반에 걸쳐
- Join Point : Advice가 Target에 적용되는 시점 → 메소드에 진입할 때, 생성자를 호출할 때, 필드에서 값을 꺼낼 때등: AOP를 적용시키는 범위에 따라 다름(클래스, 메소드, 필드등)
- Point Cut : Join Point 중 Advice가 적용될 지점을 선별하는 기능
- Advisor : Spring AOP 에서만 사용하는 용어. 하나의 어드바이스와 포인트컷으로 구성된 Asepct를 지칭하는 단어
- Proxy : 클라이언트와 타겟 사이에 존재하는 부가기능을 제공하는 오브젝트.: DI를 통해 타겟 대신 클라이언트에게 주입되며 클라이언트의 메소드 호출을 대신 받아서 타겟에 위임하고, 이 과정에서 부가기능을 부여
OOP(Object Oriented Programming) 와의 관계?
단어가 비슷해 객체 지향 프로그래밍과 다른 관점의 프로그래밍이라고 생각할 수 있지만, AOP는 관점에 따라 기능을 분리하여 핵심기능에 집중하는 객체지향적인 가치를 지킬 수 있도록 도와주는 방법입니다.
지향하는 목표가 다른 프로그래밍 방법이 아니라 OOP의 가치를 지킬 수 있도록 도와주는 방법이라고 생각하면 좋을 것 같습니다.
예제 코드 - Spring AOP
Spring AOP를 활용해서 어플리케이션에 AOP를 적용하는 간단한 예제를 구현해 보도록 하겠습니다.주문시스템에서 상품을 판매하는 가게의 목록을 조회하는 메소드의 성능을 로그로 남기는 기능을 핵심 관점과 부가기능 관점으로 분리하여 구현해보도록 하겠습니다.
환경은 다음과 같습니다.
- Jdk 17
- Spring Boot 3.1.0
- Hibernate 6.2.2
- Gradle 8.2
Spring AOP에 대해 소개하기 위한 코드이므로, Entity, Repository, DTO 등 AOP와 직접적인 연관이 없는 클래스의 코드는 생략하겠습니다.
AOP 사용하지 않은 가게 목록 조회 기능 성능 로깅
1. 의존성 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-aop'
}
2. AOP 적용시키지 않은 Service 구현
@Slf4j
@Transactional
@Service
public class StoreViewService {
private final StoreQueryRepository storeQueryRepository;
public StoreViewService(StoreQueryRepository storeQueryRepository) {
this.storeQueryRepository = storeQueryRepository;
}
public Page<StoreViewResponse> findByConditions(StoreSearchRequest storeSearchRequest, Pageable pageable) {
long start = System.currentTimeMillis();
log.info("---------- findByConditions START ----------");
log.info("START TIME : " + LocalDateTime.ofInstant(Instant.ofEpochMilli(start), TimeZone.getDefault().toZoneId()));
Page<StoreViewResponse> storeViewResponseList = storeQueryRepository.findByConditions(storeSearchRequest, pageable);
long finish = System.currentTimeMillis();
long timeMs = finish - start;
log.info("END TIME : " + LocalDateTime.ofInstant(Instant.ofEpochMilli(finish), TimeZone.getDefault().toZoneId()));
log.info("---------- findByConditions END ---------- "+ timeMs + "ms");
return storeViewResponseList;
}
}
3. 테스트코드
package io.weyoui.weyouiappcore.store.query.application;
import io.weyoui.weyouiappcore.store.query.application.dto.StoreSearchRequest;
import io.weyoui.weyouiappcore.store.query.application.dto.StoreViewResponse;
import io.weyoui.weyouiappcore.store.query.infrastructure.StoreQueryRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import java.util.ArrayList;
@ExtendWith(MockitoExtension.class)
class StoreViewServiceTest {
@Mock
StoreQueryRepository storeQueryRepository;
@InjectMocks
StoreViewService storeViewService;
@DisplayName("가게 목록 조회 서비스 호출 시 성능 측정 로그가 남는다")
@Test
void findByConditions_test() {
//given
StoreSearchRequest searchRequest = new StoreSearchRequest();
Pageable pageable = PageRequest.of(0,10);
PageImpl<StoreViewResponse> page = new PageImpl<>(new ArrayList<>(),pageable,0);
//when
storeViewService.findByConditions(searchRequest,pageable);
}
}
4. 결과
AOP 적용해 가게 목록 조회 기능 개선
1. 관점에 따라 기능 분석
findByConditions 메소드에서 storeQueryRepository에 조회 결과를 요청하는 부분이 핵심 기능에 해당되고, 핵심기능 전.후의 현재시간을 이용해 실행시간을 계산하고 로그를 남기는 부분이 부가기능에 해당됩니다.
실행시간을 계산하고 로그를 남기는 기능은 가게 목록 조회기능 뿐만 아니라 다른 메소드에서도 사용가능하기 때문에 공통모듈로 분리해 내고, AOP를 적용시켜 가게 목록 조회기능 실행 전후에 실행하도록 구현하겠습니다.
2. 공통 모듈 추출 및 구현
package io.weyoui.weyouiappcore.aspects;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.TimeZone;
@Slf4j
@Component
@Aspect
public class PerfAspect {
@Around(value = "execution(* io.weyoui.weyouiappcore.store.query.application.StoreViewService.*(..))")
public Object logPerf(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
log.info("---------- " + joinPoint.toString() + " START ----------");
log.info("START TIME : " + LocalDateTime.ofInstant(Instant.ofEpochMilli(start), TimeZone.getDefault().toZoneId()));
try {
return joinPoint.proceed();
} finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
log.info("END TIME : " + LocalDateTime.ofInstant(Instant.ofEpochMilli(finish), TimeZone.getDefault().toZoneId()));
log.info("---------- " + joinPoint + " END ----------" + timeMs + "ms");
}
}
}
Spring AOP 라이브러리를 추가하면 AnnotationAwareAspectJAutoProxyCreator 라는 자동 프록시 생성기가 Spring Bean에 등록이 됩니다.
자동 프록시 생성기는 스프링 빈으로 등록된 Advisor를 찾고, PointCut에 매칭이 될 경우 자동으로 프록시를 적용해 줍니다.
@Aspect 어노테이션은 AspectJ 프로젝트에서 제공하는 Advisor를 생성하는 기능을 가진 어노테이션입니다.
어노테이션이 선언된 클래스를 Advisor로 만들어 줍니다.
@Around 어노테이션은 Advice가 적용되는 시점(Point Cut)에 대해 정의하는 어노테이션입니다.
@Before, @After, @AfterReturning, @AfterThrowing, @Around 등의 어노테이션을 지원합니다.
가게 목록 조회기능 전.후에 로그를 남기고 싶기때문에 모든 시점을 컨트롤할 수 있는 @Around 어노테이션을 사용하였습니다.
3. Service에서 부가기능 제거
@Slf4j
@Transactional
@Service
public class StoreViewService {
private final StoreQueryRepository storeQueryRepository;
public StoreViewService(StoreQueryRepository storeQueryRepository) {
this.storeQueryRepository = storeQueryRepository;
}
public Page<StoreViewResponse> findByConditions(StoreSearchRequest storeSearchRequest, Pageable pageable) {
return Page<StoreViewResponse> storeViewResponseList = storeQueryRepository.findByConditions(storeSearchRequest, pageable);
}
}
4. 테스트 코드
package io.weyoui.weyouiappcore.store.query.application;
import io.weyoui.weyouiappcore.store.query.application.dto.StoreSearchRequest;
import io.weyoui.weyouiappcore.store.query.application.dto.StoreViewResponse;
import io.weyoui.weyouiappcore.store.query.infrastructure.StoreQueryRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import java.util.ArrayList;
@SpringBootTest
@ExtendWith(MockitoExtension.class)
class StoreViewServiceTest {
@Autowired
StoreQueryRepository storeQueryRepository;
@Autowired
StoreViewService storeViewService;
@DisplayName("가게 목록 조회 서비스 호출 시 성능 측정 로그가 남는다")
@Test
void findByConditions_test() {
//given
StoreSearchRequest searchRequest = new StoreSearchRequest();
Pageable pageable = PageRequest.of(0,10);
//when
storeViewService.findByConditions(searchRequest,pageable);
}
}
5. 실행 결과
AOP 적용해 가게 목록 조회 기능 개선 2 - Annotation
AOP Advisor 클래스인 PerfAspect 클래스의 logPerf 메소드는 StoreViewService의 모든 메소드에 적용됩니다.
왜냐하면 @Around 어노테이션은 Advise의 적용시점을 정의하는 어노테이션으로 인자로 적용 범위를 전달하는데, Execution Expression 방식으로 StoreViewService의 모든 메소드를 지정했기 때문입니다.
특정 메소드에만 동작하도록 표현식을 작성할 수도 있지만, 매번 긴 표현식을 작성하는 것은 문법적으로 실수할 여지도 많고 복잡하기도 합니다.
@Around 메소드는 PointCut을 표현하는 방법을 Execution Expression 외에도 Bean 표현 방식과 Annotation 표현방식을 지원합니다.
Bean 표현방식은 "bean(빈이름)" 과 같은 방법으로 사용할 수 있고,
Annotation 표현방식은 "@annotation(어노테이션 이름)" 처럼 표현할 수 있습니다.
이 중에서 어노테이션 방법을 사용해서 개선해 보도록 하겠습니다.
Annotation을 만들고, Advise(logPerf 메소드)가 Annotation이 선언된 메소드에만 동작하도록 개선해보겠습니다.
1. 어노테이션 생성
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface PerfLogging {
}
2. Aspect 클래스 수정(Execution Expression -> Annotation)
@Slf4j
@Component
@Aspect
public class PerfAspect {
@Around("@annotation(PerfLogging)")
public Object logPerf(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
log.info("---------- " + joinPoint.toString() + " START ----------");
log.info("START TIME : " + LocalDateTime.ofInstant(Instant.ofEpochMilli(start), TimeZone.getDefault().toZoneId()));
try {
return joinPoint.proceed();
} finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
log.info("END TIME : " + LocalDateTime.ofInstant(Instant.ofEpochMilli(finish), TimeZone.getDefault().toZoneId()));
log.info("---------- " + joinPoint + " END ----------" + timeMs + "ms");
}
}
}
3. 핵심기능이 구현된 서비스 메소드 어노테이션 추가
@Slf4j
@Transactional
@Service
public class StoreViewService {
private final StoreQueryRepository storeQueryRepository;
public StoreViewService(StoreQueryRepository storeQueryRepository) {
this.storeQueryRepository = storeQueryRepository;
}
@PerfLogging
public Page<StoreViewResponse> findByConditions(StoreSearchRequest storeSearchRequest, Pageable pageable) {
return Page<StoreViewResponse> storeViewResponseList = storeQueryRepository.findByConditions(storeSearchRequest, pageable);
}
}
4. 실행 결과
Spring AOP 프록시 패턴 의존관계 변화
- AOP 를 적용시키지 않은 상태에서의 의존 관계는 컨트롤러가 서비스에 직접적으로 의존관계를 가지고 있습니다.
- 컨트롤러는 memberService 객체에 직접 접근해서 로직을 수행합니다.
- 부가기능을 구현한다면 memberService 클래스에 직접 코드를 추가/수정 해줘야 합니다.
- 컨트롤러와 서비스 사이에 프록시 객체를 생성해서 컨트롤러가 memberService 프록시 객체에 의존하도록 합니다.
- 컨트롤러는 기능을 수행할 때, 프록시 객체를 호출하고, 프록시 객체가 실제 memberService 객체를 호출해 로직을 수행합니다.
컨트롤러, 서비스, 리파지토리 까지 전체적인 그림으로 표현하면 위의 그림처럼 표현할 수 있습니다.
계층 간에 직접적으로 의존하지 않고, 중간에 프록시 객체를 두어 직접적인 의존을 피하고 부가기능을 구현하여 핵심기능 전/후에 처리를 해줄 수 있습니다.
Spring AOP가 동작하는 과정
가게 목록 조회 기능에 AOP를 적용시키는 과정에서 @Aspect 어노테이션의 역할에 대해서 이야기했고, Spring AOP 의존성을 추가하면 자동 프록시 생성기가 Spring Bean 으로 등록된다고 했습니다.
이번에는 @Aspect 가 선언된 클래스를 Advisor로 변환하는 과정과 자동 프록시 생성기가 프록시를 생성해 Advisor 를 실행시키는 과정에 대해서 이야기해보겠습니다.
@Aspect 가 선언된 클래스를 Advisor로 변환하는 과정
- 스프링 애플리케이션을 로딩하면서 자동 프록시 생성기 호출
- 자동 프록시 생성기가 스프링 컨테이너 안의 @Aspect 어노테이션 붙은 스프링 빈 모두 조회
- @Aspect 어드바이저 빌더를 통해 Advisor 생성
- 생성한 Advisor를 @Aspect 어드바이저 빌더 내부에 저장
@Aspect 어드바이저 빌더는 BeanFactoryAspectJAdvisorsBuilder 클래스를 의미합니다.
BeanFactoryAspectJAdvisorsBuilder 클래스는 @Aspect의 정보를 기반으로 Pointcut, Advice, Advisor를 생성하고 보관하는 역할을 합니다.
자동 프록시 생성기의 동작 과정
- Spring Bean으로 등록할 클래스 객체 생성
- 생성된 객체를 빈 저장소에 등록하기 전 Bean 후처리기에 전달
- Spring 컨테이너 내부의 Advisor Bean 모두 조회
- @Aspect 어드바이저 빌더 내부에 저장된 Advisor 모두 조회 (@Aspect 가 선언된 클래스)
- 조회된 Advisor 내 Pointcut을 통해 프록시 적용 대상 확인→ 객체의 클래스 정보와 메서드를 Pointcut에 모두 매칭 한 뒤 조건이 하나라도 만족하면 프록시 적용 대상
- 프록시 적용 대상이라면 프록시 생성 후 원본 객체 대신 프록시 객체 반환→ 프록시 적용 대상이 아니라면 원본 객체를 반환 해 원본 객체를 스프링 빈에 등록→ 프록시 적용 대상이라면 원본 객체가 아닌 프록시를 스프링 빈으로 등록
'Spring' 카테고리의 다른 글
인터페이스 구현체 동적 선택을 활용한 리팩토링 (0) | 2023.09.07 |
---|---|
[JPA Hibernate] 1:N 관계의 Entity Collection 참조 변경 (0) | 2023.09.06 |
Spring Event를 활용한 Slack 메시지 전송 (0) | 2023.09.01 |
Spring - Pageable 최대 페이지 크기 제한 (0) | 2023.08.26 |
@ModelAttribute 어노테이션을 생략했을 때 파라미터가 바인딩되는 과정 (0) | 2023.08.25 |