Spring/스프링 기본

AOP란? / 스프링 AOP사용

공대키메라 2022. 4. 2. 16:35

AOP(Aspect Oriented Programming)이란 무엇인지, 그리고 스프링에서는 이것을 어떻게 지원하는지 알아보겠다. 

 

참고한 내용은 다음과 같다.

 

출처 사이트 : 

https://whatis.techtarget.com/definition/aspect-oriented-programming-AOP

https://www.geeksforgeeks.org/aspect-oriented-programming-and-aop-in-spring-framework/

https://docs.jboss.org/aop/1.0/aspect-framework/userguide/en/html/what.html

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/lecture/94503

1. AOP란?

 

GeekForGeek에서 다음과 같이 설명하고 있다. 

Aspect oriented programming(AOP) as the name suggests uses aspects in programming. It can be defined as the breaking of code into different modules, also known as modularisation, where the aspect is the key unit of modularity.

 

=> Aspect 지향 프로그래밍은 이름처럼 프로그래밍에서 aspect사용을 제안한다. 이 제안은 모듈화처럼 다른 모듈로 코드를 부수는 것으로 정의될 수 있다. aspect는 모듈화의 핵심 유닛이다. 

 

위키 피디아에서는 다음과 같이 소개한다. 

In computing, aspect-oriented programming (AOP) is a programming paradigm that aims to increase modularity by allowing the separation of cross-cutting concerns. It does so by adding behavior to existing code (an advice) without modifying the code itself, instead separately specifying which code is modified via a "pointcut" specification, such as "log all function calls when the function's name begins with 'set'". 

=> 컴퓨팅에서, AOP는 크로스 커팅 concerns의 분리를 허용하는 모듈화를 증가시키는데 중심을 두는 패러다임이다.

코드 자체를 수정하는 대신에 포인트컷 상술을 통해 존재하는 코드에 행동을 추가한다. 

 

그러면 Aspect는 무엇일까?

An aspect is a common feature that's typically scattered across methods, classes, object hierarchies, or even entire object models. 

 

=> aspect는 메소드, 클래스, 객체 계층구조, 또는 전체 객체 모델들 사이에 전형적으로 흩어진 공통의 관심사이다. 

(이말이 가장 핵심인거 같다.)

 

그렇다. 공통의 관심사 해결. 이것이 key point다. 

 

애플리케이션은 크게 핵심 기능과 부가 기능으로 나뉜다. 

 

  • 핵심 기능 : 해당 객체가 제공하는 고유의 기능
  • 부가 기능 : 핵심 기능을 보조하기 위해 제공되는 기능

 

수많은 핵심 기능이 있는데 부가 기능을 전부 추가하게 된다고 생각하자. 

 

예들 들어, 로그 추적 로직이 부가 기능으로 추가되어야 한다고 하자. 

 

이 구조에서 가장 큰 문제는 로그 추적 로직에 수정이 생길 때 마다 적용된 곳에 가서 직접 코드를 수정해줘야 한다는 것이다. (지옥...)

 

부가 기능 적용시 문제는 다음과 같다. 

 

  • 부가 기능을 적용할 때 많은 반복이 발생
  • 중복 코드를 많은 곳에서 만들어 냄
  • 중복 코드 때문에 많은 수정 발생
  • 적용 대상 변경시 많은 수정 발생

하지만 이러한 것은 전통적인 OOP 방식으로는 해결이 불가능하다. 

 

그래서 이러한 것의 해결책으로 AOP가 등장했다.

In AOP, a feature like metrics is called a crosscutting concern, as it's a behavior that "cuts" across multiple points in your object models, yet is distinctly different. As a development methodology, AOP recommends that you abstract and encapsulate crosscutting concerns.

 

 

여러 핵심 기능, 혹은 기능 모듈에 공통적으로 해결하길 원하는 기능을 횡단 관심사라고 한다. 

 

