Java

[Spring AOP] 스프링 관점 지향 프로그래밍(Spring Aspect Oriented Programming)

ride-dev 2024. 5. 23. 15:33

짧막한 요약

애플리케이션을 웹, 비즈니스, 데이터 등 여러 계층으로 나눌 수 있습니다.

각 계층이 담당하고 있는 책임이 다르지만, 보안이나 로깅은 모든 계층에서 필요합니다.

공통의 관심사를 구현하고,

원하는 계층 별로 적용하는 것을 관점 지향 프로그래밍이라고 합니다.

Spring AOP는 관점 지향 프로그래밍 프레임 워크 중 하나입니다.

관점 지향 프로그래밍(AOP)?

애플리케이션은 대부분 웹 계층, 비즈니스 계칭, 데이터 계층 등 계층적 접근을 적용합니다.

(애플리케이션에 따라 층이 더 세분화 되거나 추가 되기도 합니다)

웹 계층 - 뷰 로직, 컨트롤러, REST API의 JSON 변환

비즈니스 계층 - 비즈니스 로직

데이터 계층 - MySQL, MariaDB 등의 영속성(Persistence) 로직

Cross Cutting Concerns - 횡단 관심사(공통의 관점; Common Aspects)

각 계층 별 책임이 다르지만,

보안이나 로깅처럼 공통적으로 적용되는 측면(관점, aspects)이 있습니다.

(보안, 로깅은 모든 계층에 적용하는 것이 요구됩니다)

이를 횡단 관심사(Cross Cutting Concerns)라고 합니다.

 

공통 관심사를 모든 계층에 중복으로 구현한다면 유지보수가 어려워질 것입니다.

AOP를 횡단 관심사 구현에 사용할 수 있습니다.

적용

각 관심사(관점, 측면)를 Aspect로 만들고,

관심사를 적용할 포인트를 정의합니다.

프레임워크(Spring AOP, AspectJ)

AOP에는 Spring AOP, AspectJ 두 가지 유명한 프레임 워크가 있습니다.

Spring AOP는 AOP에 대해 완전한 솔루션이 아니지만 매우 유명합니다.

Spring AOP는 Spring Bean으로만 동작합니다.

AspectJ는 AOP에 대해 완전한 솔루션이나 근래 잘 사용되지 않습니다.

AspectJ는 Spring Bean 뿐만 아니라, 아무 JAVA 클래스로의 메서드 호출을 인터셉트 할 수 있습니다.

Spring AOP 데모 프로젝트

AOP를 적용할 데모 프로젝트를 생성합니다.

비즈니스 계층의 로직과 데이터 계층의 로직을 작성합니다.

aop 의존성을 추가하고,

공통 관심사를 Aspect로 만들고 적용합니다.

데모 프로젝트에서 공통 관심사는 '각 계층 메서드 로그 출력'입니다.

프로젝트 생성

start.spring.io 에서 프로젝트를 생성합니다.

비즈니스 로직, 데이터 로직 구현

비즈니스, 데이터 로직을 간략하게 구현합니다.

비즈니스 로직은 아래와 같으며 @Service 어노테이션을 추가합니다.

package com.ride.learnspringaop.aopexample.business;

import com.ride.learnspringaop.aopexample.data.DataService1;
import org.springframework.stereotype.Service;

import java.util.Arrays;

@Service
public class BusinessService1 {

    private DataService1 dataService1;

    public BusinessService1(DataService1 dataService1) {
        this.dataService1 = dataService1;
    }

    public int calculateMax() {
        int[] data = dataService1.retrieveData();
        return Arrays.stream(data).max().orElse(0);
    }

    public int calculateMin() {
        int[] data = dataService1.retrieveData();
        throw new RuntimeException("Something Went Wrong");
    }
}

데이터 로직은 아래와 같으며 @Repository 어노테이션을 추가합니다.

(편의상 DB에 저장된 int 배열을 반환함을 가정합니다)

package com.ride.learnspringaop.aopexample.data;

import org.springframework.stereotype.Repository;

@Repository
public class DataService1 {

    public int[] retrieveData() {
        return new int[]{11, 22, 33, 44, 55};
    }
}

마지막으로 CommandLineRunner를 구현하여,

Spring Boot 애플리케이션이 실행되는 순간 실행될 로직을 run 메서드에 추가합니다.

(비즈니스 로직 결과를 로그로 확인하도록 합니다)

package com.ride.learnspringaop;

