Spring Core Technolohies 2. AOP (1) AOP의 개념 이해

#Spring#AOP

2023-07-18 04:11

대표 이미지

이 글에서는 AOP (Aspect Oriented Programming)의 핵심 개념들을 이해하고 그 관계를 명확히 이해하는 것이 목표입니다. 따라서 Aspect, Advice, Pointcut, Join point 등 AOP의 중요한 요소들에 대해 설명하고, 이들이 어떻게 연결되는지에 대해 분석합니다. 추가적으로 Spring AOP의 목표와 프록시 기반의 AOP 구현에 대해 설명하며, JDK와 CGLIB 프록시의 차이점과 사용 방법에 대해 살펴봅니다.

개요


첫번째 포스트에서는 AOP에 대한 이해를 돕기 위해, AOP의 핵심 개념들과 이들이 어떻게 서로 연결되어 동작하는지를 설명한다. Aspect는 공통 로직 수행을 위한 정의이며, 이를 정의하기 위해 "언제" 작업을 할 것인지(Pointcut)와 "어떤" 작업을 할 것인지(Advice)에 대한 정보가 필요하다. 이러한 개념들을 통해 우리는 AOP가 프로그램의 구조를 어떻게 개선하고, 기능을 어떻게 추가하는지 이해할 수 있다.

또한, 이 포스트에서는 Spring AOP에 대해 간략히 소개하며, Spring AOP가 어떻게 AOP를 해석하는지, 그리고 그것이 프록시 기반의 방식을 사용하여 어떻게 동작하는지에 대해 설명한다. Spring AOP와 JDK, CGLIB 프록시의 차이점과 장단점에 대해서도 이야기하며, 어떤 상황에서 어떤 프록시를 사용해야 하는지에 대한 가이드라인을 제공한다.

핵심 개념


OOP(Object-Oriented Programming)

  • 객체 지향 프로그래밍
  • 현실 세계의 객체(object)를 프로그래밍적으로 모델링하여 소프트웨어를 개발하는 프로그래밍 패러다임 중 하나
  • 데이터와 해당 데이터를 처리하는 함수(메서드)를 하나의 논리적인 단위인 객체로 묶어서 생각하고 프로그래밍한다.
  • 이러한 객체들은 상속, 캡슐화, 다형성과 같은 OOP의 특징을 이용해 유연하고 확장 가능한 소프트웨어를 만들 수 있다.
  • OOP는 현대 소프트웨어 개발에서 널리 사용되며, Java, C++, Python, Ruby, JavaScript 등 대부분의 언어에서 지원하고 있다.

AOP(Aspect-Oriented Programming)

  • AOP는 OOP(Object-Oriented Programming)의 보완 개념으로, 여러 모듈에서 반복되는 공통 기능을 하나의 모듈로 분리하여 재사용 가능한 모듈로 만드는 것
  • Aspect는 사전적 의미로 "측면", "관점", "면" 등을 의미한다. AOP에서는 Aspect가 "횡단 관심사(cross-cutting concern)"를 구현한 코드 모듈을 의미한다.
  • AOP에서는 비즈니스 로직과 비즈니스 로직에 대한 보조 역할인 '관심사(Concerns)'를 분리하여 개발한다.

Spring AOP, AspectJ

  • Spring AOP는 프록시 기반의 AOP로서 스프링 프레임워크의 기능을 이용하여 비즈니스 로직과 관심사를 분리하여 처리할 수 있다.

왜 AOP 인가


AOP라는 개념의 시작점과 그 콘셉트에 대해서 이해하는 것으로 시작해 보자.

OOP의 한계

OOP는 객체를 중심으로 코드를 구성하며, 각 객체는 자신만의 데이터(필드)와 메서드를 가지고 있다. 하지만 OOP에서는 한 객체의 메서드가 다른 객체와 관련된 작업을 수행해야 할 때, 해당 객체의 코드에 이를 구현해야 한다. 이러한 코드 구현 방식은 코드의 유연성과 재사용성을 떨어뜨리며, 코드의 복잡성도 증가시킨다.

뿐만아니라 기능별로 메서드를 만들어 분리하고 상속을 이용해 클래스를 구성하기 때문에 공통적으로 필요한 기능들이 여러 메서드에서 중복적으로 구현되어야하는 경우가 많다. 이 때문에 코드의 중복성이 증가하고, 유지보수가 어려워진다.

