Spring(고급) - 스프링 AOP 개념, 구현

Kwon Yongho·2023년 6월 24일
0

Spring

목록 보기
27/37
post-thumbnail
  1. AOP 소개 - 핵심 기능과 부가 기능
  2. AOP 소개 - 애스펙트
  3. AOP 적용 방식
  4. AOP 구현

1. AOP 소개 - 핵심 기능과 부가 기능

  • 핵심 기능은 해당 객체가 제공하는 고유의 기능이다. 예를 들어서 OrderService의 핵심 기능은 주문 로직이다.
  • 부가 기능은 핵심 기능을 보조하기 위해 제공되는 기능이다. 예를 들어서 로그 추적 로직, 트랜잭션 기능이 있다.

여러 곳에서 공통으로 사용하는 부가 기능

  • 이러한 부가 기능은 횡단 관심사(cross-cutting concerns)가 된다. 하나의 부가 기능이 여러 곳에 동일하게 사용된다는 뜻이다.

부가 기능 적용 문제

  • 이런 부가 기능을 여러 곳에 적용하려면 너무 번거롭다.
  • 부가 기능을 별도의 유틸리티 클래스로 만든다고 해도, 해당 유틸리티 클래스를 호출하는 코드가 결국 필요하다.
  • 부가 기능이 구조적으로 단순 호출이 아니라 try~catch~finally같은 구조가 필요하다면 더욱 복잡해진다. (예: 실행 시간 측정)
  • 더 큰 문제는 수정이다. 만약 부가 기능에 수정이 발생하면, 100개의 클래스 모두를 하나씩 찾아가면서 수정해야 한다.

정리

  • 부가 기능을 적용할 때 아주 많은 반복이 필요하다.
  • 부가 기능이 여러 곳에 퍼져서 중복 코드를 만들어낸다.
  • 부가 기능을 변경할 때 중복 때문에 많은 수정이 필요하다.
  • 부가 기능의 적용 대상을 변경할 때 많은 수정이 필요하다.

2. AOP 소개 - 애스펙트

  • 부가 기능과 부가 기능을 어디에 적용할지 선택하는 기능을 합해서 하나의 모듈로 만들었는데 이것이 바로 애스펙트(aspect)이다.
  • @Aspect바로 그것이다. 그리고 스프링이 제공하는 어드바이저도 어드바이스(부가 기능)과 포인트컷(적용 대상)을 가지고 있어서 개념상 하나의 애스펙트이다.
  • 애스펙트를 사용한 프로그래밍 방식을 관점 지향 프로그래밍 AOP(Aspect-Oriented Programming)이라 한다.

AspectJ 프레임워크

  • AOP의 대표적인 구현으로 AspectJ 프레임워크가 있다. 물론 스프링도 AOP를 지원하지만 대부분 AspectJ의 문법을 차용하고, AspectJ가 제공하는 기능의 일부만 제공한다.

기능

  • 자바 프로그래밍 언어에 대한 완벽한 관점 지향 확장
  • 횡단 관심사의 깔끔한 모듈화
    • 오류 검사 및 처리
    • 동기화
    • 성능 최적화(캐싱)
    • 모니터링 및 로깅

3. AOP 적용 방식

AOP를 사용할 때 부가 기능 로직은 어떤 방식으로 실제 로직에 추가될 수 있을까?

3가지 방법이 있다.

  • 컴파일 시점
  • 클래스 로딩 시점
  • 런타임 시점(프록시)

1. 컴파일 시점

  • .java소스 코드를 컴파일러를 사용해서 .class를 만드는 시점에 부가 기능 로직을 추가할 수 있다. 이때는 AspectJ가 제공하는 특별한 컴파일러를 사용해야 한다.
  • 이해하기 쉽게 풀어서 이야기하면 부가 기능 코드가 핵심 기능이 있는 컴파일된 코드 주변에 실제로 붙어 버린다고 생각하면 된다.
  • 참고로 이렇게 원본 로직에 부가 기능 로직이 추가되는 것을 위빙(Weaving)이라 한다.