import com.ride.learnspringaop.aopexample.business.BusinessService1;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LearnSpringAopApplication implements CommandLineRunner {

    private Logger logger = LoggerFactory.getLogger(getClass());

    private BusinessService1 businessService1;

    public LearnSpringAopApplication(BusinessService1 businessService1) {
        this.businessService1 = businessService1;
    }

    public static void main(String[] args) {
        SpringApplication.run(LearnSpringAopApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        logger.info("Max value returned is {}", businessService1.calculateMax());
        logger.info("Min value returned is {}", businessService1.calculateMin());
    }
}

의존성 추가

build.gradle에 의존성을 추가합니다.

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

Aspect 클래스 생성

로깅에 대한 aspect 클래스를 생성하고,

포인트 컷을 정의하는 것으로,

비즈니스 계층과 데이터 계층의 메서드 호출을 인터셉트합니다.

@Configuration 어노테이션을 통해 설정 클래스이고, 스프링 빈으로 관리할 클래스임을 선언합니다.

@Aspect 어노테이션을 사용하여 Aspect 클래스임을 선언합니다.

Pointcut을 작성하여 인터셉트 시점을 지정합니다.

execution에 Aspect를 사용할 패키지를 설정합니다.

@Before - 메서드 호출 직전, 선행 조건으로 인증이 필요할 때 등에 사용 가능

@After - 메서드가 실행되고 난 뒤 수행할 작업 명시(성공, 실패 유무에 관계 없이)

@AfterReturning - 메서드가 성공적으로 실행되었을 때 수행할 작업 명시

@AfterThrowing - 메서드가 예외를 던졌을 때 수행할 작업 명시

@Aound - 메서드 실행 전후에 수행할 작업 명시

 

로깅Aspect와 작업시간을 측정하는 Aspect 클래스를 생성합니다.

joinPoint 객체를 생성하여 특정 메서드에 대해 로깅하도록 합니다.

@Before, @After, @AfterReturning, @AfterThrowing

package com.ride.learnspringaop.aopexample.aspects;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;

@Configuration
@Aspect
public class LoggingAspect {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Before("com.ride.learnspringaop.aopexample.aspects.CommonPointcutConfig.businessAndDataPackageConfig()")
    public void logMethodCallBeforeExecution(JoinPoint joinPoint) {
        logger.info("Before Aspect - Method is called - {}", joinPoint);
    }

    //    @After("execution(* com.ride.learnspringaop.aopexample.*.*.*(..))")
    @After("com.ride.learnspringaop.aopexample.aspects.CommonPointcutConfig.businessAndDataPackageConfig()")
    public void logMethodCallAfterExecution(JoinPoint joinPoint) {
        logger.info("After Aspect - has executed - {}", joinPoint);
    }

    @AfterThrowing(
//            pointcut = "execution(* com.ride.learnspringaop.aopexample.*.*.*(..))",
            pointcut = "com.ride.learnspringaop.aopexample.aspects.CommonPointcutConfig.businessAndDataPackageConfig())",
            throwing = "exception"
    )
    public void logMethodCallAfterException(JoinPoint joinPoint, Exception exception) {
        logger.info("AfterThrowing Aspect - {} has thrown an exception - {}", joinPoint, exception);
    }

    @AfterReturning(
            pointcut = "com.ride.learnspringaop.aopexample.aspects.CommonPointcutConfig.businessAndDataPackageConfig())",
            returning = "resultValue"
    )
    public void logMethodCallAfterSuccessfulExecution(JoinPoint joinPoint, Object resultValue) {
        logger.info("AfterReturning Aspect - {} has returned - {}", joinPoint, resultValue);
    }
}

위 예제에서 두 가지 Pointcut 설정방식을 확인할 수 있습니다.

execution과 패키지의 경로를 명시하는 방식과,

CommonPointcutConfig 클래스에 Pointcut을 지정하고 참조하는 방식입니다.

(@annotation 지시자 방식은 아래에서 설명하도록 하겠습니다)

CommonPointcutConfig 클래스를 생성하면, 패키지 경로 변경에 대해 유연하게 대처할 수 있습니다.

ComminPointcutConfig - execute지시자, bean지시

package com.ride.learnspringaop.aopexample.aspects;

import org.aspectj.lang.annotation.Pointcut;

public class CommonPointcutConfig {

    @Pointcut("execution(* com.ride.learnspringaop.aopexample.*.*.*(..))")
    public void businessAndDataPackageConfig() {
    }

    @Pointcut("execution(* com.ride.learnspringaop.aopexample.business.*.*(..))")
    public void businessPackageConfig() {
    }

    @Pointcut("execution(* com.ride.learnspringaop.aopexample.data.*.*(..))")
    public void dataPackageConfig() {
    }

    @Pointcut("bean(*Service*)")
    public void allServicePackageConfigUsingBean() {
    }
}

또한 execution지시자를 사용하여 패키지 경로를 지정하는 방식,

bean지시자를 사용하여 bean 이름을 기준으로 인터셉트 대상을 지정할 수도 있습니다.

@Around

package com.ride.learnspringaop.aopexample.aspects;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;

@Configuration
@Aspect
public class PerformanceTrackingAspect {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Around("com.ride.learnspringaop.aopexample.aspects.CommonPointcutConfig.businessAndDataPackageConfig()")
    public Object findExecutionTime(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        long startTimeMillis = System.currentTimeMillis();

        Object returnValue = null;
        try {
            returnValue = proceedingJoinPoint.proceed();
        } catch (Throwable throwable) {

            long stopTimeMillis = System.currentTimeMillis();
            long executionDuration = stopTimeMillis - startTimeMillis;
            logger.error("Around Aspect - {} Method executed in {} ms - Exception: {}"
                    , proceedingJoinPoint
                    , executionDuration
                    , throwable.getMessage());
            throw throwable;
        }
        long stopTimeMillis = System.currentTimeMillis();
        long executionDuration = stopTimeMillis - startTimeMillis;
        logger.info("Around Aspect - {} Method executed in {} ms"
                , proceedingJoinPoint
                , executionDuration);
        return returnValue;
    }
}

 

AOP 관련 용어(Compile Time과 Runtime 에서의 용어)

Compile Time에서의 용어

Advice - 무엇을?

실행하려고 하는 코드(로깅, 시큐리티 등)

Pointcut -언제, 어디서?를 명시하는 표현식

인터셉트하려는 메서드 호출(어떤 메서드를 인터셉트할 것인가, data 디렉토리, business 디렉토리 등)

@Before, @After 등의 어노테이션을 통해 시점을 지정.

공통의 Pointcut을 관리하는 설정 클래스를 생성하여 유연하게 사용할 수 있음.

execute 지시자, bean 지시자, @annotation 방식이 있음.

Aspect - Advice와 Pointcut의 조합

Weaver - AOP를 구현한 프레임워크

Aspect 추적 및 관리(Spring AOP, AspectJ)

Weaving - AOP를 실행하는 과정의 통칭

(프레임워크가 하는 일을 위빙이라고 합니다)

Runtime에서의 용어

Join Point

Pointcut의 조건이 참이면 실행, Advice  실행 인스턴스, 메소드 호출과 관련된 정보를 제공

사용자 어노테이션 생성하기

사용자가 커스텀 어노테이션을 생성하고,

커스텀 어노테이션을 부착한 메서드에 특정 메서드들이 동작하도록 할 수 있습니다.

(메서드에 국한되지 않습니다)

성능을 측정하는 용도의 @TrackTime 어노테이션을 직접 만들어보겠습니다.

먼저 커스텀 어노테이션 인터페이스를 생성합니다.

package com.ride.learnspringaop.aopexample.annotations;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TrackTime {
}

Target과 Retention 설정을 통해 동작 대상과 범위를 지정합니다.

다음으로, CommonPointcutConfig클래스에서

@annotation 지정자를 통해 TrackTime을 Pointcut으로 지정합니다.

@Pointcut("@annotation(com.ride.learnspringaop.aopexample.annotations.TrackTime)")
public void trackTimeAnnotation() {
// 이제 TrackTime 어노테이션에서 사용할 메서드의 pointcut을
// com.ride.learnspringaop.aopexample.aspects.CommonPointcutConfig.trackTimeAnnotation() 로 지정합니다.
}

 

성능을 측정할 수 있도록 findExecutionTime 메서드를 TrackTime어노테이션의 pointcut으로 지정합니다.

@Around("com.ride.learnspringaop.aopexample.aspects.CommonPointcutConfig.trackTimeAnnotation()")
public Object findExecutionTime(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {

이제 @TrackTime 어노테이션을 명시한 메서드가 있으면 findExecutionTime이 동작합니다.

@TrackTime
public int calculateMax() {
    int[] data = dataService1.retrieveData();
    return Arrays.stream(data).max().orElse(0);
}

 

728x90