AOP 구현 Framework도 여러개가 있다고 한다. 많은 기능을 제공하는 AspectJ가 있고, 이를 사용하도록 잘 지원해주는 Spring이 있다. 그리고 JBoss라는게 있다. 

 

 

AOP를 사용할 때는 크게 3가지의 방식이 있다.

 

우리는 spring으로만 사용할 것이다. 

 

spring으로 AOP사용시 주의점은 프록시 방식을 사용할 때는 스프링 빈에만 AOP를 적용할 수 있다는 점이다. 

 

그러면 AspectJ가 기능을 더 많이 제공한다는데 AspectJ를 사용하는게 더 좋지 않을까?

 

그럴수도 있지만 난이도도 높을 뿐더러  Spring에서 제공해주는 기능만으로도 AOP를 쉽게 사용할 수 있다고 한다. 

 

굳이... AspectJ를 사용하지 않아도 문제가 해결 가능하니 Spring으로도 충분하다고 한다. 

 

2. 용어 정리

  1. Weaving: ​​포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용한 것. 
  2. Target : 어드바이스를 받는 객체, 포인트컷으로 결정
  3. Advisor : 하나의 어드바이스 + 하나의 포인트 컷으로 구성. Spring AOP에서만 사용
  4. Aspect : 어드바이스 + 포인트 컷을 모듈화한 것. @Aspect
  5. Advice : 어드바이스는 횡단 관심에 해당하는 공통 기능의 코드를 의미하며, 독립된 클래스의 메소드로 작성된다.
  6. JoinPoints: 어드바이스가 적용될수 있는 위치, 메소드 실행, 생성자 호출, 필드 값 접근, static 메서드 같은 프로그램 실행 중 지점. AOP를 적용할 수 있는 모든 지점. 
  7. Pointcut: 조인 포인트 중에서 어드바이스가 적용될 위치를 선별하는 기능.  

Advice는 5개의 타입이 있다. 

 

  • Before : 비즈니스 메소드 실행 전 동작
  • After Returning : 비즈니스 메소드가 성공적으로 리턴되면 동작 (단, 메소드 내부에 리턴값이 존재하는 경우)
  • After Throwing : 비즈니스 메소드 실행 중, 예외가 발생하면 동작 (예를들면 try~catch 블록의 catch 부분에 해당!)
  • After : 비즈니스 메소드가 실행된 후,  !무조건!  실행 (예를들면 try~catch 블록에서 finally 부분에 해당!)
  • Around : 메소드 호출 자체를 가로채서 비즈니스 메소드 실행 전&후 모두에 처리할 로직을 삽입할 수 있음

 

 

3. AOP 시점 분류

  • 컴파일 시점
  • 클래스 로딩 시점
  • 런타입 시점

3가지가 있다는데 런타임 시점만 주로 사용한다고 한다. 자세한건 찾아보자.

 

4. 코드 구현

 

아주 간단한 전통적인 Layered Architecture를 구성할 것이다.

 

우선 gradle에 의존성을 추가한다.

 

build.gradle

plugins {
   id 'org.springframework.boot' version '2.6.6'
   id 'io.spring.dependency-management' version '1.0.11.RELEASE'
   id 'java'
}

group = 'aoptest'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
   compileOnly {
      extendsFrom annotationProcessor
   }
}

repositories {
   mavenCentral()
}

dependencies {
   implementation 'org.springframework.boot:spring-boot-starter'
   implementation 'org.springframework.boot:spring-boot-starter-aop'
   compileOnly 'org.projectlombok:lombok'
   annotationProcessor 'org.projectlombok:lombok'
   testImplementation 'org.springframework.boot:spring-boot-starter-test'
   testImplementation 'org.projectlombok:lombok'
   testImplementation 'org.springframework.boot:spring-boot-starter-aop'
}

tasks.named('test') {
   useJUnitPlatform()
}

bul

 

AopService.java

