Spring에서의 요청 흐름 5 - AOP

Updated: Categories:

Spring 여기저기에 숨어있는 개념인 AOP에 대해 알아보자 (feat. Proxy)

AOP

드디어 스프링의 장점 중 하나로 꼽히는 AOP가 등장했다. 일단 AOP의 정의부터 살펴보자.

Aspect Orient Programming - 관점 지향 프로그래밍

음.. 관점 지향이라는게 무엇일까? 이를 쉽게 이해하자면 이렇다.

우리는 Spring으로 개발을 할 때 객체지향적으로 코드를 짠다.
따라서 Service 클래스에서는 특정 서비스를 책임지는 많은 메소드들이 있을 것이다.

근데 여기서 모든 서비스 메소드가 호출될 때마다 로그를 남겨야 한다고 가정하자.
하드코딩을 하면 그냥 모든 메소드에 로깅 코드를 삽입하면 되겠지만 수많은 코드 중복이 발생하게 되고, 이는 각 서비스 메소드가 가지는 책임과는 무관하다.
SRP(단일책임원칙)을 위반하게 되는 것이다.

이런 경우에 사용하는 것이 바로 AOP이다.
AOP는 Spring에 국한되는 기술이 아닌 OOP와 같은 패러다임이고, OOP를 대체하는 것이 아니라 OOP를 더 잘 활용할 수 있도록 해준다.

기본 구조

image

위 예시처럼 로깅과 같은 중복 기능를 횡단 관심사(Cross Cutting Concerns)라고 부른다.
이러한 ConcernAspect로 만들어 주는 것이 AOP의 핵심이라고 할 수 있다


aop

위 그림은 AOP의 구조를 용어와 함께 나타낸 그림이다. 각 용어가 나타내는 의미는 다음과 같다.

  • Aspect : 기존 뜻은 ‘관점’이지만 -> ‘기능’을 묶는 모듈이라고 생각하면 좋다
  • Advice : 기존 Concern을 Aspect 모듈에 넣으면 Advice. 즉 추가할 기능!
  • Target : Advice를 적용할 대상 -> 즉 적용대상 클래스/메소드임
  • PointCut : Advice를 적용할 Target의 메소드를 선별하는 정규표현식
  • Join point : Advice가 PointCut에 적용되는 시점까지 적용한 것이다


추가적으로 Join point에는 다음과 같이 6가지 시점이 존재한다.

image

실제로 적용시켜보면 다음과 같은 순서로 적용된다

Around 시작 -> Before -> AfterReturning or AfterThrowing -> After -> Around 끝


AOP 적용 기술

위에서 AOP는 패러다임이라고 말했는데, 따라서 이를 구현하는 기술은 다양하다.
Java에서의 AOP 구현 종류는 크게 AspectJ 프레임워크와 Spring AOP 두 가지가 있다.

  • AspectJ : PARC에서 개발한 자바 AOP 확장 기능
    • 완벽한 AOP 기능을 제공하는 근본 기술
    • 구현이 복잡하다
  • Spring AOP : Spring에서 사용하는 AOP 기능
    • Spring IoC를 활용해 간단한 AOP 제공
    • Bean만 적용 대상이 된다

즉, 같은 목적이긴 하지만 두 프레임워크에서 AOP를 구현하는 방식은 서로 다르다는 것을 알아야 한다.

Weaving

image

Weaving이란 타겟의 Join pointAdvice를 적용하는 방법을 뜻한다.

아래와 같이 총 세 가지 방법이 존재한다.

  • 컴파일 시점 : 컴파일시 소스코드를 삽입하는 방식 (AspectJ에서 사용)
  • 클래스 로드 시점 : 클래스 로드시 Byte Code를 삽입하는 방식 (AspectJ에서 사용)
  • 런타임 시점 : 런타임시 프록시 객체를 동적 생성하는 방식 (Spring에서 사용)

여기서 우리는 Spring AOP만 살펴보도록 하자.
우선 프록시 객체를 동적 생성하여 Advice를 부여하는 것 같은데, 프록시를 어떻게 사용하는 것일까?

아래 코드는 간단한 Proxy 패턴을 구현한 것이다.

// 기능정의
interface Dummy {
    void doSomething();
}

// target
public static class DummyImpl implements Dummy{
    @Override
    public void doSomething(){
        System.out.println("DO");
    }
}

// proxy
public static class DummyProxy implements Dummy{
    Dummy target;

    DummyProxy(Dummy target){
        this.target = target;
    }

    @Override
    public void doSomething(){
        System.out.println("proxy 적용 드간다");
        target.doSomething();
    }
}

