6 분 소요

 인프런 스프링 핵심 원리 - 고급편을 학습하고 정리한 내용 입니다.

프로젝트 생성

https://start.spring.io/

  • 프로젝트 선택
    • Project : Gradle Project
    • Language : Java
    • Spring Boot : 3.3.2
  • Project Metadata
    • Group : hello
    • Artifact : aop
    • Name : aop
    • Packagename : hello.aop
    • Packaging : Jar
    • Java : 21
  • Dependencies: Lombok

이번에는 스프링 웹 기술은 따로 사용하지 않는다. Lombok만 추가하면 된다.

참고로 스프링 프레임워크의 핵심 모듈들은 별도의 설정이 없어도 자동으로 추가된다. 추가로 AOP 기능을 사용하기 위해서 다음을 build.gradle에 직접 추가하자.

1
2
3
4
5
implementation 'org.springframework.boot:spring-boot-starter-aop'

//테스트에서 lombok 사용 
testCompileOnly 'org.projectlombok:lombok' 
testAnnotationProcessor 'org.projectlombok:lombok'

스프링 부트 실행 로그가 나오면 성공(스프링 웹 프로젝트를 추가하지 않아서 서버가 실행되지는 않는다.)

예제 프로젝트 만들기

AOP를 적용할 예제 프로젝트를 만들어보자.

  • OrderRepository
  • OrderService
  • AopTest

order패키지를 만들어서 OrderRepository, OrderService를 만들었다.

OrderRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Slf4j  
@Repository  
public class OrderRepository {  
  
    public String save(String itemId) {  
        log.info("[orderRepository] 실행");  
  
        // 저장로직  
        if (itemId.equals("ex")) {  
            throw new IllegalStateException("예외 발생!");  
        }  
        return "ok";  
    }  
}

OrderService

1
2
3
4
5
6
7
8
9
10
11
12
@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);  
    }  
}

둘 다 별거 없는 AOP를 적용하기 위한 코드다.

이제 테스트 코드로 간단히 실행해보자.

AopTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@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 프록시가 적용 되었는지 확인할 수 있다.

현재 AOP 관련 코드를 작성하지 않았으므로 프록시가 적용되지 않고, 결과도 false를 반환해야 정상이다.

별 무리 없이 이해할 수 있다.

스프링 AOP 구현 1 - 시작

스프링 AOP를 구현하는 일반적인 방법은 앞서 학습한 @Aspect를 사용하는 방법이다.

hello.aop.order.aop 패키지에 AOP 관련 코드를 작성한다.

AspectV1

1
2
3
4
5
6
7
8
9
10
11
@Slf4j  
@Aspect  
public class AspectV1 {  
  
    //hello.aop.order 패키지와 하위 패키지  
    @Around("execution(* hello.aop.order..*(..))")  
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {  
        log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처  
        return joinPoint.proceed();  
    }  
}
  • @Around 애노테이션의 값인 execution(* hello.aop.order..*(..))포인트컷이 된다.
  • @Around 애노테이션의 메서드인 doLog어드바이스(Advice)가 된다.
  • execution(* hello.aop.order..*(..))hello.aop.order패키지와 그 하위 패키지(..)를 지정하는 AspectJ 포인트컷 표현식이다.
  • 이제 OrderService, OrderRepository 의 모든 메서드는 AOP 적용의 대상이 된다. 참고로 스프링은 프록시 방식의 AOP를 사용하므로 프록시를 통하는 메서드만 적용 대상이 된다.

참고
스프링 AOP는 AspectJ의 문법을 차용하고, 프록시 방식의 AOP를 제공한다. AspectJ를 직접 사용하는 것이 아니다.
스프링 AOP를 사용할 때는 @Aspect 애노테이션을 주로 사용하는데, 이 애노테이션도 AspectJ가 제공하는 애노테이션이다.