단점

  • 컴파일 시점에 부가 기능을 적용하려면 특별한 컴파일러도 필요하고 복잡하다.

2. 클래스 로딩 시점

  • 자바를 실행하면 자바 언어는 .class파일을 JVM 내부의 클래스 로더에 보관한다. 이때 중간에서 .class파일을 조작한 다음 JVM에 올릴 수 있다.
  • 많은 모니터링 툴들이 이 방식을 사용한다. 이 시점에 애스펙트를 적용하는 것을 로드 타임 위빙이라 한다.

단점

  • 로드 타임 위빙은 자바를 실행할 때 특별한 옵션(java -javaagent)을 통해 클래스 로더 조작기를 지정해야 하는데, 이 부분이 번거롭고 운영하기 어렵다.

3. 런타임 시점

  • 런타임 시점은 컴파일도 다 끝나고, 클래스 로더에 클래스도 다 올라가서 이미 자바가 실행되고 난 다음을 말한다.
  • 스프링과 같은 컨테이너의 도움을 받고 프록시와 DI, 빈 포스트 프로세서 같은 개념들을 총 동원해야 한다. 이렇게 하면 최종적으로 프록시를 통해 스프링 빈에 부가 기능을 적용할 수 있다. 그렇다. 지금까지 우리가 학습한 것이 바로 프록시 방식의 AOP이다.

단점

  • 프록시를 사용하기 때문에 AOP 기능에 일부 제약이 있다.
  • BUT 스프링이 복잡한 코드를 자동으로 적용해준다.

AOP 적용 위치

  • 프록시 방식을 사용하는 스프링 AOP는 메서드 실행 지점에만 AOP를 적용할 수 있다.
    • 프록시는 메서드 오버라이딩 개념으로 동작한다. 따라서 생성자나 static 메서드, 필드 값 접근에는 프록시 개념이 적용될 수 없다.
    • 프록시를 사용하는 스프링 AOP의 조인 포인트는 메서드 실행으로 제한된다.
  • 프록시 방식을 사용하는 스프링 AOP는 스프링 컨테이너가 관리할 수 있는 스프링 빈에만 AOP를 적용할 수 있다.

중요

  • AspectJ를 사용하려면 공부할 내용도 많고, 자바 관련 설정(특별한 컴파일러, AspectJ 전용 문법, 자바 실행 옵션)도 복잡하다.
  • 반면에 스프링 AOP는 별도의 추가 자바 설정 없이 스프링만 있으면 편리하게 AOP를 사용할 수 있다.
  • AOP 기능만 사용해도 대부문의 문제를 해결할 수 있다. 따라서 스프링 AOP가 제공하는 기능을 학습하는 것에 집중하자.

4. AOP 구현

4-1. 예제 프로젝트 만들기

spring-aop라는 프로젝트를 새로 만들었습니다.

추가
build.gradle

implementation 'org.springframework.boot:spring-boot-starter-aop'

간단한 예제를 추가해보겠습니다.

OrderRepository

package com.example.springaop.order;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;

@Slf4j
@Repository
public class OrderRepository {
    public String save(String itemId) {
        log.info("[orderRepository] 실행");
        //저장 로직
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }
        return "ok";
    }
}

OrderService

package com.example.springaop.order;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
    public void orderItem(String itemId) {
        log.info("[orderService] 실행");
        orderRepository.save(itemId);
    }
}

AopTest

package com.example.springaop;

import com.example.springaop.order.OrderRepository;
import com.example.springaop.order.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
@SpringBootTest
public class AopTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void aopInfo(){
        log.info("isAopProxy, orderService={}", AopUtils.isAopProxy(orderService));
        log.info("isAopProxy, orderRepository={}", AopUtils.isAopProxy(orderRepository));
    }

    @Test
    void success(){
        orderService.orderItem("itemA");
    }

    @Test
    void exception() {
        assertThatThrownBy(() -> orderService.orderItem("ex")).isInstanceOf(IllegalStateException.class);
    }
}
  • AopUtils.isAopProxy: AOP 프록시가 적용 되었는지 확인 가능

4-2. 스프링 AOP 구현 시작

