세상에 나쁜 코드는 없다

Spring AOP [1] 본문

웹개발/백엔드

Spring AOP [1]

Beomseok Seo 2023. 8. 30. 19:08

개요

AOP는 IoC/DI, 서비스 추상화와 더불어 스프링의 3대 기반기술의 하나다. 이 글에서는 FibonacciCalculator 예제를 통해 AOP의 필요성을 느끼고 스프링이 이 문제를 해결하기 위해 제공하는 기능들을 알아볼 것이다.

예제, FibonacciCalculator

FibonacciCalculator는 long fibonacci(long num);이라는 하나의 메서드를 갖고 있는 인터페이스로, 파라미터로 제공된 항의 피보나치 수를 반환한다.

//FibonacciCalculator.java
public interface FibonacciCalculator {
    long fibonacci(long num);
}

피보나치 수를 구하는 것은 전통적인 DP 문제 중 하나로, 이 예제에서는 재귀적으로 문제를 해결하는 클래스와 DP를 사용하여 문제를 해결하는 클래스를 만든 후 두 해결법의 성능 차이를 비교하고자 한다.

//RecursiveFibonacciCalculator.java
public class RecursiveFibonacciCalculator implements FibonacciCalculator{
    @Override
    public long fibonacci(long num) {
        if (num <= 1) {
            return num;
        }
        return fibonacci(num - 1) + fibonacci(num - 2);
    }
}
//DynamicFibonacciCalculator.java
public class DynamicFibonacciCalculator implements FibonacciCalculator{
    @Override
    public long fibonacci(long num) {

        if (num <= 1) {
            return num;
        }

        long[] fib = new long[(int) (num + 1)];
        fib[0] = 0;
        fib[1] = 1;

        for (int i = 2; i <= num; i++) {
            fib[i] = fib[i - 1] + fib[i - 2];
        }

        return fib[(int) num];
    }
}

성능 체크

다음과 같은 요구사항이 제시되었다.

앞으로 FibonacciCalculator를 구현한 객체가 피보나치 수를 계산할 때, 매번 로그로 그 결과와 수행시간을 출력되게 해주세요!

이것을 가장 간단하게 구현하는 방법은 메서드의 처음과 끝 부분에 시간을 기록하는 코드를 작성한 뒤, 그것을 출력하게 하는 것이다.

//DynamicFibonacciCalculator.java
public class DynamicFibonacciCalculator implements FibonacciCalculator{
    @Override
    public long fibonacci(long num) {

        long start = System.currentTimeMillis();

        if (num <= 1) {
            return num;
        }

        long[] fib = new long[(int) (num + 1)];
        fib[0] = 0;
        fib[1] = 1;

        for (int i = 2; i <= num; i++) {
            fib[i] = fib[i - 1] + fib[i - 2];
        }
				
				long end = System.currentTimeMillis();
				System.out.println("DynamicFibonacciCalculator Result : %d, Time : %d",fib[(int)num], end-start);

        return fib[(int) num];
    }
}

하지만 이러한 구조에는 몇가지 문제점이 존재한다.

  1. 코드의 중복

매번 FibonacciCalculator를 구현한 클래스를 생성할 때 마다 동일한 로직의 시간 체크 코드를 작성해주어야 한다. 이는 이후 로직에 수정이 발생할 때 모든 부분을 수정해야하는 문제로 이어진다.

  1. 메서드 비대화

피보나치 수를 반환하는 역할을 수행하는 메서드가 시간을 측정하는 역할도 동시에 수행하고 있다.

  1. 재귀 메서드에서 시간 측정의 어려움

재귀 메서드는 명령어가 선형적으로 수행되지 않기 때문에 위와 같은 시간 측정 로직을 사용할 수 없다.

이러한 문제를 해결하면서 새로운 기능을 추가하는 방법이 있을까?

프록시

public class ExecuteTimeCheckProxyCalculator implements FibonacciCalculator {

    private static final Logger log = org.slf4j.LoggerFactory.getLogger(ExecuteTimeCheckProxyCalculator.class);
    private final FibonacciCalculator delegate;

    public ExecuteTimeCheckProxyCalculator(FibonacciCalculator delegate) {
        this.delegate = delegate;
    }
    @Override
    public long fibonacci(long num) {
        long start = System.currentTimeMillis();
        long result = delegate.fibonacci(num);
        long end = System.currentTimeMillis();

        log.info("DynamicFibonacciCalculator Result : {}, Time : {}",result, end-start);

        return result;
    }
}