AOP는 이러한 문제를 해결하기 위해 등장했다. AOP는 애플리케이션의 핵심 기능과 공통 기능을 분리하여, 공통 기능을 별도의 모듈로 관리하고 애플리케이션의 핵심 기능에 집중할 수 있게 한다. 이를 통해 코드의 재사용성과 유연성을 높이고, 코드의 복잡성을 낮출 수 있다.

맛보기

public class ProductService {
    public void saveProduct(Product product) {
        if (product.getName() == null) {
            throw new RuntimeException("Product name cannot be null");
        }

        // ... save product
    }

    public Product getProductById(Long id) {
        // ... get product by id
        return product;
    }

    // 다른 메서드들...
}

여기 AOP를 사용하지 않은 코드가 있다.

saveProduct에서는 제품 이름이 null인 경우 예외를 던지도록 구현하였다. 이와 같은 유효성 검사 코드는 ProductService 클래스의 여러 메서드에서 중복해서 구현될 가능성이 높기 때문에 이러한 구조는 코드의 중복을 증가시키고 유지보수를 어렵게 만든다.

AOP를 사용해 이를 해결해보자.

public class ProductService {
    public void saveProduct(Product product) {
        // ... save product
    }

    public Product getProductById(Long id) {
        // ... get product by id
        return product;
    }

    // 다른 메서드들...
}

public aspect ValidationAspect {
    pointcut saveProduct(Product product) :
        execution(* ProductService.saveProduct(Product)) && args(product);

    before(Product product) : saveProduct(product) {
        if (product.getName() == null) {
            throw new RuntimeException("Product name cannot be null");
        }
    }
}

이 예제는 AspectJ를 이용한 방식이다. 처음 AOP를 접한다면 코드를 이해하기 힘들겠지만 눈치껏 따라가보자 🤔

  • 우선 ValidationAspect 라는 유효성 검사와 같은 공통적인 기능을 수행하는 별도의 모듈을 만들었다. 따라서 ProductService에서는 따로 유효성 검사를 진행하는 로직을 작성할 필요가 없다.
  • public aspect ValidationAspect : Aspect 클래스를 정의하고 있다.
  • pointcut saveProduct(Product product) : saveProduct라는 이름의 포인트컷을 정의한다.
  • execution(* ProductService.saveProduct(Product))
    • 이 포인트컷은 Product 타입의 매개변수를 갖는 ProductService 클래스의 saveProduct 메서드를 대상으로 한다.
    • *은 대상이 되는 메서드의 리턴 타입이 무엇이든 상관없다는 것을 의미한다.
  • args(product);: pointcut에 매칭되는 메서드의 인자로 product 객체를 사용한다.
  • before(Product product) : saveProduct(product)
    • saveProduct pointcut이 매칭될 때 Product 타입의 product 매개변수를 가지고 before 어드바이스를 실행한다.
  • if (product.getName() == null) { throw new RuntimeException("Product name cannot be null"); }
    • product 객체의 name 필드가 null인 경우 RuntimeException을 발생시킨다.

이렇듯 AOP를 사용하면 반복되는 코드를 줄이고 유지보수를 쉽게 할 수 있다.

Spring과 AOP

그래서 어쩌자는 걸까?

아무튼 중복 코드를 줄이는 방법으로 AOP라는 개념이 사용된다는 것은 알았는데, Spring에서 이를 어떻게 사용하고 있는건지 당최 알 수가 없을 것이다.

이전에 스프링이 의미하는 바가 무엇인지 말했던 적이 있다.

스프링 프레임워크는 자바 기반의 엔터프라이즈급 애플리케이션을 개발하기 위한 종합적인 솔루션을 제공한다. 스프링은 다양한 기능을 제공하는 모듈로 구성되어 있으며, 이를 조합하여 필요한 기능을 선택하여 사용할 수 있다.

스프링은 개발자가 개발을 쉽게 할 수 있게 만들어주는 솔루션이다.

위의 예제에서 보았듯이 반복되는 코드, 유연성이 떨어지고 유지보수가 힘든 코드는 개발자의 적이다. 이를 해결하기 위한 방법 중 하나로 AOP라는 개념이 등장하였고 우리의 스프링도 개발자들을 지원하기 위해 Spring AOP를 제공해주고 있는 것이다.