@Aspect를 사용하는 방법

AspectV1

package com.example.springaop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;

@Slf4j
@Aspect
public class AspectV1 {

    @Around("execution(* com.example.springaop.order..*(..))")
    public Object dolog(ProceedingJoinPoint joinPoint) throws  Throwable{
        log.info("[log] {}", joinPoint.getSignature()); // join 포인트 시그니처
        return joinPoint.proceed();
    }
}
  • @Around애노테이션의 값인 execution(* hello.aop.order..*(..))는 포인트컷이 된다.
  • @Around애노테이션의 메서드인 doLog는 어드바이스(Advice)가 된다.
  • 이제 OrderService, OrderRepository의 모든 메서드는 AOP 적용의 대상이 된다.

AopTest에 @Import(AspectV1.class)추가

@Aspect는 애스펙트라는 표식이지 컴포넌트 스캔이 되는 것은 아니다. 따라서 AspectV1를 AOP로 사용하려면 스프링 빈으로 등록해야 한다.

스프링 빈으로 등록하는 방법

  • @Bean을 사용해서 직접 등록
  • @Component컴포넌트 스캔을 사용해서 자동 등록
  • @Import주로 설정 파일을 추가할 때 사용(@Configuration)

4-3. 포인트컷 분리

@Around에 포인트컷 표현식을 직접 넣을 수 도 있지만, @Pointcut애노테이션을 사용해서 별도로 분리할 수 도 있다.

AspectV2

package com.example.springaop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Slf4j
@Aspect
public class AspectV2 {

    @Pointcut("execution(* com.example.springaop.order..*(..))") // 포인트컷 포현식
    private void allOrder(){} // 포인트컷 시그니처

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }
}

@Pointcut

  • @Pointcut 에 포인트컷 표현식을 사용한다.
  • 메서드 이름과 파라미터를 합쳐서 포인트컷 시그니처(signature)라 한다.
  • 포인트컷 시그니처는 allOrder()이다. 이름 그대로 주문과 관련된 모든 기능을 대상으로 하는 포인트컷이다.

4-4. 어드바이스 추가

  • 조금 더 복잡한 예제를 만들어보자.
  • 트랜잭션을 적용하는 코드도 추가 (기능이 동작하는 것 처럼만)

AspectV3

package com.example.springaop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Slf4j
@Aspect
public class AspectV3 {

    @Pointcut("execution(* com.example.springaop.order..*(..))")
    public void allOrder(){}

    //클래스 이름 패턴이 *Service
    @Pointcut("execution(* *..*Service.*(..))")
    private void allService(){}

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }

    // com.example.springaop.order 패키와 하위 패키지 이면서 클래스 이름 패턴이 *Service
    @Around("allOrder() && allService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable{
        try{
            log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e){
            log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
            throw e;
        } finally {
            log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
        }
    }
}
  • allService()포인트컷은 타입 이름 패턴이 *Service를 대상으로 하는데 쉽게 이야기해서 XxxService처럼 Service로 끝나는 것을 대상으로 한다. *Servi*과 같은 패턴도 가능하다.
  • @Around("allOrder() && allService()") 포인트컷은 이렇게 조합할 수 있다. &&(AND), ||(OR), !(NOT) 3가지 조합이 가능하다.
  • 결과적으로 doTransaction()어드바이스는 OrderService에만 적용된다.
  • doLog()어드바이스는 OrderService, OrderRepository에 모두 적용된다.

success()

exception()

orderService에는 doLog(), doTransaction() 두가지 어드바이스가 적용되어 있고,
orderRepository에는 doLog() 하나의 어드바이스만 적용된 것을 확인할 수 있다.

AOP 적용 후 실행순서

[ doLog() -> doTransaction() ] -> orderService.orderItem() -> [ doLog() ] -> orderRepository.save()

4-5. 포인트컷 참조

Pointcuts

package com.example.springaop.order.aop;

import org.aspectj.lang.annotation.Pointcut;

public class Pointcuts {

    @Pointcut("execution(* com.example.springaop.order..*(..))")
    public void allOrder(){}

