안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

밤이 늦었지만,  AOP에 관한 내용을 나눠서 정리하고자 글을 작성하게 되었습니다.

 

AOP를 알기 전에는, 핵심 로직과 부가 기능을 분리하지 않고 작성하다 보니, 추후 리팩토링 하는 과정에서 난관에 부딪친 적이 있습니다. 영한님 스프링 핵심 원리 고급편을 수강하고 나니, AOP를 활용하는 방법을 배울 수 있었고, 앞 선 문제를 해결하는데 많은 도움을 받을 수 있었습니다.

 

이제 본격적으로 SpringBoot AOP과 AOP를 사용하는데 활용되는 어노테이션을 정리하는 글을 이어 나가도록 하겠습니다.

 

1. AOP(Aspect-Oriented Programming)란 ?

https://tecoble.techcourse.co.kr/post/2021-06-25-aop-transaction/

AOP란 애플리케이션을 바라보는 관점을 하나하나의 기능에서 횡단 관심사 관점으로 보는 것을 의미합니다. 전통적인 객체 지향 설계 방식을 따라 기능을 설계하면 높은 응집성과 낮은 결합도를 유지할 수 있습니다. 하지만, 각 객체에 걸쳐서 공통적으로 사용되는 부가 기능들이 존재합니다. 로그를 남기거나 트랜잭션 처리를 하는 등 애플리케이션 전반에 걸쳐 흩어져 있는 공통적인 관심사를 하나로 모듈화 하여 관리하는 것을 관점 지향 프로그래밍 AOP라고 부릅니다. 

 

 

2.  AOP 적용 방법

컴파일 실제 대상 코드에 애스펙트를 통한 부가 기능 호출 코드가 포함됩니다. (AspectJ 직접 사용)
클래스 로딩 실제 대상 코드에 애스펙트를 통한 부가 기능 호출 코드가 포함됩니다. (AspectJ 직접 사용)
런타임  실제 대상 코드는 크대로 유지하되, 프록시를 통해 부가 기능이 적용됩니다. (스프링 AOP는 이 방식을 사용)

 

 

3.  AOP 적용 위치

AOP가 적용될 수 있는 적용 가능한 지점을 조인 포인트라 부르는데, 생성자, 필드값 접근, static 메서드 접근, 메서드 실행이 조인포인트가 될 수 있습니다.

AspectJ를 사용해서 컴파일 시점과 클래스 로딩 시점에 적용하는 AOP는 바이트 코드를 실제 조작하기 때문에 해당 기능을 모두 적용할 수 있습니다.  하지만 프록시 방식을 사용하는 스프링 AOP는 메서드 실행 시점에만 AOP를 적용할 수 있습니다.

--> 프록시는 메서드 오버라이딩 개념으로 동작합니다. 따라서, 생성자, static 메서드, 필드 값 접근에는 프록시 개념이 적용될 수 없습니다.

 

 

4. 용어 정리

포인트컷(Pointcut) 조인 포인트 중에서 어드바이스가 적용될 위치, AspectJ 표현식을 사용해서 지정
타겟(Target) 어드바이스를 받는 객체, 포인트컷으로 결정
어드바이스(Advice) 특정 조인 포인트에서 Aspect에 의해 취해지는 조치 (Around, Before 등 어드바이스 존재)
애스팩트(Aspect) 어드바이스와 포인트컷을 모듈화한 기능
어드바이저(Advisor) 하나의 어드바이스에 하나의 포인트컷으로 구성
위빙(Weaving) 포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용한 것
위빙으로 핵심 기능 코드에 영향을 주지 않고 부가 기능을 추가할 수 있음

 

 

5. 스프링에 AOP 적용하기

스프링에 AOP를 적용하기 위해, 크게 사용될 기능은 @Aspect와 @Around (어드바이스 종류)입니다.

@Aspect는 어노테이션 기반 프록시를 적용할 때 필요합니다.

@Around는 표현식을 value의 값으로 설정하고,  해당 어노테이션을 받는 메서드는 어드바이스가 됩니다.

이를 바탕으로, BlogRepository와 BlogService(Impl)에 적용되는 AOP를 확인하도록 하겠습니다.

 

<Blog를 저장하고 로드하는 기능을 가진 간단한 Repository, Service>

@Slf4j
@Repository
public class BlogRepository {