위 클래스 ExecuteTimeCheckProxyCalculator는 FibonacciCalculator를 구현한 클래스이지만, 피보나치 수를 구하는 것 대신에 시간을 측정하는 로직을 갖고 있다. 실질적인 피보나치 수 구하는 동작은 객체가 갖고있는 delegate를 통해 수행되며, 이 객체는 외부에서 파라미터로 받는다.

//AppConfig.java

@Configuration
public class AppConfig {
    @Bean
    public FibonacciCalculator recursiveFibonacciCalculator() {
        return new ExecuteTimeCheckProxyCalculator(new RecursiveFibonacciCalculator());
    }

    @Bean
    public FibonacciCalculator dynamicFibonacciCalculator() {
        return new ExecuteTimeCheckProxyCalculator(new DynamicFibonacciCalculator());
    }
}

스프링 빈 설정 정보를 담고 있는 Configuration 클래스는 위와 같이 구성되어 있다. recursiveFibonacciCalculator, dynamicFibonacciCalculator 라는 이름의 빈을 등록하고 있으며 각각의 빈은 실제 수행되어야 하는 책임을 가지는 객체를 ExecuteTimeCheckProxyCalculator로 감싼 객체로 등록된다. 이런 설정을 통해 얻을 수 있는 결과는 다음과 같다.

//ExecutionTimeTest.java
@Component
public class ExecutionTimeTest implements ApplicationRunner {

    private final FibonacciCalculator recursiveFibonacciCalculator;
    private final FibonacciCalculator dynamicFibonacciCalculator;

    public ExecutionTimeTest(FibonacciCalculator recursiveFibonacciCalculator, FibonacciCalculator dynamicFibonacciCalculator) {
        this.recursiveFibonacciCalculator = recursiveFibonacciCalculator;
        this.dynamicFibonacciCalculator = dynamicFibonacciCalculator;
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {

        recursiveFibonacciCalculator.fibonacci(40);
        dynamicFibonacciCalculator.fibonacci(40);
    }
}

ExecutionTimeTest는 실행시간을 측정하는 코드를 담고 있는 클래스이다. 이 클래스의 객체는 스프링 빈으로 관리되며, recursiveFibonacciCalculator와 dynamicFibonacciCalculator를 스프링 컨테이너로부터 주입받는다. 주입받는 객체의 실체는 ExecuteTimeCheckProxyCalculator이지만, 이는 FibonacciCalculator를 구현하고 있으므로 ExecutionTimeTest는 이를 FibonacciCalculator 타입으로 전달받아 사용할 수 있다.

이 결과 클라이언트 클래스에서는 FibonacciCalculator가 Wrapping 되어있음을 모르는 상태로 메서드를 호출하고 그에 기대되는 결과를 반환받을 수 있으며, 내부적으로는 프록시를 거쳐서 시간측정에 대한 로그도 출력된다. 이를 통해 피보나치 수를 구하는 메서드에 시간 측정 로직을 제거할 수 있고, 여러 클래스에서 중복되는 시간 측정 코드를 한 곳으로 모을 수 있으며, 클라이언트 측에서는 이를 모르는 상태로 기존의 코드를 유지시킬 수 있다.

이렇게 핵심 기능의 실행은 다른 객체에게 위임하고 부가적인 기능을 제공하는 객체를 프록시라고 부른다. 지금 적용하는 패턴은 엄밀히 말하면 Decorator에 가깝지만, 스프링 AOP에서는 프록시라는 용어로 다루므로 프록시라고 설명하도록 한다.

  • 프록시 패턴과 데코레이터 패턴의 차이

    두 패턴은 거의 비슷한 구조를 가졌지만 의도에 따라 다른 패턴으로 구분한다.