그렇다. Spring AOP의 간단한 설정만으로도 개발자는 쉽게 AOP를 구현하고 가볍게 기능을 사용할 수 있다. 또한 Spring AOP는 Spring Framework의 핵심 기능 중 하나인 IoC/DI와 함께 사용할 수 있어서 애플리케이션의 다양한 영역에서 AOP를 활용할 수 있다. (스프링이 제공하는 다양한 라이브러리와 기능들 간의 호환성은 스프링의 큰 자랑이자 이점이다.)   한마디로 AOP는 공통적인 기능을 여러 모듈에서 반복적으로 구현해야 할 경우 발생하는 코드 중복과 복잡도를 줄이는 기술이기에 Spring IoC/DI와 함께 사용하면 객체 간의 의존성을 관리하는 동시에 공통 기능을 모듈화하여 관리할 수 있다. 이를 테면, Spring AOP를 이용하면 다양한 모듈에서 로깅, 인증, 트랜잭션 등의 공통 기능을 별도의 모듈로 분리하여 관리할 수 있다.

잘 와닿지 않을테니 예를 생각해보자.

지금 개발 중인 프로젝트에는 인증 기능이 필요하다. 모든 요청에 대해 사용자의 회원 가입 및 로그인 여부에 대한 인증을 추가해 주어야 한다. 인증 기능이 필요한 모든 클래스마다 인증 코드를 중복해서 구현하면 어떨까? 굉장히 비효율적 일 수 밖에 없다.

이 때, 인증 기능을 AOP로 분리해서 관리하면, 모든 클래스에서 인증 기능을 중복해서 구현하지 않아도 되므로 코드 양이 줄어들고 유지보수가 용이해질 것이다. 그리고 이렇게 분리된 공통 모듈들을 IoC/DI를 이용해 스프링 컨테이너에서 관리하게 위임할 수 있다.

즉, 스프링 컨테이너는 객체 간의 의존성을 관리하면서 동시에 공통 기능을 모듈화하여 관리할 수 있다.

그렇기에 Spring의 주요 구성 요소 중 하나는 AOP 프레임워크이다.

Spring IoC 컨테이너는 AOP에 의존하지 않지만(즉, 원하지 않다면 AOP를 사용하지 않아도 된다.) AOP는 Spring IoC를 보완하여 매우 유능한 미들웨어 솔루션을 제공한다.

AOP Concept


핵심 개념

AOP에서 다뤄지는 주요한 단어와 개념들을 간단히 알아보자.

1. Aspect

어떤 기능이나 관심사를 여러 개의 객체에 걸쳐 적용(횡단 관심사)할 때 사용하는 모듈화된 단위.

예를 들어, 여러 개의 클래스에서 사용되는 트랜잭션 관리 코드를 Aspect로 정의하여 중복된 코드를 피하고 하나의 Aspect에서 관리할 수 있다. 이렇게 작성된 Aspect는 Spring AOP를 이용해 프로그램 실행 시간에 핵심 로직에 적용된다.

즉, “반복되는 기능을 하나의 모듈로 만들었다”라고 할 때 그 모듈을 Aspect라고 하면 이해하기 쉽다. 흔히 utils라는 이름으로 여기저기서 쓰이는 공통 클래스를 만들곤하는데 순전히 개발자에 의해 관리되는 것과 다르게 Aspect 모듈로 정의되면 미리 지정해둔 시점(pointcut과 join point가 일치하는 곳) 마다 실행되게 할 수 있다.

2. Join point

point cut에 의한 대상이 될 수 있는 시점. 프로그램 실행 중의 특정 지점, 예를 들어 메소드 실행이나 예외 처리 등을 나타낸다. (어떤 비즈니스 로직의 시작 지점이라고 생각하면 될 것 같다.) Spring AOP에서 join point는 항상 메소드 실행을 나타낸다.

3. Pointcut

join point들 중에서 실행할 대상을 선택하는 기준. 즉, 어떠한 조건으로 정의된 pointcut과 일치하는 join point를 찾아내어 특정 동작(advice)을 실행한다. AspectJ와 같은 AOP 프레임워크에서는 pointcut을 정의하기 위해 표현식 언어를 제공한다. (Spring AOP에서도 AspectJ 표현식 언어를 지원합니다.)

4. Advice

