소소한 글 : Spring AOP 예제를 차근차근 구현해보기
이 글은 간단한 구현 예제만을 포함하고 있습니다. AOP에 대한 이론적인 부분은 기존에 포스팅한
https://lob-dev.tistory.com/entry/Spring-AOP와-요청-인터셉트-개념
을 참고해주시길 바랍니다.
프로젝트 구성
Web과 Lombok을 추가합니다. (해당 예제에서는 Web 관련 로직을 사용하진 않고, 통합 테스트만 작성합니다. )
추가적으로
implementation 'org.springframework.boot:spring-boot-starter-aop'
의존성을 추가합니다.
우선 GreetingAspect를 통해서 Aspect를 적용할 수 있는 각 시점들을 확인해보겠습니다.
- Spring AOP는 xml를 통한 기본적인 설정 방식과 AspectJ에서 제공하는 Annotation을 통해 적용할 수 있습니다.
GreetingService
해당 서비스는 메서드가 호출되었음을 알 수 있도록 간단하게 작성합니다.
- AfterReturning과 AfterThrowing을 따로 호출하기 위해 bool 값을 통해 분기 처리합니다.
@Slf4j
@Service
public class GreetingService {
public void greeting(boolean condition) {
log.info("GreetingService.greeting");
if(condition) {
throw new RuntimeException();
}
}
}
개인적인 편의를 위해서 테스트 코드는 ApplicationTest class에서 작성합니다.
@SpringBootTest
class TestApplicationTest {
//...
@Test
void greeting() {
greetingService.greeting(false);
}
}
이제 이 코드를 실행하면 GreetingService.greeting이 Console에 출력되는 것을 확인할 수 있습니다.
이제 GreetingAspect를 작성합니다.
GreetingAspect
@Around는 LoggingAspect를 작성해볼 때 사용해보는 것으로 하고 다른 Advice Annotation들만 사용합니다.
@Slf4j
@Aspect
@Component
public class GreetingAspect {
@Before(value = "execution( public void aop.domain.GreetingService.*(*) )")
public void beforeAdvice() {
log.info("GreetingAspect.BeforeAdvice");
}
@After(value = "execution( public void aop.domain.GreetingService.*(*) )")
public void afterAdvice() {
log.info("GreetingAspect.AfterAdvice");
}
@AfterReturning(value = "execution( public void aop.domain.GreetingService.*(*) )")
public void afterReturningAdvice() {
log.info("GreetingAspect.AfterReturningAdvice");
}
@AfterThrowing(value = "execution( public void aop.domain.GreetingService.*(*) )", throwing = "exception")
public void afterThrowingAdvice(RuntimeException exception) {
log.info("GreetingAspect.AfterThrowingAdvice"+ exception.getClass());
}
}
GreetingAspect 역시 앞선 서비스와 같이 log만 출력합니다. 이제 앞선 테스트를 다시 실행해보면
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.BeforeAdvice
GreetingService.greeting
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterAdvice
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterReturningAdvice
이렇게 출력되는 것을 확인할 수 있습니다.
- execution 내부에 정의된 내용은 public 접근 지시자를 가지고, Return 타입이 void이며, 하나의 인자를 가지는 GreetingService의 모든 메서드를 대상으로 한다는 의미입니다.
- 해당 코드에서는 메서드 명을 작성하거나 GreetingService.* 등으로도 설정할 수 있습니다.
그럼 앞선 Service 메서드가 Exception을 던지도록 한 다음 AfterThrowing이 정상적으로 호출되는지 확인해보겠습니다. ApplicationTest에 하나의 Test 메서드를 추가합니다
@Test
void greeting_throw() {
assertThrows(RuntimeException.class, () -> greetingService.greeting(true));
}
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.BeforeAdvice
GreetingService.greeting
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterAdvice
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterThrowingAdviceclass java.lang.RuntimeException
AfterReturning과 AfterThrowing은 각각 Aspect target이 호출된 후 정상, 비정상(exception) 여부에 따라 동작하는 Advice입니다.
이제 @Around advice를 사용하여 위에서 작성한 greeting 메서드의 실행 시간에 관한 로깅을 남겨보도록 하겠습니다.
LoggingAspect
LoggingAsepct는 execution 표현식을 사용하지 않고 Annotation 방식으로 사용할 생각이므로, 우선 Logging이라는 Annotation Interface를 정의합니다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Logging {
}
이는 point cut 표현식 중 @annotation()을 통해 지정할 수 있습니다.
@Slf4j
@Aspect
@Component
public class LoggingAspect {
@Around(
value = "@annotation(aop.annotation.Logging)",
argNames = "joinPoint"
)
public Object logging(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("LoggingAspect.logging");
final Signature signature = joinPoint.getSignature();
final String className = signature.getDeclaringTypeName();
final StopWatch watch = new StopWatch();
watch.start();
Object result = joinPoint.proceed();
watch.stop();
log.info("logging : component = {} : execution time(ms) = {}", className, watch.getTotalTimeMillis());
return result;
}
}
joinPoint를 통해 Signature interface를 가져오면 이를 통해 Aspect target 메서드의 여러 정보들을 가져올 수 있습니다. 그리고 Spring Utils에서 제공하는 StopWatch를 통해 시간을 측정할 수 있습니다.
- joinPoint.proceed()을 통해 Aspect target의 메서드를 호출한 다음 return 값이 넘어왔을 때 StopWatch를 정지시켜 처리에 걸린 시간을 측정합니다.
이제 Logging과 권한 검사를 테스트할 도메인 모델인 Account와 AccountService를 작성합니다.
Account
@Getter
@ToString
public class Account {
private final String username;
private final String password;
private final String email;
private final String role;
public Account(String username, String password, String email, String role) {
this.username = username;
this.password = password;
this.email = email;
this.role = role;
}
}
Account는 간단한 도메인 모델이므로 별도의 설명을 달지는 않겠습니다.
AccountService
@Slf4j
@Service
public class AccountService {
@Logging
public void userRoleCheck(Account account) {
log.info("{}", account.toString());
}
public void adminRoleCheck(Account account) {
log.info("{}", account.toString());
}
}
단순한 도메인 모델인 Account를 구현하였고, Service 로직은 인자로 넘어온 account를 로깅하는 로직만 작성하였습니다.
이제 테스트 메서드를 추가하고 실행해봅니다.
@Logging
public void userRoleCheck(Account account) {
log.info("{}", account.toString());
}
------
@Test
void account() {
final Account account = new Account("lob", "password", "coffeescript@kakao.com", "USER");
accountService.userRoleCheck(account);
}
INFO 37028 --- [main] aop.domain.AccountService : Account{username='lob', password='password', email='coffeescript@kakao.com', role='USER'}
INFO 37028 --- [main] aop.aspect.LoggingAspect : logging : component = aop.domain.AccountService : execution time(ms) = 19
정상적으로 결과가 나오는 것을 확인할 수 있습니다.
이제 마지막으로 RoleCheck Annotation과 이를 통해 부여된 Role을 기준으로 exception을 발생시키는 Aspect를 작성해보겠습니다.
RoleCheck
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RoleCheck {
String value() default "ADMIN";
}
RoleCheck 어노테이션에는 하나의 String Field를 정의하여 값을 지정할 수 있도록 정의합니다.
RoleCheckAspect
RoleCheckAspect는 JoinPoint를 통해 메서드 인자를 가져오고, Annotation 정보와 비교하여 권한 검사를 진행합니다.
@Slf4j
@Aspect
@Component
public class RoleCheckAspect {
@Before(value = "@annotation(aop.annotation.RoleCheck)")
public void roleCheckByExecution(JoinPoint joinPoint) {
log.info("RoleCheckAspect.roleCheckByExecution");
final MethodSignature signature = (MethodSignature) joinPoint.getSignature();
final Method method = signature.getMethod();
final RoleCheck annotation = method.getAnnotation(RoleCheck.class);
final String methodRole = annotation.value();
log.info("method role = {}", methodRole);
final Account account = (Account) joinPoint.getArgs()[0];
final String accountRole = account.getRole();
log.info("account role = {}", accountRole);
if (methodRole.equals(accountRole)) {
log.info("request success : method = {} : username = {} : email = {} : role = {}",
method.getName(), account.getUsername(), account.getEmail(), accountRole);
return;
}
log.info("request failed : method = {} : username = {} : email = {} : role = {}",
method.getName(), account.getUsername(), account.getEmail(), accountRole);
throw new RuntimeException("권한 부족");
}
}
이제 AccountService 메서드에 @RoleCheck Annotation을 붙이고 테스트를 다시 실행합니다.
@Logging
@RoleCheck(value = "USER")
public void userRoleCheck(Account account) {
log.info("{}", account.toString());
}
------
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : method role = USER
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : account role = USER
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : request success : method = userRoleCheck : username = lob : email = coffeescript@kakao.com : role = USER
INFO 37028 --- [main] aop.domain.AccountService : Account{username='lob', password='password', email='coffeescript@kakao.com', role='USER'}
INFO 37028 --- [main] aop.aspect.LoggingAspect : logging : component = aop.domain.AccountService : execution time(ms) = 19
Aspect가 정상 동작하는 것을 확인할 수 있습니다.
이제 반대의 경우를 간단히 테스트해보겠습니다.
@RoleCheck
public void adminRoleCheck(Account account) {
log.info("{}", account.toString());
}
------
@Test
void account_throw() {
final Account account = new Account("lob", "password", "coffeescript@kakao.com", "USER");
assertThrows(RuntimeException.class, () -> accountService.adminRoleCheck(account));
}
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : method role = ADMIN
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : account role = USER
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : request failed : method = adminRoleCheck : username = lob : email = coffeescript@kakao.com : role = USER
AccountService의 메서드는 호출되지 않고 Exception이 발생하였음을 확인할 수 있습니다.
이론을 제외하고 단순한 구현 코드만을 작성하다보니 코드 블럭과 "~다"만 가득한 글이 만들어졌네요.. ㅋㅋ;
해당 글과 관련하여 궁금하신 부분은 댓글을 통해 문의해주시길 바랍니다.
추천, 참고 링크
- https://www.baeldung.com/spring-aop-get-advised-method-info
- https://www.baeldung.com/spring-aop-pointcut-tutorial
- https://gmoon92.github.io/spring/aop/2019/04/01/spring-aop-mechanism-with-self-invocation.html