package aoptest.aop.aoptest;

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

@Slf4j
@Service
public class AopService {

    private final AopRepository repository;

    public AopService(AopRepository repository) {
        this.repository = repository;
    }

    public void AopServiceTest(String test1){
        log.info("[AopService.AopServiceTest]");
        repository.AopRepositoryTest(test1);
    }

}

 

AopRepository.java

package aoptest.aop.aoptest;

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

@Slf4j
@Repository
public class AopRepository {

    public String AopRepositoryTest(String test1){
        log.info("[AopRepository.AopRepositoryTest]");
        if (test1.equals("exception")) {
            throw new IllegalStateException("error occur!");
        }
        return "ok";
    }

}

 

AopTest.java

package aoptest.aop;

import aoptest.aop.aoptest.AspectV1;
import aoptest.aop.aoptest.AopRepository;
import aoptest.aop.aoptest.AopService;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
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 org.springframework.context.annotation.Import;

@Slf4j
@SpringBootTest
//@Import(AspectV1.class)
public class AopTest {

    @Autowired
    private AopService aopService;

    @Autowired
    private AopRepository aopRepository;

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

    @Test
    void success() {
        aopService.AopServiceTest("test1");
    }

    @Test
    void  exception() {
        Assertions.assertThatThrownBy(() -> aopService.AopServiceTest("exception"))
                .isInstanceOf(IllegalStateException.class);
    }

}

 

처음 세팅한 test코드의 실행 결과는 다음과 같다. 

 

 

그리고 다음 패키지 안에 파일을 생성했다.

 

AspectV1.java

package aoptest.aop.aoptest;

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(* aoptest.aop.aoptest..*(..))")
    public Object loggingTest(ProceedingJoinPoint joinPoint) throws Throwable{
        log.info("[log] {}", joinPoint.getSignature()); // joint point 시그니처
        return joinPoint.proceed();
    }

}

 

@Around : 애노테이션의 값인 execution(* aoptest.aop.aoptest..*(..)) 가 포인트컷이 된다. 

*포인트 컷 : 조인 포인트 중에서 어드바이스가 적용될 위치를 선별하는 기능 => 적용될 위치 선택

 

@Around 애노테이션의 메서드인 loggingTest는 advice가 된다.

*어드바이스 : 어드바이스는 횡단 관심에 해당하는 공통 기능의 코드를 의미

 

그리고 Test에 @Import(AspectV1.class)

를 추가한 후 실행하면 우리가 Apect로 만든 loggingTest가 적용된다.

 

결과

2022-04-02 15:37:37.367  INFO 11024 --- [ main] aoptest.aop.aoptest.AspectV1             : [log] void aoptest.aop.aoptest.AopService.AopServiceTest(String)
2022-04-02 15:37:37.374  INFO 11024 --- [ main] aoptest.aop.aoptest.AopService           : [AopService.AopServiceTest]
2022-04-02 15:37:37.374  INFO 11024 --- [ main] aoptest.aop.aoptest.AspectV1             : [log] String aoptest.aop.aoptest.AopRepository.AopRepositoryTest(String)
2022-04-02 15:37:37.377  INFO 11024 --- [ main] aoptest.aop.aoptest.AopRepository        : [AopRepository.AopRepositoryTest]
2022-04-02 15:37:37.385  INFO 11024 --- [ main] aoptest.aop.AopTest                      : isAopProxy, aopService=true
2022-04-02 15:37:37.385  INFO 11024 --- [ main] aoptest.aop.AopTest                      : isAopProxy, aopRepository=true
2022-04-02 15:37:37.409  INFO 11024 --- [ main] aoptest.aop.aoptest.AspectV1             : [log] void aoptest.aop.aoptest.AopService.AopServiceTest(String)
2022-04-02 15:37:37.410  INFO 11024 --- [ main] aoptest.aop.aoptest.AopService           : [AopService.AopServiceTest]
2022-04-02 15:37:37.410  INFO 11024 --- [ main] aoptest.aop.aoptest.AspectV1             : [log] String aoptest.aop.aoptest.AopRepository.AopRepositoryTest(String)
2022-04-02 15:37:37.410  INFO 11024 --- [ main] aoptest.aop.aoptest.AopRepository        : [AopRepository.AopRepositoryTest]

 

