Programming

소소한 글 : Spring AOP 예제를 차근차근 구현해보기

Junior Lob! 2021. 9. 22. 00:16

 

이 글은 간단한 구현 예제만을 포함하고 있습니다. 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이 발생하였음을 확인할 수 있습니다.

 

 

이론을 제외하고 단순한 구현 코드만을 작성하다보니 코드 블럭과 "~다"만 가득한 글이 만들어졌네요.. ㅋㅋ;

해당 글과 관련하여 궁금하신 부분은 댓글을 통해 문의해주시길 바랍니다.

 

 

추천, 참고 링크