참고
@Aspect를 포함한 org.aspectj 패키지 관련 기능은 aspectjweaver.jar 라이브러리가 제공하는 기능이다. 앞서 build.gradlespring-boot-starter-aop를 포함했는데, 이렇게 하면 스프링의 AOP 관련 기능과 함께 aspectjweaver.jar도 함께 사용할 수 있게 의존 관계에 포함된다. 그런데 스프링에서는 AspectJ가 제공하는 애노테이션이나 관련 인터페이스만 사용하는 것이고, 실제 AspectJ 가 제공하는 컴파일, 로드타임 위버 등을 사용하는 것은 아니다. 스프링은 지금까지 우리가 학습한 것 처럼 프록시 방식의 AOP를 사용한다.

AopTest - 추가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Slf4j  
@SpringBootTest  
@Import(AspectV1.class)  //<--------- 추가가
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);  
    }  
}

@Aspect는 애스펙트라는 표식이지 컴포넌트 스캔이 되는 것은 아니다.

따라서 AspectV1를 AOP로 사용하려면 스프링 빈으로 등록해야 한다.

스프링 빈으로 등록하는 방법은 다음과 같다.

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

테스트를 다시 돌려보니 AopUtils.isAopProxy(...)true가 나온다.

스프링 AOP 구현 2 - 포인트컷 분리

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

hello.aop.order.aop.AspectV2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Slf4j  
@Aspect  
public class AspectV2 {  
  
    //hello.aop.order 패키지와 하위 패키지  
    @Pointcut("execution(* hello.aop.order..*(..))") //pointcut expression  
    private void allOrder(){} //pointcut signature  
  
    @Around("allOrder()")  
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {  
        log.info("[log] {}", joinPoint.getSignature());  
        return joinPoint.proceed();  
    }  
}

@Pointcut

  • @Pointcut에 포인트컷 표현식을 사용한다.
  • 메서드 이름과 파라미터를 합쳐서 포인트컷 시그니처(signature)라 한다.
  • 메서드의 반환 타입은 void여야 한다.
  • 코드 내용은 비워둔다.
  • 포인트컷 시그니처는 allOrder()이다. 이름 그대로 주문과 관련된 모든 기능을 대상으로 하는 포인트컷이다.
  • @Around 어드바이스에서는 포인트컷을 직접 지정해도 되지만, 포인트컷 시그니처를 사용해도 된다. 여기서는 @Around("allOrder()") 를 사용한다.
  • private, public 같은 접근 제어자는 내부에서만 사용하면 private을 사용해도 되지만, 다른 애스팩트에서 참고하려면 public을 사용해야 한다.

결과적으로 AspectV1과 같은 기능을 수행한다. 이렇게 분리하면 하나의 포인트컷 표현식을 여러 어드바이스에서 함께 사용할 수 있다.

다른 클래스에 있는 외부 어드바이스에서도 포인트컷을 함께 사용할 수 있다.

스프링 AOP 구현 3 - 어드바이스 추가

이번에는 조금 복잡한 예제를 만들어보자.

앞서 로그를 출력하는 기능에 추가로 트랜잭션을 적용하는 코드도 추가해보자. 여기서는 진짜 트랜잭션을 실행하는 것은 아니다.

기능이 동작한 것 처럼 로그만 남기겠다.

트랜잭션 기능은 보통 다음과 같이 동작한다.

  • 핵심 로직 실행 직전에 트랜잭션을 시작
  • 핵심 로직 실행
  • 핵심 로직 실행에 문제가 없으면 커밋
  • 핵심 로직 실행에 예외가 발생하면 롤백

AspectV3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Slf4j  
@Aspect  
public class AspectV3 {  
  
    //hello.aop.order 패키지와 하위 패키지  
    @Pointcut("execution(* hello.aop.order..*(..))") //pointcut expression  
    private void allOrder(){} //pointcut signature  
  
    // 클래스 이름 패턴이 *Service    @Pointcut("execution(* *..*Service.*(..))")  
    private void allService(){}  
  
    @Around("allOrder()")  
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {  
        log.info("[log] {}", joinPoint.getSignature());  
        return joinPoint.proceed();  
    }  
  