// client
public static void main(String[] args){
    Dummy target = new DummyImpl();
    Dummy proxy = new DummyProxy(target);
    proxy.doSomething();
}

Target 클래스의 기능은 그대로 두고, 기능을 추가하는 데에는 프록시 패턴이 매우 적합하기 때문에 사용되는 것이다.
하지만 위 코드처럼 AOP를 추가할때마다 프록시 클래스를 직접 만들어주는 것은 매우 피곤한 일이다.

따라서 Spring AOP는 자동으로 프록시를 추가해주는 두 가지 방법을 제공한다.

  • JDK dynamic Proxy : Reflection을 통해 프록시 팩토리가 동적으로 프록시 객체를 생성하는 방식
  • CGLIB Proxy : 상속을 사용해 메소드를 재정의한 프록시 객체를 생성하는 방식

두 가지 방법 중 프레임워크가 어떤 방식을 선택하는지에 대한 기준은 아래와 같다.

  • Spring에서는 타겟 클래스의 인터페이스가 정의되었다면 JDK dynamic Proxy, 없다면 CGLIB Proxy로 동작한다.
  • Spring Boot 2버전 이상에서는 CGLIB Proxy이 default로 동작한다

그럼 두 방식이 어떻게 구현되어 있는지 코드로 살펴보자.

JDK Dynamic Proxy

// 기능정의
interface Dummy {
    void doSomething();
}

// target
public static class DummyImpl implements Dummy {
    @Override
    public void doSomething() {
        System.out.println("DO");
    }
}

// jdk dynamic proxy
public static class DummyProxy implements InvocationHandler {
    Dummy target;

    public DummyProxy(Dummy target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if ("doSomething".equals(method.getName())) {
            System.out.println("proxy 적용 드간다");
            return method.invoke(target, args);
        }
        return method.invoke(target, args);
    }
}

// client
public static void main(String[] args) {
    Dummy target = new DummyImpl();
    Dummy proxy = (Dummy) Proxy.newProxyInstance(
        Example.class.getClassLoader(),
        new Class[]{Dummy.class},
        new DummyProxy(target)
    );
    proxy.doSomething();
}

코드 흐름은 다음과 같다.

  1. 프록시를 생성할때 Target 클래스의 인터페이스 정보를 넘겨준다.
  2. 그럼 InvocationHandler에서는 해당 인터페이스를 Reflection하여 인터페이스에 정의된 메소드를 추출한다.
  3. 메소드를 실행(method.invoke)하기 전에 추가 기능(Advice)를 구현한다

인터페이스 정보를 받아 자동으로 프록시를 생성해주긴 하지만, target이 인터페이스를 상속받지 않는다면 동작하지 않는다.
또 Reflection을 사용하기 때문에 성능이 떨어지는 단점도 존재한다.

이런 단점을 보완한 CGLIB proxy에 대해서도 자세히 알아보자.

CGLIB proxy

// target
public static class DummyService {

    public void doSomething() {
        System.out.println("DO");
    }
}

// proxy
public static class DummyProxy implements MethodInterceptor {

    DummyService target;

    DummyProxy(DummyService target) {
        this.target = target;
    }

    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        System.out.println("proxy 적용 드간다");
        return proxy.invokeSuper(obj, args);    // 상속을 통해 적용
    }
}

// client
public static void main(String[] args) {
    DummyService target = new DummyService();
    DummyService proxy = (DummyService) Enhancer.create(
        DummyService.class,
        new DummyProxy(target)
    );
    proxy.doSomething();
}

인터페이스를 사용하지 않고 상속을 사용해 메소드를 재정의하는 것을 볼 수 있다.
Reflection을 사용하지 않고 Byte코드를 조작하기에 JDK dynamic Proxy보다 성능이 뛰어나다.

하지만 단점도 존재하는데, final, private 메소드는 상속할수 없기에 재정의할 수 없다.


정리

Spring AOP 또한 이번 포스팅에서 직접 구현하지 않고 이론만 살펴보았다. (다음 포스팅에서 구현할 예정)

여기까지 Servlet부터 시작해서 AOP까지 스프링의 동작원리에 대해 살펴보았다.
그럼 Spring의 흐름을 처음부터 끝까지 그림으로 정리해보면….

Spring Boot 기준

어우야.. 매우 장황한 구조가 나오게 된다… 😇
하지만 Spring Boot를 사용할 때 이를 이해하고 쓰는 것과 모르고 쓰는 것은 매우 큰 차이가 난다고 생각한다.

마지막 포스팅에서는 지금까지 공부했던 이론들을 실제 코드로 구현해보도록 하겠다.