특정 join point에서 aspect가 취하는 동작. "around", "before", "after" 등 다양한 종류의 Advice가 있다. Spring을 비롯한 많은 AOP 프레임워크에서는 advice를 interceptor로 모델링하고 join point 주변에 interceptor chain을 유지한다. 이는 다음과 같은 방식으로 작동한다.

  • 각 advice는 인터셉터로 변환되어 join point 주변에 위치한다.
  • join point가 실행될 때, interceptor chain은 해당 join point와 일치하는 pointcut에 대한 advice를 순차적으로 실행한다.
  • advice의 실행 순서는 각 advice가 선언된 순서와 동일하다. 즉, 같은 pointcut을 가진 advice의 경우에는 선언된 순서에 따라 실행되는 것이다.

5. AOP proxy ⭐️

스프링 AOP의 동작방식을 이해하기 위해 반드시 알아둬야할 중요한 요소이다.

프록시 객체

프록시 객체는 객체 지향 프로그래밍에서 자주 사용되는 디자인 패턴 중 하나이다. 즉, 스프링에서만 사용되는 것이 아니라 이전부터 존재하던 개념이라고 볼 수 있다.

프록시 객체는 실제 객체의 대리인 역할을 수행하며, 실제 객체와 동일한 인터페이스를 구현한다. 따라서, 클라이언트에서 실제 객체를 호출하는 것처럼 보일지라도 실제로는 진짜 객체의 메소드를 프록시 객체가 대신 호출한다.

실제 객체와 같은 인터페이스를 구현하였더라도 실제 객체의 메소드를 호출할 때 추가적인 동작을 수행하도록 구현되는데, 이를 통해 타깃이 되는 실제 객체의 동작을 제어하거나 변경할 수 있다.

스프링에서는 프록시 객체를 사용하여 AOP 기능을 구현한다.

타깃 객체의 메소드 호출 앞뒤에 추가적인 동작(advice)을 수행하도록 하는 것이 바로 이 프록시 객체의 도움 덕이다. 스프링에서 제공하는 프록시 객체를 사용하면, 타겟 객체의 동작을 변경하지 않고도 추가적인 기능을 수행할 수 있다.

이후에 좀 더 자세하게 다룰것이니 간단히 개념만 알고 넘어가자.

6. Target object

하나 이상의 aspect에 의해 advice가 수행되는 객체. "advised object"로도 불린다. Spring AOP는 런타임 프록시를 사용하기 때문에 이 객체는 항상 프록시된 객체이다.

7. Introduction

프록시 객체의 일종으로 볼 수 있다. Introduction 기능을 사용해 프록시 객체를 생성하면 기존 객체와 같은 인터페이스를 구현하면서도 추가적인 기능을 수행하는 새로운 객체를 생성할 수 있다.

예를 들어, Introduction을 사용하여 인터페이스를 구현하도록 지정된 빈(인터페이스가 의존성 객체로 지정된 경우, 해당 인터페이스의 구현체 빈이 자동으로 주입된다.)에 새로운 메서드를 추가할 수 있다. 이러한 방식으로, Introduction은 객체의 기능을 확장하고, 코드 중복을 줄이며, 유지보수성을 향상시킨다.

IntroductionInterceptor 인터페이스를 구현하고, 추가적으로 구현하고자 하는 인터페이스(프록시 객체가 구현해야하는 인터페이스)를 지정하여 이를 구현해서 Introduction 기능을 사용할 수 있다.

// Spring AOP의 Introduction을 사용한 객체 확장
// IntroductionInterceptor는 springframework.aop에서 제공하는 인터페이스이다.
public interface IntroductionInterceptor extends MethodInterceptor, DynamicIntroductionAdvice {

}

public class MyAspect implements IntroductionInterceptor {

    // 타깃 객체의 메소드 호출 시 호출되는 메소드
    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        Object target = invocation.getThis();
        if (!(target instanceof MyExtendedClass)) {
            throw new IllegalStateException("Target object must be of type MyExtendedClass");
        }
        // 추가로 구현한 메소드가 호출될 경우 실행
        if (invocation.getMethod().getName().equals("newMethod")) {
            // 새로운 동작 수행
            System.out.println("New method is called");
            return null;
        }
        return invocation.proceed();
    }

    // 프록시 객체 생성 시 추가 동작을 더하고 싶은 인터페이스 지정
    @Override
    public boolean implementsInterface(Class<?> intf) {
        return intf.isAssignableFrom(MyExtendedClass.class);
    }
    
}