    //타입 패턴이 *Service
    @Pointcut("execution(* *..*Service.*(..))")
    public void allService(){}
    
    //allOrder && allService
    @Pointcut("allOrder() && allService()")
    public void orderAndService(){}
}

AspectV4Pointcut

package com.example.springaop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;

@Slf4j
@Aspect
public class AspectV4Pointcut {

    @Around("com.example.springaop.order.aop.Pointcuts.allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }

    @Around("com.example.springaop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable
    {
        try {
            log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e) {
            log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
            throw e;
        } finally {
            log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
        }
    }
}
  • 사용하는 방법은 패키지명을 포함한 클래스 이름과 포인트컷 시그니처를 모두 지정하면 된다.
  • 포인트컷을 여러 어드바이스에서 함께 사용할 때 이 방법을 사용하면 효과적이다.

테스트 결과

잘 동작한다.

4-6. 어드바이스 순서

  • 어드바이스는 기본적으로 순서를 보장하지 않는다.
  • 순서를 지정하고 싶으면 @Aspect적용 단위로 org.springframework.core.annotation.@Order애노테이션을 적용해야 한다.
  • 문제는 이것을 어드바이스 단위가 아니라 클래스 단위로 적용할 수 있다는 점이다. 그래서 지금처럼 하나의 애스펙트에 여러 어드바이스가 있으면 순서를 보장 받을 수 없다.
  • 따라서 애스펙트를 별도의 클래스로 분리해야 한다.

AspectV5Order

package com.example.springaop.order.aop;

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.core.annotation.Order;

@Slf4j
public class AspectV5Order {
    
    @Aspect
    @Order(2)
    public static class LogAspect {
        @Around("com.example.springaop.order.aop.Pointcuts.allOrder()")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[log] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }
    }
    
    @Aspect
    @Order(1)
    public static class TxAspect {
        @Around("com.example.springaop.order.aop.Pointcuts.orderAndService()")
        public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
            try {
                log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
                Object result = joinPoint.proceed();
                log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
                return result;
            } catch (Exception e) {
                log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
                throw e;
            } finally {
                log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
            }
        }
    }
}
  • 하나의 애스펙트 안에 있던 어드바이스를 LogAspect, TxAspect애스펙트로 각각 분리했다. 그리고 각 애스펙트에 @Order애노테이션을 통해 실행 순서를 적용했다. 숫자가 작을 수록 먼저 실행된다.

AopTest 변경

@Import({AspectV5Order.LogAspect.class, AspectV5Order.TxAspect.class})


실행 결과를 보면 트랜잭션 어드바이스가 먼저 실행되는 것을 확인할 수 있다.

4-7. 어드바이스 종류

어드바이스 종류

  • @Around: 메서드 호출 전후에 수행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환 값 변환, 예외 변환 등이 가능
  • @Before: 조인 포인트 실행 이전에 실행
  • @AfterReturning: 조인 포인트가 정상 완료후 실행
  • @AfterThrowing: 메서드가 예외를 던지는 경우 실행
  • @After: 조인 포인트가 정상 또는 예외에 관계없이 실행(finally)

예제로 한번 확인해보자

AspectV6Advice

package com.example.springaop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;

@Slf4j
@Aspect
public class AspectV6Advice {
    