    public void save(String content) {

        log.info("[BlogRepository.save 실행]");
        if (content == null) {
            log.info("[BlogRepository.save 에러]");
            throw new IllegalArgumentException();
        }

        log.info("[BlogRepository.save 저장]");
    }

    public String load(Long blogId) {

        log.info("[BlogRepository.load 실행]");

        if (blogId == null) {
            log.info("[BlogRepository.load 에러]");
            throw new IllegalArgumentException();
        }

        log.info("[BlogRepository.load 성공]");
        return "안녕하세요";
    }
}
public interface BlogService {
    String load(Long blogId);
    void save(String content);
}
@Slf4j
@Service
@RequiredArgsConstructor
public class BlogServiceImpl implements BlogService {

    private final BlogRepository blogRepository;

    @Override
    public String load(Long blogId) {

        log.info("[BlogService.load 실행]");
        String content = blogRepository.load(blogId);
        log.info("[BlogService.load 완료] content = {}", content);

        return content;
    }


    @Override
    public void save(String content) {

        log.info("[BlogService.save 실행]");
        blogRepository.save(content);
        log.info("[BlogService.save 완료]");

    }
}

 

@Around  어노테이션의 AspectJ 표현식에 사용할 Pointcut을 클래스로 분리하여 Repository와 Service에 중복되는 메서드에 공통적으로 처리하는 Pointcuts 클래스입니다.

@Slf4j
public class Pointcuts {

    @Pointcut("execution(* hello.aop.blog.*.save(..))")
    public void allSave() {}

    @Pointcut("execution(* hello.aop.blog.*.load(..))")
    public void allLoad() {}

    @Pointcut("execution(* hello.aop.blog.BlogService.*(..))")
    public void allService() {}

}

 

Pointcuts을 생성한 후 AOP를 적용할 어드바이스에 내부 static 클래스를 생성하였습니다.

@Around와 @Before @After 등의 다양한 어드바이스 어노테이션을 활용하여 차이점을 확인하였습니다. @Around는 ProceedingJoinPoint를 파라미터로 받는데, 반드시 joinPoint의 proceed()를 실행하여, 해당 핵심 로직이 수행될 수 있도록 설정하여야 합니다. @Around가 아닌 어드바이스 종류의 어노테이션은 부가 기능을 활용하는 데 사용됩니다. 이는 기능을 세분화하여 런타임 시점에 발생할 수 있는 장애를 방지하는 역할을 수행할 수 있습니다.

 

@Slf4j
public class ApplyAspect {

    // --- 중략 ---

    @Aspect
    public static class AllLoad {

        @Before("hello.aop.blog.Pointcuts.allLoad()")
        public void loadBefore(JoinPoint joinPoint) throws Throwable {
            log.info("[Before AllLoad JoinPoint]");
        }

        @AfterReturning(value = "hello.aop.blog.Pointcuts.allLoad()", returning = "result")
        public void loadReturn(JoinPoint joinPoint, Object result) {
            log.info("[AfterReturning AllLoad ProceedingJoinPoint]");
            log.info("[AfterReturning AllLoad joinPoint] Signature = {}", joinPoint.getSignature());
         	// 로그 중략 
            log.info("[AfterReturning AllLoad joinPoint] class = {}", joinPoint.getClass());
            log.info("[AfterReturning AllLoad Result] result = {}", result.toString());
        }

        @AfterThrowing(value = "hello.aop.blog.Pointcuts.allLoad()", throwing = "e")
        public void loadThrowing(JoinPoint joinPoint, Exception e) {
            log.info("[AfterThrowing AllLoad Exception] message = {}", e);
        }

        @After(value = "hello.aop.blog.Pointcuts.allLoad()")
        public void loadAfter(JoinPoint joinPoint) {
            log.info("[After AllLoad joinPoint] Signature = {}", joinPoint.getSignature());
        }
    }

    @Aspect
    public static class AllService {

        @Around("hello.aop.blog.Pointcuts.allService()")
        public Object saveAndLoadAround(ProceedingJoinPoint joinPoint) throws  Throwable {

            try{

                log.info("[Around AllService ProceedingJoinPoint]");
                Object result = joinPoint.proceed();
                log.info("[Around AllService ProceedingJoinPoint] result = {}", result);

                return result;

            } catch (Exception e) {

                log.info("[Around AllService Exception] message = {}", e.getMessage());
                throw e;
            }
        }
    }
}

 

 