위 코드에서 MyAspect 클래스는 IntroductionInterceptor 인터페이스를 구현함으로써 Introduction의 기능을 수행하는 객체가 된다. implementsInterface() 메소드에서 MyAspect가 추가로 구현하고자하는, 즉 추가적인 동작을 더하고 싶은 인터페이스로 MyExtendedClass를 지정한다. 이후 MyExtendedClass 인터페이스를 구현한 객체의 메소드가 호출 될 때 마다, MyAspect 클래스의 invoke() 메소드가 호출된다. invoke() 메소드는 다음과 같이 작동된다.

  • if (!(target instanceof MyExtendedClass)) : 호출되는 메소드가 MyExtendedClass 클래스를 구현한 객체의 메소드인지 확인한다. (target은 실제 타겟 객체이다.)
  • if (invocation.getMethod().getName().equals("newMethod")) : 호출된 메소드가 newMethod() 메소드인지를 확인한다.
  • newMethod() 메소드가 호출되었다면, 새로운 동작을 수행한다. 위 코드에서는 System.out.println()을 사용하여 "New method is called"라는 메시지를 출력하도록 구현되어 있다. 이후 null 값을 반환하여, 실제 메소드가 호출되지 않도록 하고, 프록시 객체를 통해 동작을 수행한다.
  • return invocation.proceed(); : newMethod() 메소드가 아닌 다른 메소드가 호출되었다면, invocation.proceed() 메소드를 호출하여, 실제 타겟 객체의 메소드를 실행하도록 한다.

이런 방식으로, 프록시 객체를 통해 MyExtendedClass 클래스의 특정 메소드를 호출할 때 추가적인 동작을 수행하도록 구현할 수 있다.

if (invocation.getMethod().getName().equals("newMethod")) {
     System.out.println("New method is called");
     return invocation.proceed();
}

위와 같이 returnnull이 아닌 invocation.proceed();를 반환해주면 System.out.println("New method is called");를 실행한 뒤에 실제 타깃의 newMethod()를 호출한다.

앞서 설명한 개념들을 잘 이해했다면 IntroductionInterceptorinvoke 메소드에서 수행하는 동작은 AOP에서 어드바이스가 수행하는 동작과 유사함을 알 수 있을 것이다. AOP에서 어드바이스 또한 대상 메소드의 호출 전, 후 또는 예외 발생 시점에 추가적인 동작을 수행하며, 대상 메소드의 실행 여부를 제어할 수 있다.

8. Weaving

AOP에서 핵심 로직 코드에 Aspect를 적용하는 과정을 말한다.

일반적인 객체 지향 프로그래밍에서는 클래스 파일을 컴파일하여 실행 파일을 생성하는 과정에서 로직이 구현된 메소드 코드가 생성된다. 하지만 AOP에서는 핵심 로직 코드에 Aspect를 적용하기 위해, 컴파일된 클래스 파일에 추가적인 코드를 삽입해야 한다.

이 추가적인 코드 삽입 작업을 Weaving이라고 한다. Weaving은 컴파일 시점, 클래스 로딩 시점, 런타임 시점 중 하나에서 수행되며 대부분의 AOP 프레임워크에서는 런타임 시점에서 코드 삽입 작업을 수행한다.

개념들간의 관계도

위에서 설명한 개념을 바탕으로 관계도를 그려보면 다음과 같다. image.png

  • Aspect는 공통 로직 수행을 위한 정의이다.
  • 이를 정의 하기 위해선 “언제”, “어떤” 작업을 할 것 인지가 필요하다.
    • “언제”를 담당하는 것이 Pointcut이다.
    • “어떤”을 담당하는 것이 Advice이다.
  • 어플리케이션 내에서의 모든 동작은 Join point라고 일컫는다.
    • Advice는 interceptor로 모델링되어 join point 주변에서 interceptor chain을 유지한다.
    • Join point와 정의한 Aspect의 Pointcut이 일치되는 시점에 interceptor가 실행된다.

Spring AOP의 기능과 목표

Spring AOP는 기본적으로 메서드 실행에 대한 join point를 지원하며, 프록시 기반의 방식을 사용한다. 이를 통해 객체의 메서드를 호출할 때, 타깃 객체의 메서드 호출 전에 프록시 객체가 먼저 실행되어 추가적인 기능을 수행할 수 있다.

Spring AOP가 AOP에 대해 해석하는 방식은 다른 여러 AOP 프레임워크와는 다르다. 포괄적인 AOP 솔루션을 제공하는 것은 목표가 아니다.