    @Around("com.example.springaop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable
    {
        try {
            // @Before
            log.info("[around][트랜잭션 시작] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            // @AfterReturning
            log.info("[around][트랜잭션 커밋] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e) {
            // @AfterThrowing
            log.info("[around][트랜잭션 롤백] {}", joinPoint.getSignature());
            throw e;
        } finally {
            // @After
            log.info("[around][리소스 릴리즈] {}", joinPoint.getSignature());
        }
    }
    
    @Before("com.example.springaop.order.aop.Pointcuts.orderAndService()")
    public void doBefore(JoinPoint joinPoint) {
        log.info("[before] {}", joinPoint.getSignature());
    }
    
    @AfterReturning(value = "com.example.springaop.order.aop.Pointcuts.orderAndService()", returning = "result")
    public void doReturn(JoinPoint joinPoint, Object result) {
        log.info("[return] {} return={}", joinPoint.getSignature(), result);
    }
    
    @AfterThrowing(value = "com.example.springaop.order.aop.Pointcuts.orderAndService()", throwing = "ex")
    public void doThrowing(JoinPoint joinPoint, Exception ex) {
        log.info("[ex] {} message={}", joinPoint.getSignature(), ex.getMessage());
    }
    
    @After(value = "com.example.springaop.order.aop.Pointcuts.orderAndService()")
    public void doAfter(JoinPoint joinPoint) {
        log.info("[after] {}", joinPoint.getSignature());
    }
}

주석을 확인하면 조금 더 잘 이해 할 수 있다.

ProceedingJoinPoint 인터페이스의 주요 기능

  • proceed(): 다음 어드바이스나 타켓을 호출한다.

@Before

  • @Around와 다르게 작업 흐름을 변경할 수는 없다.
  • @AroundProceedingJoinPoint.proceed()를 호출해야 다음 대상이 호출된다. 만약 호출하지 않으면 다음 대상이 호출되지 않는다.
  • 반면에 @BeforeProceedingJoinPoint.proceed()자체를 사용하지 않는다. 메서드 종료시 자동으로 다음 타켓이 호출된다.

@AfterReturning

  • returning속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야 한다.
  • returning절에 지정된 타입의 값을 반환하는 메서드만 대상으로 실행한다. (부모 타입을 지정하면 모든 자식 타입은 인정된다.)
  • @Around와 다르게 반환되는 객체를 변경할 수는 없다. 반환 객체를 변경하려면 @Around를 사용해야 한다.

@AfterThrowing

  • throwing속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야 한다.
  • throwing절에 지정된 타입과 맞는 예외를 대상으로 실행한다. (부모 타입을 지정하면 모든 자식 타입은 인정된다.)

@After

  • 메서드 실행이 종료되면 실행된다. (finally를 생각하면 된다.)
  • 정상 및 예외 반환 조건을 모두 처리한다.
  • 일반적으로 리소스를 해제하는 데 사용한다

@Around

  • 메서드의 실행의 주변에서 실행된다. 메서드 실행 전후에 작업을 수행한다.
  • 가장 강력한 어드바이스
    • 조인 포인트 실행 여부 선택 joinPoint.proceed()호출 여부 선택
    • 전달 값 변환: joinPoint.proceed(args[])
    • 반환 값 변환
    • 예외 변환
    • 트랜잭션 처럼 try ~ catch~ finally모두 들어가는 구문 처리 가능
  • 어드바이스의 첫 번째 파라미터는 ProceedingJoinPoint를 사용해야 한다.
  • proceed()를 통해 대상을 실행한다.
  • proceed()를 여러번 실행할 수도 있음(재시도)

테스트 확인

순서

  • 스프링은 5.2.7 버전부터 동일한 @Aspect 안에서 동일한 조인포인트의 우선순위를 정했다.
  • 어드바이스가 적용되는 순서는 이렇게 적용되지만, 호출 순서와 리턴 순서는 반대이다.

장점

  • @Around외에 다른 어드바이스가 존재하는 이유
    @Around("hello.aop.order.aop.Pointcuts.orderAndService()")
    public void doBefore(ProceedingJoinPoint joinPoint) {
        log.info("[before] {}", joinPoint.getSignature());
    }
  • 이 코드는 타켓을 호출하지 않는 문제가 있다.
  • @Around는 항상 joinPoint.proceed()를 호출해야 한다.
  • 만약 실수로 호출하지 않으면 타켓이 호출되지 않는 치명적인 버그가 발생한다.
  • @BeforejoinPoint.proceed()를 호출하는 고민을 하지 않아도 된다.
  • @Around가 가장 넓은 기능을 제공하는 것은 맞지만, 실수할 가능성이 있다. 반면에 @Before, @After 같은 어드바이스는 기능은 적지만 실수할 가능성이 낮고, 코드도 단순하다.

참고
김영한: 스프링 핵심 원리 - 고급편(인프런)
Github - https://github.com/b2b2004/Spring_ex

0개의 댓글