데이터베이스 업데이트 프로젝트에서 테이블 별 업데이트 된 현황을 명세 테이블에 저장하는 기능을 구현해야 했다.
이 기능의 핵심 동작은 모두 같기 때문에 공통적으로 사용할 수 있도록 모듈화(하나만 만들어서 재사용)를 시키고 싶었다. 그래서 공통 기능을 모듈화하여 자동적으로 수행할 수 있도록 해주는 AOP를 적용하게 되었다.
AOP란?
- Aspect Oriented Programing으로 관점 지향 프로그래밍으로 객체 지향을 보완하는 수단이다.
- 공통 관심사(Aspect)를 모듈화하여 비즈니스 로직을 헤치지 않고 재사용하는 프로그래밍 기법이다.
공통 관심사는 쉽게 말해서 여러 클래스에서 반복적으로 사용되는 공통 로직이라고 이해하면 된다.
Spring에서 AOP는 프록시 패턴을 기반으로 설계되어 있다.
Proxy 메커니즘
프록시란 '대리인'이라는 의미를 가진다. 실제 객체를 대리하는 가짜 객체라고 생각하면 된다.
- 프록시와 실제 객체가 공유하는 인터페이스가 있고, 클라이언트는 인터페이스를 통해 프록시 객체를 사용한다.
- 클라이언트는 실제 객체를 사용하는 것처럼 동작하지만, 실제로는 프록시 객체를 통해 접근한다.
- 실제 객체는 자신이 해야하는 일에만 집중하면서 부가적인 기능은 프록시 객체에게 넘겨주어 객체지향적인 프로그래밍을 할 수 있게 되는 것이다.
스프링에서 proxy 객체를 생성하는 방법은 JDK Dynamic Proxy, CGLIB Proxy로 두 가지이다.
JDK proxy
java에서 제공하는 API를 사용하며, 인터페이스를 기반으로 프록시 객체를 생성한다.
- 인터페이스에 적혀있는 모든 메서드에 프록시를 적용시킨다.
- 그리고 리플렉션 정보로 특정 메서드만 추가 동작하도록 만들 수 있다. (invoke)
*리플렉션(Reflection): 구체적인 클래스 타입을 알지 못해도 그 클래스의 메소드, 타입, 변수들에 접근할 수 있도록 해주는 자바 API (Class<T>)
CGLIB proxy
바이트코드를 조작하여 프록시 객체를 생성해주는 코드 생성 라이브러리다.
- MethodMatcher 객체를 사용하여 특정 메서드만 프록시화하고, 나머지는 프록시를 거치치 않고 실제 객체 메서드를 호출하도록 만들 수 있다. (intercept)
- 이는 인터페이스가 아닌 타겟 객체를 직접 상속받아 프록시 객체 생성하기 때문에 가능하다.
*Spring Boot 2.0부터는 디폴트 설정이 CGLIB proxy를 사용하도록 바뀌었다.
(→ JDK proxy는 무조건 인터페이스가 있어야하고 Reflection을 사용하기 때문에 성능이 비교적 느리다고 알려져 있음.)
Spring AOP 적용 방법
먼저 어떤 때에 Spring AOP를 적용하는지, 적용 방법에 대하여 간단한 예시를 통해 알아보자.
- 요구 사항
- 현 프로젝트 내의 모든 메서드 실행 시간을 파악하여 어디서 시간이 오래 걸리는지 보고하라.
- 문제점
- 메서드의 개수가 한 두개가 아닌데 메서드마다 시작 시간, 종료 시간을 넣어서 실행 시간을 찍는 것은 비효율적이다.
- 비즈니스 로직을 수행하는 메서드에 부가적인 기능(실행 시간을 구하는 로직)이 섞여서 들어간다.
이런 경우에 spring aop를 사용하면 간단하게 해결된다.
예시
@Component
@Aspect // aop라고 선언
public class TimeTraceAop throw Throwable {
@Around("execution(* hello.hellospring..*(..))") // 동작(Advice)을 어느 시점에 할 건지 지정
public Object execute(ProceedingJoinPoint joinPoint) {
// before
long start = System.currentTimeMillis();
System.out.println("START: "+ joinPoint.toString());
try {
return joinPoint.proceed(); // 실제 객체 실행
} finally {
// after
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("END: "+ joinPoint.toString()+ " "+ timeMs + "ms");
}
}
}
- 공통되는 부가 기능을 처리할 클래스를 생성한다. (TimeTraceAop.class)
- 공통 관심사를 모듈화 한다. (@Aspect)
- JoinPoint를 지정한다. (@Around)
동작 시점을 지정하는 어노테이션은 @Before, @After 그리고 @Around가 있는데, Around는 Before + After를 모두 제어한다. (예외가 발생하더라도 실행 된다.)
Around 내에 문법은 JoinPoint(시점)을 지정하는 pointcut 표현식이다. 위에서 사용된 표현식은 "hello.hellospring 패키지 하위의 모든 메서드(파라미터가 몇개든 상관 없음)"에 적용하겠다는 의미이다.
동작 방식
클라이언트가 메서드를 호출하면 내부에서는 프록시 객체가 먼저 호출되고, 그 프록시 객체가 실제 객체를 호출한다.
- Controller -> ProxyService -> RealService
동작 시점 분리 적용
실제 적용 시에는 메소드에서 예외가 발생했을 때를 고려하고자 어노테이션을 달리하여 명확하게 시점을 분리했지만, 사실 Around에서 try catch로 exception을 잡아줘도 될 것 같다.
- UpdateSpecificAop 모듈의 역할 : controller내 메소드들의 동작이 완료되면 테이블 별 업데이트 된 정보를 명세 테이블에 입력한다.
...
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
import pharosibio.api.dbUpdate.util.exception.CustomException;
import pharosibio.api.dbUpdate.util.exception.ErrorCode;
import pharosibio.api.dbUpdate.web.dto.UpdateInfoDto;
import pharosibio.api.dbUpdate.web.service.UpdateSpecificationService;
import java.time.LocalDateTime;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class UpdateSpecificAop {
private final UpdateSpecificationService updateSpecificationService;
private LocalDateTime startDate;
private String methodName;
// controller내의 update prefix 메소드를 지정하는데 ExcludeSpecification 어노테이션이 있는건 제외
@Pointcut("execution(* pharosibio.api.dbUpdate..*Controller.update*(..)) && " +
"!@annotation(pharosibio.api.dbUpdate.util.annotation.ExcludeSpecification)")
private void pointcut() {}
@Before("pointcut()")
public void before(JoinPoint joinPoint) {
startDate = LocalDateTime.now();
methodName = joinPoint.getSignature().getName();
log.info("start {}: {}", methodName, startDate);
}
// 메소드가 정상 종료되었을 때 동작
@AfterReturning(pointcut = "pointcut()", returning = "returning")
public void execute(JoinPoint joinPoint, Object returning) {
LocalDateTime endDate = LocalDateTime.now();
// set info
UpdateInfoDto updateInfoDto = updateSpecificationService.setUpdateInfo(methodName);
updateInfoDto.addDate(startDate, endDate);
// save info
updateSpecificationService.saveUpdateInfo(updateInfoDto);
}
// 메소드에서 예외가 발생했을 때
@AfterThrowing(pointcut = "pointcut()", throwing = "exception")
public void afterThrowing(JoinPoint joinPoint, Exception exception) {
throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR, exception.getMessage());
}
}
동일한 JoinPoint를 각 시점에서 사용하기 때문에 pointcut 메소드를 만들어 공통으로 사용하였고, Controller에서 제외해야 하는 메소드가 있어서 애노테이션을 생성하여 구분시켜줬다.
마치며
Spring AOP는 김영한님의 JPA 강의를 통해 알아 두었고, 마침 기회가 생겨 프로젝트에 적용하면서 혼자 응용해본 거라 제대로 사용했는지는 잘 모르겠다. 하지만 다방면으로 유용한 기술이라고 생각되어 앞으로 사용하면서 발전시킬 기회가 많을 것 같다.
'spring & java' 카테고리의 다른 글
[Spring] 스프링 부트 핵심 가이드 - API 작성 기초 (4) | 2023.10.29 |
---|---|
[Spring] 스프링 부트 핵심 가이드 - Spring 기초 지식 (0) | 2023.10.22 |
[Java] 비동기 + multi threading 구현 (0) | 2023.03.01 |
[Spring] Spring Security + JWT (7) | 2023.02.06 |
[JPA] batch insert 적용 (0) | 2022.11.19 |