    • 프록시 패턴 : 접근 제어에 목적이 있을 때
      • ex. 권한에 따른 접근 차단
      • 캐싱
      • 지연 로딩
    • 데코레이터 : 부가 기능 추가에 목적이 있을 때
      • 요청/응답 값 변형
      • 로깅

프록시의 특징은 핵심 기능을 구현하지 않는다는 점이다. 그 대신 여러 객체에 공통으로 적용할 수 있는 기능을 구현한다. (실행 시간 확인, Transaction, …) 이렇게 프록시를 통해 공통 기능과 핵심 기능을 분리하는 것이 AOP의 핵심이다.

AOP

AOP는 Aspect Oriented Programming의 약자로, 여러 객체에 공통으로 적용할 수 있는 기능을 분리해서 재사용성을 높여주는 프로그래밍 기법이다. AOP는 핵심 기능과 공통 기능의 구현을 분리함으로써 핵심 기능을 구현한 코드의 수정 없이 공통 기능을 적용할 수 있게 만들어준다.

앞의 예시에서 피보나치 수를 계산하는 로직이 핵심 기능이고, 계산의 시간을 측정하는 로직이 공통 기능이다. 공통 기능은 그 자체로는 의미가 없으며 항상 핵심 기능을 보조하는 역할로써 동작한다.

공통 기능 삽입 방법

핵심 기능에 공통 기능을 삽입하는 과정을 Weaving이라고 부르며, 크게 세 타입으로 구분된다.

  • Compile-time Weaving
  • Load-time Weaving
  • Runtime Weaving

Compile-time Weaving은 공통 기능을 컴파일 시점에 바이너리 코드 내부에 삽입하는 방법이다. 이는 일반적인 자바 컴파일러로는 할 수 없고, AOP의 구현을 위해 제작된 특수한 컴파일러를 사용해야한다.

Load-time Weaving은 클래스가 JVM에 로딩되는 시점에 클래스 파일을 조작하여 공통 기능을 삽입하는 방법이다. 이를 위해서는 Java의 클래스로더가 클래스 파일을 조작할 수 있는 기능을 가질 수 있도록 확장이 필요하다.

Runtime Weaving은 애플리케이션의 실행 시점에서 공통 기능이 핵심 기능과 결합하여 수행되는 방식이며, 주로 프록시를 기반으로 하여 구현된다.

위에서 살펴본 FibonacciCalculator 예제는 런타임에 프록시 객체 조립을 통해 AOP를 구현한 Runtime Weaving 방식이다.

AspectJ와 Spring AOP

AspectJ와 Spring AOP는 모두 AOP 기법을 구현한 모듈이다.

AspectJ는 AOP를 구현한 프레임워크로, 자체 컴파일러를 제공하고 클래스로더를 확장함으로써 다양한 방식의 Weaving을 사용할 수 있게 해준다.

스프링 AOP는 스프링 프레임워크의 핵심 기술 중 하나로, 스프링 컨테이너와 함께 작동하여 프록시 기반의 Runtime Weaving을 쉽게 적용할 수 있게 해준다.

스프링 AOP는 AspectJ의 문법을 차용하여 AspectJ와 유사한 방법으로 AOP를 적용할 수 있게 한다. 반면 스프링 AOP는 특수 목적 컴파일러나 클래스로더를 제공하지 않으므로, 프록시 기반의 AOP만 제공하여 기능에 제약이 있다.

스프링 AOP는 프록시 기반으로만 동작하므로 오버라이딩의 대상이 되는 곳, 즉 인스턴스 메서드에만 공통 기능을 적용시킬 수 있다. 반면 AspectJ는 Compile-time이나 Load-time Weaving도 제공하므로 적용 위치에 제한이 없어 생성자, static 메서드, 필드 등 다양한 지점에 공통 로직을 삽입할 수 있다.

AOP 용어

  • Advice : 공통 로직, 부가 기능을 의미함.
  • Joinpoint : 애플리케이션 내에서 Advice가 적용될 수 있는 위치들을 의미함. 메서드 실행, 생성자 호출, 필드 값 접근, static 메서드 접근과 같은 지점.
    • 스프링 AOP의 경우 프록시 기반 동작이 이뤄지기 때문에, Joinpoint는 오직 메서드 실행 부분으로 한정된다. 반면 AspectJ를 사용한다면 다양한 Joinpoint에 대해 AOP를 적용할 수 있다.
  • Pointcut : 여러 Joinpoint 중 Advice가 적용될 위치를 선별하는 기능. 주로 AspectJ의 표현식을 통해 지정할 수 있다.
  • Aspect : Advice(부가 기능) + Pointcut(부가 기능이 적용될 위치)을 모듈화 한 것.
  • 스프링 AOP에서는 Advisor라는 클래스로 Aspect를 구현할 수 있다. Advisor는 하나의 Advice와 하나의Pointcut을 갖고 있는 객체이다.


Uploaded by N2T