포인트 컷은 Around에 바로 적용이 가능하지만 분리해서 적용도 가능하다.

 

AspectV2.java - pointcut 분리

package aoptest.aop.aoptest;

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 {

    //aoptest.aop.aoptest와 하위 패키지 전부를 가리킴
    @Pointcut("execution(* aoptest.aop.aoptest..*(..))")
    private void executeAll(){} // Pointcut signature

    @Around("executeAll()")
    public Object loggingTest(ProceedingJoinPoint joinPoint) throws Throwable{
        log.info("[log] {}", joinPoint.getSignature()); // joint point 시그니처
        return joinPoint.proceed();
    }

}

 

  • @Pointcut에 포인트컷 표현식 사용
  • 메서드 이름과 파라미터를 합쳐 포인트컷 시그니쳐라 한다. 
  • 메서드 반환 타입은 void이어야 한다. 
  • 다른 aspect에서 참고하길 원하면 public으로 한다. 

 

기존 AopTest 상단에 Import만 AspectV2.class로 변경해주면 된다. 

 

실행 결과는 기존의 AspectV1.class를 Import한 경우와 동일하다. 

 

AspectV3.java - 어드바이스 추가

package aoptest.aop.aoptest;

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 {

    //aoptest.aop.aoptest와 하위 패키지 전부를 가리킴
    @Pointcut("execution(* aoptest.aop.aoptest..*(..))")
    private void executeAll(){} // Pointcut signature

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

    @Around("executeAll()")
    public Object loggingTest(ProceedingJoinPoint joinPoint) throws Throwable{
        log.info("[log] {}", joinPoint.getSignature()); // joint point 시그니처
        return joinPoint.proceed();
    }

    //aoptest.aop.aoptest패키지와 하위 패키지면서 클래스 이름 패턴이 *Service인 경우
    @Around("executeAll() & allService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {

        try {
            log.info("[transaction start] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[transaction commit] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e){
            log.info("[transaction rollback] {}", joinPoint.getSignature());
        } finally {
            log.info("[resource release] {}", joinPoint.getSignature());
        }
    }

}

 

executeAll() 포인트컷은 aoptest.aop.aoptest 패키지와 하위 패키지를 대상으로 한다. 

allService() 포인트컷은 타입 이름 패턴이 *Service를 대상으로 한다. 

 

또한, 포인트컷을 여러개 조합도 가능하다. (doTransaction을 확인)

 

그러면 이제는 AopRepository에는 log 만 실행되는데 AopService에는 Transaction관련 log도 출력된다. 

(그냥 임의로 생성한 것임. 실제로는 transaction처리는 안한다. 오해 금물)

 

결과 - success()

2022-04-02 16:08:09.697  INFO 13008 --- [           main] aoptest.aop.aoptest.AspectV3             : [transaction start] void aoptest.aop.aoptest.AopService.AopServiceTest(String)
2022-04-02 16:08:09.698  INFO 13008 --- [           main] aoptest.aop.aoptest.AspectV3             : [log] void aoptest.aop.aoptest.AopService.AopServiceTest(String)
2022-04-02 16:08:09.704  INFO 13008 --- [           main] aoptest.aop.aoptest.AopService           : [AopService.AopServiceTest]
2022-04-02 16:08:09.704  INFO 13008 --- [           main] aoptest.aop.aoptest.AspectV3             : [log] String aoptest.aop.aoptest.AopRepository.AopRepositoryTest(String)
2022-04-02 16:08:09.707  INFO 13008 --- [           main] aoptest.aop.aoptest.AopRepository        : [AopRepository.AopRepositoryTest]
2022-04-02 16:08:09.707  INFO 13008 --- [           main] aoptest.aop.aoptest.AspectV3             : [transaction commit] void aoptest.aop.aoptest.AopService.AopServiceTest(String)
2022-04-02 16:08:09.707  INFO 13008 --- [           main] aoptest.aop.aoptest.AspectV3             : [resource release] void aoptest.aop.aoptest.AopService.AopServiceTest(String)

 

PointCuts.java - 포인트컷 참조

 

Pointcuts.java

package aoptest.aop.aoptest;

import org.aspectj.lang.annotation.Pointcut;

public class Pointcuts {

    //aoptest.aop.aoptest와 하위 패키지 전부를 가리킴
    @Pointcut("execution(* aoptest.aop.aoptest..*(..))")
    public void executeAll(){} // Pointcut signature

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

    //allOrder && allService
    @Pointcut("executeAll() && allService()")
    public void orderAndService(){}

}

 

 

AspectV4Pointcuts.java

package aoptest.aop.aoptest;

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 AspectV4Pointcut {

    @Around("aoptest.aop.aoptest.Pointcuts.executeAll()")
    public Object loggingTest(ProceedingJoinPoint joinPoint) throws Throwable{
        log.info("[log] {}", joinPoint.getSignature()); // joint point 시그니처
        return joinPoint.proceed();
    }

    //aoptest.aop.aoptest패키지와 하위 패키지면서 클래스 이름 패턴이 *Service인 경우
    @Around("aoptest.aop.aoptest.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {

        try {
            log.info("[transaction start] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[transaction commit] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e){
            log.info("[transaction rollback] {}", joinPoint.getSignature());
            throw e;
        } finally {
            log.info("[resource release] {}", joinPoint.getSignature());
        }
    }

}

 

AspectV5 - advice order

 

AspectV5.java

package aoptest.aop.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 AspectV5 {

    @Aspect
    @Order(2)
    public static class LogAspect {
        @Around("aoptest.aop.order.aop.PointCuts.allOrder()")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable{
            log.info("[log] {}", joinPoint.getSignature()); // joint point 시그니처
            return joinPoint.proceed();
        }
    }

    @Aspect
    @Order(1)
    public static class TxAspect{
        //aoptest.order.aop 패키지와 하위 패키지이면서 클래스 이름 패턴이 *Service
        @Around("aoptest.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());
            }
        }
    }
}

 

 

AopTest.java

package aoptest.aop;

import aoptest.aop.aoptest.*;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
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 org.springframework.context.annotation.Import;

@Slf4j
@SpringBootTest
@Import({AspectV5Order.LogAspect.class, AspectV5Order.TxAspect.class})
public class AopTest {

    @Autowired
    private AopService aopService;

    @Autowired
    private AopRepository aopRepository;

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

    @Test
    void success() {
        aopService.AopServiceTest("test1");
    }

    @Test
    void  exception() {
        Assertions.assertThatThrownBy(() -> aopService.AopServiceTest("exception"))
                .isInstanceOf(IllegalStateException.class);
    }

}

 

위처럼 반영하면 우리가 적용한 Aspect의 순서를 설정이 가능하다. 

 


전 회사 이야기를 잠깐 하자면 이러한 기능에 대해서 무언가 활용하고 싶었는데

 

그 이유는 모든 비즈니스 로직에서는 뭐만 하면 전부 try ~ catch로 직접 코드를 작성해야 하는 문제가 생겼다.

 

코드의 반복이 계속 생길 뿐더러 어떤 기준으로 어떤 오류를 반환할지에 대한 이야기도 되지를 않았다.

 

그래서 필자는 항상 Exception으로만 해서 throw 한 기억이 난다.

 

이제 공통의 관심사는 AOP를 활용해 적용할 수 있을 거 같다 히히...