    // hello.aop.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());  
        }  
    }  
}
  • allOrder() 포인트컷은 hello.aop.order 패키지와 하위 패키지를 대상으로 한다.
  • allService() 포인트컷은 타입 이름 패턴이 *Service를 대상으로 하는데 쉽게 이야기해서 XxxService처럼 Service로 끝나는 것을 대상으로 한다. *Servi*과 같은 패턴도 가능하다.
  • 여기서 타입 이름 패턴이라고 한 이유는 클래스, 인터페이스에 모두 적용되기 때문이다.

@Around("allOrder() && allService()")

  • 포인트컷은 이렇게 조합할 수 있다. && (AND), || (OR), ! (NOT) 3가지 조합이 가능하다.
  • hello.aop.order 패키지와 하위 패키지 이면서 타입 이름 패턴이 *Service인 것을 대상으로 한다.
  • 결과적으로 doTransaction()어드바이스는 OrderService에만 적용된다.
  • doLog() 어드바이스는 OrderService, OrderRepository에 모두 적용된다.

포인트컷이 적용된 AOP 결과는 다음과 같다.

  • orderService : doLog(), doTransaction() 어드바이스 적용
  • orderRepository : doLog() 어드바이스 적용

AopTest - 수정 및 결과

1
2
3
4
5
6
7
8
@Slf4j  
@SpringBootTest  
//@Import(AspectV1.class)  
//@Import(AspectV2.class)  
@Import(AspectV3.class)  
public class AopTest { 
	...
}

전체 실행 순서를 분석해보자.

AOP 적용 전

클라이언트 → orderService.orderItem()orderRepository.save()

AOP 적용 후

클라이언트 → [ doLog() → doTransaction() ]orderService.orderItem()[doLog()]orderRepository.save()

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

예외 상황에서는 트랜잭션 커밋 대신에 트랜잭션 롤백이 호출되는 것을 확인할 수 있다.

스프링 AOP 구현 4 - 포인트컷 참조

다음과 같이 포인트컷을 공용으로 사용하기 위해 별도의 외부 클래스에 모아두어도 된다.

참고로 외부에서 호출할 때는 포인트컷의 접근 제어자를 public으로 열어두어야 한다.

hello.aop.order.aop.Pointcuts

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Pointcuts {  
  
    //hello.aop.order 패키지와 하위 패키지  
    @Pointcut("execution(* hello.aop.order..*(..))") //pointcut expression  
    public void allOrder(){} //pointcut signature  
  
    // 클래스 이름 패턴이 *Service    @Pointcut("execution(* *..*Service.*(..))")  
    public void allService(){}  
  
    @Pointcut("allOrder() && allService()")  
    public void orderAndService(){}  
  
}

다음과 같이 하나의 클래스로 포인트컷들을 몰아놨다.

orderAndService() : allOrder() 포인트컷와 allService() 포인트컷을 조합해서 새로운 포인트컷을 만들었다.

이제 이걸 적용시켜보자.

AspectV4Pointcut

hello.aop.order.aop.AspectV4Pointcut

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Slf4j  
@Aspect  
public class AspectV4Pointcut {  
  
    @Around("hello.aop.order.aop.Pointcuts.allOrder()")  
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {  
        log.info("[log] {}", joinPoint.getSignature());  
        return joinPoint.proceed();  
    }  
  
    // hello.aop.order 패키지와 하위 패키지이면서 클래스 이름 패턴이 *Service 인것  
    @Around("hello.aop.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());  
        }  
    }  
}

기존에 있던 포인트컷 대신에 만들어둔 포인트컷을 적용시키면 되는데

사용하는 방법은 패키지명을 포함한 클래스 이름과 포인트컷 시그니처를 모두 지정하면 된다.

포인트컷을 여러 어드바이스에서 함께 사용할 때 이 방법을 사용하면 효과적이다.

이제 테스트에 @Import를 수정해서 결과를 확인해보면

결과는 동일하게 나온다.

댓글남기기