Spring AOP의 핵심 목표는 좀 더 간결하고 쉬운 AOP 구현과 Spring IoC와의 강력한 통합성을 제공하여 엔터프라이즈 애플리케이션에서 발생하는 공통 문제를 해결하는 것이다.

따라서 Spring AOP와 같은 프록시 기반 프레임워크와 AspectJ와 같은 포괄적인 프레임워크가 상호 보완적일 수 있다. Spring은 Spring AOP와 IoC를 AspectJ와 매끄럽게 통합함으로써 일관된 Spring 기반 애플리케이션 아키텍처에서 AOP가 제공하는 모든 기능을 가능하게 한다.

AOP Proxies


Spring AOP는 기본적으로 실제 객체 대신 프록시 객체가 사용되며 이 때 AOP 프록시는 기본적으로 표준 JDK 동적 프록시를 사용한다.

JDK dynamic proxies

자바의 기본 라이브러리인 java.lang.reflect.Proxy 클래스를 이용하여 생성되는 프록시 객체를 말한다. 인터페이스의 구현체에 대한 프록시 객체를 생성할 수 있으며, 프록시 객체는 대상 객체와 동일한 인터페이스를 구현하므로 클라이언트는 둘을 구분하지 않고 사용할 수 있다. JDK dynamic proxies는 인터페이스를 구현한 대상 객체만 프록시 객체로 만들 수 있다는 한계가 있다.

JDK 동적 프록시는 해당 빈이 구현하고 있는 인터페이스를 기준으로 생성되므로 빈이 구현하고 있는 인터페이스가 없는 경우, 프록시 객체를 생성할 수 없다.

이런 경우에는 CGLIB 라이브러리를 사용해 클래스 기반으로 프록시 객체를 생성 할 수 있다. 하지만 CGLIB는 클래스의 바이트 코드를 조작하기 때문에 프록시 객체를 생성하는 데 비용이 더 많이 들어가는 문제가 있다.

비즈니스 클래스 대신 인터페이스를 구현하는 것이 좋은 프로그래밍 관행이므로, 보통은 인터페이스를 구현하고 JDK 동적 프록시 객체를 사용하는 것을 권장한다.

JDK vs. CGLIB

JDK 프록시와 CGLIB 프록시는 자바에서 프록시를 구현하는 데 사용되는 라이브러리이다.

JDK 프록시는 자바 프록시 API를 사용하여 인터페이스를 기반으로 프록시 객체를 생성한다. 즉, JDK 프록시는 인터페이스를 구현하는 대상 객체를 위한 프록시를 생성한다. JDK 프록시는 동적으로 프록시 객체를 생성하므로 대상 객체가 구현하는 인터페이스만 프록시로 래핑할 수 있다.

반면에, CGLIB 프록시는 자바 바이트 코드 조작 라이브러리를 사용하여 프록시 객체를 생성한다. CGLIB 프록시는 인터페이스를 구현하지 않은 클래스에 대한 프록시를 생성할 수 있다. CGLIB 프록시는 상속을 통해 프록시를 생성하기 때문에 final 클래스나 final 메소드에 대해서는 프록시를 생성할 수 없다.

JDK 프록시는 프록시 객체를 생성하는 데 드는 오버헤드가 적고, 자바에서 기본적으로 지원하는 API를 사용하기 때문에 구현하기 쉽다. 반면에, CGLIB 프록시는 동적으로 클래스를 생성하기 때문에 프록시 객체 생성 시간이 더 오래 걸리지만, 인터페이스를 구현하지 않은 클래스에 대해서도 프록시 객체를 생성할 수 있다는 장점이 있다.

또한, JDK 프록시는 메소드 호출을 처리하기 위해 InvocationHandler 인터페이스를 사용하고, CGLIB 프록시는 Enhancer 클래스를 사용하여 프록시 객체를 생성한다. 이러한 차이점 때문에 JDK 프록시는 코드가 간결하고 가독성이 좋은 반면, CGLIB 프록시는 코드가 더 복잡하고 이해하기 어려울 수 있다.

Spring AOP에서 CGLIB 프록시를 사용하기 위해서는 스프링 설정 파일에 proxy-target-class 속성값을 true로 설정하거나 configuration 파일에 어노테이션을 추가한다.

<aop:aspectj-autoproxy proxy-target-class="true" />
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class AppConfig {
    // ...
}