6. 테스트 확인하기

@Slf4j
@SpringBootTest
@Import({ApplyAspect.AllLoad.class, ApplyAspect.AllSave.class, ApplyAspect.AllService.class})
class ApplyAspectTest {

    @Autowired
    BlogRepository blogRepository;

    @Autowired
    BlogService blogService;

    @Test
    @DisplayName("BlogRepository save AOP 로그 확인")
    public void saveRepository() throws Exception {
        blogRepository.save("안녕하세요");
    }

    @Test
    @DisplayName("BlogRepository save 실패 AOP 로그 확인")
    public void failSaveRepository() throws Exception {
        Assertions.assertThatThrownBy(() -> blogRepository.save(null))
                        .isInstanceOf(IllegalArgumentException.class);
    }

   /*
   * 중략 
   */
   

    @Test
    @DisplayName("BlogService load AOP 로그 확인")
    public void loadService() throws Exception {
        String load = blogService.load(14L);
        assertThat(load).isEqualTo("안녕하세요");
    }

    @Test
    @DisplayName("BlogService load 실패 AOP 로그 확인")
    public void failLoadService() throws Exception {
        Assertions.assertThatThrownBy(() -> blogService.load(null))
                .isInstanceOf(IllegalArgumentException.class);
    }

}

AOP를 설정한 결과, 핵심 로직에는 작성하지 않았던 부가 기능 로그가 런타임 시점에 프록시가 적용되어 로그가 기록된 것을 확인할 수 있습니다. 또한 @Around ->  @Before -> @AfterReturing 순서로 출력된 것을 확인할 수 있었습니다.

[Around AllService ProceedingJoinPoint]
[BlogService.load 실행]
[Before AllLoad JoinPoint]
// 중략
[AfterReturning AllLoad joinPoint] Signature //중략

 

 

7.  로그 기록 분석하기 

<제 생각이 많이 담긴 부분이라, 정확하지 않을 수 있습니다 !!

제가 잘못 분석한 부분이 있다면 댓글 적어주시면 감사히 배우겠습니다.!!>

 

Signature: 반환 타입, 경로, 클래스 정보, 메서드 정보, 파라미터 타입을 확인할 수 있었습니다.

[AfterReturning AllLoad joinPoint] Signature = String hello.aop.blog.BlogServiceImpl.load(Long)

>> [AfterReturning AllLoad joinPoint] Signature = String hello.aop.blog.BlogServiceImpl.load(Long)

 

Target: 어드바이스를 받는 객체가 BlogServiceImpl의 프록시 형태로 출력되었는데,

이는 런타임 시점에 핵심 로직에 프록시가 적용되어 부가 기능이 추가되는 것과 연관 지어 생각할 수 있었습니다.

>> [AfterReturning AllLoad joinPoint] Target = hello.aop.blog.BlogServiceImpl@7d979d34

 

Args: BlogService.load()의 파라미터로 14를 입력하였는데,

joinPoint에서도 파라미터 정보를 얻을 수 있었습니다.

이를 확장하면 앞 선 security나 transaction 처리 등에도 활용될 수 있습니다.

>> [AfterReturning AllLoad joinPoint] Args = 14

 

class: 한 가지 놀라운 점은 ProceedingJoinPoint와 JoinPoint 모두 동일한

MethodInvocationProceedingJoinPoint 라는 구현체를 출력하였습니다.

>>[Around AllService ProceedingJoinPoint] class = class org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint
>>[AfterReturning AllLoad joinPoint] class = class org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint

 

이는, 비록 같은 구현체를 사용하지만 파라미터로 사용되는 인터페이스가 @Around는 ProceedingJoinPoint 이므로 다형성의 원리로 인해 ProceedingJoinPoint로 캐스팅된 MethodInvocationProceedingJoinPoint만 proceeding() 메서드를 호출할 수 있는 방식이라고 생각하게 되었습니다.

 

스프링 AOP는 정말 멋진 스프링의 기능인 것 같습니다. 어느새 정리하다 보니 두 시간이 훌쩍 지나갔지만 예제를 작성하고 로그를 분석하다보니, 새롭게 더 많은 부분을 알게 되었습니다.!

감사합니다.!

 

참고 자료: 인프런 김영한 님 스프링 핵심 원리 고급편 

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

+ Recent posts