proxy-target-class 속성의 디폴트값은 false이므로 스프링은 기본적으로 인터페이스를 구현하는 대상 객체에 대해서만 JDK 프록시를 사용하도록 한다.

따라서, proxy-target-class 속성의 값을 true로 설정해 인터페이스를 구현하지 않은 클래스에 대해서도 CGLIB 프록시를 사용하도록 강제할 수 있다.

AOP Proxy 이해

public class SimplePojo implements Pojo {

    public void foo() {
        this.bar();
    }

    public void bar() {
    }
}

위와 같은 코드가 있다고 하였을 때, 일반적으로 메서드를 호출하는 경우 아래 이미지처럼 해당 객체에서 메서드가 직접 호출된다.

image.png

만약 프록시를 참조한다면 작동 방식이 변경된다.

image.png

어떤 클래스에서 SimplePojo 객체에 대한 프록시를 생성하면, 클라이언트 코드에서는 SimplePojo 객체 대신에 프록시 객체에 대한 참조를 가지게 된다. 이렇게 되면, 프록시 객체에 대한 메소드 호출은 프록시를 거쳐서 advice를 실행한다.

하지만 foo()에서 this.bar()로 대상 객체 자신의 메소드를 호출하는 경우에는 프록시 객체를 거치지 않기 때문에 advice가 실행되지 않는다.

이러한 이유로 자체 호출은 advice를 실행할 수 없다. 따라서, 프록시 객체를 사용하여 advice를 적용하고자 할 때는, 대상 객체 내부에서 자기 자신을 호출하는 코드가 있는지 확인하고, 자체 호출을 피해야 한다.

부득이하게 자체 호출이 필요한 경우에는 다음과 같이 자체 호출을 하는 메소드 내부에서 프록시를 가져와 사용하는 방법이 있다.

public class SimplePojo implements Pojo {

    public void foo() {
        ((Pojo) AopContext.currentProxy()).bar();
    }

    public void bar() {
    }
}

하지만 위와 같이 사용하기 위해서는 프록시를 만들 때 추가적인 구성이 필요하다.

@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class AppConfig {

    @Bean
    public SimplePojo simplePojo() {
        return new SimplePojo();
    }

    @Bean
    public RetryAdvice retryAdvice() {
        return new RetryAdvice();
    }

    @Bean
    public ProxyFactoryBean proxyFactoryBean() {
        ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
        proxyFactoryBean.setTarget(simplePojo());
        proxyFactoryBean.addInterface(Pojo.class);
        proxyFactoryBean.addAdvice(retryAdvice());
        proxyFactoryBean.setExposeProxy(true);
        return proxyFactoryBean;
    }
}

proxyFactoryBean.setExposeProxy(true); 에 true값을 설정해주는 것이 목적인데, 이는 클래스에서 직접 어노테이션 등을 사용해서 간편하게 설정할 수 없으므로 Configuration을 이용하는 수 밖에 없다.

public class SimplePojo implements Pojo, ApplicationContextAware {

    private ApplicationContext applicationContext;

    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    public void foo() {
        // this works with ApplicationContextAware
        ((Pojo) applicationContext.getBean("simplePojo")).bar();
    }

    public void bar() {
        // some logic...
    }
}

혹은 ApplicationContextAware를 구현한 뒤, ApplicationContext를 사용하여 프록시 객체를 가져올 수도 있다. 반면에 AspectJ는 프록시 기반이 아닌 대상 객체의 메소드를 실행할 때, 바이트 코드를 조작하여 advice를 적용하는 byte-code weaving 방식을 사용한다. 따라서 프록시 기반 AOP 프레임워크와는 다르게 self-invocation 문제가 발생하지 않는다.

맺음말


AOP는 간결한 코드 작성과 반복적인 코드의 제거, 그리고 모듈 간의 높은 결합도 해소 등을 통해 소프트웨어의 품질을 향상시키는 데 중요한 역할을 한다. 이 글을 통해 AOP의 핵심 개념들과 그들의 상호작용에 대해 더 잘 이해하셨기를 바란다.

다음 글인 “AOP (2) @AspectJ를 이용해 Aspect 구현하기“에서는 @AspectJ를 이용하여 실제 Aspect를 어떻게 구현하는지에 대해 알아볼 것이다. 이를 통해 AOP의 이론을 실제 코드에 어떻게 적용하는지에 대한 실질적인 이해에 도움이 될 것이라 기대한다.

Ref