Spring AOP 동작 원리 및 사용법

smc2315·2024년 2월 26일
1

spring-boot

목록 보기
4/7
post-thumbnail

1. AOP(Aspect Oriented Programming)란

전통적인 객체지향 기술의 설계 방법(추상화와 메서드 추출 등)으로는 독립적인 모듈화가 불가능한 부가기능을 모듈화 하기 위해 Aspect라는 개념을 도입했다.

Aspect란 그 자체로 애플리케이션의 핵심기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다.

아래의 그림을 살펴보면 Aspect를 통해 부가기능을 분리하는 방식을 살펴볼 수 있다.

왼쪽을 보면 부가기능이 핵심기능의 모듈에 침투해 들어가면서 설계와 코드가 모두 지저분해진 것을 볼 수 있다.

오른쪽과 같이 핵심기능과 부가기능을 Aspect를 통해 분리하여 설계와 개발 시에는 다른 특성을 띈 Aspect들을 독립적인 관점으로 작성할 수 있고, 런타임 시에는 왼쪽과 같이 각 부가기능들이 필요한 위치에 다이내믹하게 참여하게 된다.

이렇게 Aspect를 만들어 설계하고 개발하는 방법을 Aspect 지향 프로그래밍 즉, AOP라고 부른다.

2. AOP의 등장 배경

AOP는 기존의 OOP 방식으로는 부가기능의 모듈화가 어렵다는 단점을 극복하기 위해 등장하였다.

매개변수로 이름을 받아 해당 이름에게 인사를 출력하는 핵심기능과 모든 철자를 대문자로 바꾸는 부가기능을 지닌 클래스에서 부가기능을 분리하는 간단한 예제를 통해 AOP에 대해 좀 더 깊게 알아보자. (간단한 예제라 부가기능의 분리의 필요성이 잘 느껴지지 않을 수 있다. 대문자 변환 기능을 트랜잭션 경계설정 기능이나 분산락 적용 기능 등으로 치환하여 생각하면 좀 더 와닿을 것이다.)

2-1. Hello.java

매개변수로 이름을 받아 이름 앞에 인삿말을 추가하여 출력하는 간단한 클래스가 있다.

Hello.java

public class Hello {

    public String sayHello(String name) {
        return "Hello " + name;
    }

    public String sayHi(String name) {
        return "Hi " + name;
    }

    public String sayThankYou(String name) {
        return "Thank You " + name;
    }
}

이 클래스의 각 메서드에 리턴값의 모든 철자를 대문자로 바꿔서 출력해야 되는 상황이 발생했다.

아래와 같이 모든 메서드에 toUpperCase()를 달아서 이를 구현하였다.

대문자 변환 로직이 추가된 Hello.java

public class Hello {

    public String sayHello(String name) {
        return ("Hello " + name).toUpperCase();
    }

    public String sayHi(String name) {
        return ("Hi " + name).toUpperCase();
    }

    public String sayThankYou(String name) {
        return ("Thank You " + name).toUpperCase();
    }
}

해당 코드에는 어떠한 문제점이 존재할까?

Hello 클래스는 단일책임원칙을 위배하고 있다.

Hello 클래스의 모든 메서드는 주 기능인 인삿말을 출력하는 역할과는 무관한 대문자 변환 로직을 품고 있다.

또한 대문자변환 로직의 중복이 발생했다.

이는 해당 로직을 메서드로 추출함으로써 어느정도 해결할 수 있지만 다른 클래스에서 해당 기능을 사용할려면 중복이 또 발생하게 되고, 메서드 추출을 하더라도 부가기능이 여전히 Hello 클래스에 남아있다. (대문자 변환 기능을 트랜잭션 경계설정 기능으로 변환하면 좀더 와닿을 수 있다. 모든 메서드에 트랙잭션 경제설정 로직이 존재한다면 엄청 복잡해질 것이다.)

어떻게 하면 부가기능을 주요기능에서 분리할 수 있을까?

2-2. 데코레이터 패턴

대문자 변환 코드를 Hello 밖으로 빼내게 되면 Hello 클래스를 직접적으로 사용하는 클라이언트 코드(ex. Controller)에서는 대문자 변환 기능이 빠진 Hello를 사용하게 될 것이다. 구체적인 구현 클래스를 직접 참조하는 경우의 전형적인 단점이다.

직접 사용하는 것이 문제라면 간접적으로 사용하면 어떨까?

DI 개념을 활용하여 인터페이스를 도입하여 약한 결합을 갖도록 바꿀 수 있다. 인터페이스의 구현 클래스를 두개로 두어 메인 로직과 부가 기능을 분리하여 보자.

Hello interface

public interface Hello {

    String sayHello(String name);
    
    String sayHi(String name);
    
    String sayThankYou(String name);
}

HelloImpl

public class HelloImpl implements Hello {

    public String sayHello(String name) {
        return "Hello " + name;
    }

    public String sayHi(String name) {
        return "Hi " + name;
    }

    public String sayThankYou(String name) {
        return "Thank You " + name;
    }
}

HelloUppercase

@RequiredArgsConstructor
public class HelloUppercase implements Hello {

    private final Hello hello;

    public String sayHello(String name) {
        return hello.sayHello(name).toUpperCase();
    }

    public String sayHi(String name) {
        return hello.sayHi(name).toUpperCase();
    }

    public String sayThankYou(String name) {
        return hello.sayThankYou(name).toUpperCase();
    }
}

HelloImpl 클래스는 Hello 인터페이스를 구현한 비즈니스 로직만을 지닌 깔끔한 클래스이다.

대문자 변환로직은 HelloUppercase를 통해 구현하였다. HelloUppercase는 Hello의 비즈니스 로직들은 전혀 갖지 않고 다른 Hello의 구현체에 기능을 위임한다. 이를 위해 Hello 오브젝트를 DI 받을 수 있도록 만든다.

Config.java

    @Bean
    public Hello hello(HelloImpl helloImpl) {
        return new HelloUppercase(helloImpl);
    }

위와 같이 빈을 등록하면 클라이언트가 Hello라는 인터페이스를 통해 비즈니스 로직에 부가기능인 대문자 변환 기능을 추가한 작업을 수행할 수 있다.

여기서 HelloUppercase와 같이 자신이 클라이언트가 사용하려하는 실제 대상인 것처럼 위장해서 클라언트의 요청을 받아주는 것을 프록시라고 부른다.
또한 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃이라고 부른다.

프록시는 사용 목적에 따라 두 가지로 구분된다.

  1. 클라이언트가 타깃에 접근하는 방법을 제어 -> 프록시 패턴
  2. 타깃에 부가적인 기능을 부여 -> 데코레이터 패턴

디자인 패턴에서는 각 목적에 따라 프록시를 사용하는 다른 패턴으로 구분한다.

앞서 구현한 HelloUppercase와 같은 경우에는 타깃에 대문자 변환이라는 부가적인 기능을 부여하는 데코레이터 패턴을 적용한 것으로 볼 수 있다.

2-3. 다이내믹 프록시 & 프록시 팩토리 빈

프록시는 기존 코드에 영향을 주지 않으면서 타깃의 기능을 확장하거나 접근 방법을 제어할 수 있는 유용한 방법이다.

하지만 매번 인터페이스를 구현한 프록시를 만드는건 쉽지 않은 일이다.

프록시를 만드는 것이 번거로운 이유는 두 가지가 존재한다.

  1. 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거롭다.
  2. 부가기능 코드가 중복될 가능성이 많다.

프록시를 만들라면 매번 부가기능이 필요 없는 메소드도 구현해서 타깃으로 위임하는 코드를 일일이 만드는 번거로움이 존재한다.
또한, 위의 HelloUppercase에서 볼 수 있듯이 여전히 모든 메서드에 toUppercase()를 부여해야 한다는 중복이 발생한다.

다이내믹 프록시를 통해 이러한 번거로움을 줄일 수 있다.

다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트다.

다이내믹 프록시는 자바의 리플랙션 기능을 이용하여 프록시를 만들어준다.

UppercaseHandler

@RequiredArgsConstructor
public class UppercaseHandler implements InvocationHandler {

    private final Object target;

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Object ret = method.invoke(target, args);
        if (ret instanceof String) {
            return ((String)ret).toUpperCase();
        }
        return ret;
    }
}

위와 같이 다이내믹 프록시로부터 요청을 전달받으려면 InvocationHandler를 구현해야 한다.

다이내믹 프록시가 클라이언트로부터 받는 모든 요청은 invoke() 메서드로 전달된다.

invoke()에서 부가기능을 수행하고 리턴된 값은 다이내믹 프록시가 받아서 최종적으로 클라이언트에게 전달된다.

아래와 같이 다이내믹 프록시를 생성할 수 있다.

다이내믹 프록시 생성

Hello proxiedHello = (Hello)Proxy.newProxyInstance(
	getClass().getClassLoader(), // 동적으로 생성되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더
    new Class[] { Hello.class }, // 구현할 인터페이스
    new UppercaseHandler(new HelloImpl())); // 부가기능과 위임 코드를 담은 InvocationHandler
)

ProxynewProxyInstance() 메서드를 통해서만 생성이 가능한 다이내믹 프록시 오브젝트를 빈으로 등록하기 위해서는 팩토리 빈을 사용해야 한다.

UpperProxyFactoryBean.java

@RequiredArgsConstructor
public class UpperProxyFactoryBean implements FactoryBean<Object> {

    private final Object target;
    private final Class<?> serviceInterface;

    public Object getObject() throws Exception {
        UpperClassHandler upperClassHandler = new UpperClassHandler(target);
        return Proxy.newProxyInstance(
                getClass().getClassLoader(), new Class[]{ serviceInterface }, upperClassHandler
        );
    }

    public Class<?> getObjectType() {
        return serviceInterface;
    }

    public boolean isSingleton() {
        return false;
    }
}

Config.java

    @Bean
    public Hello hello(HelloImpl helloImpl) throws Exception {
        UpperProxyFactoryBean upperProxyFactoryBean = new UpperProxyFactoryBean(helloImpl, Hello.class);
        return (Hello) upperProxyFactoryBean.getObject();
    }

FactoryBean을 구현하여 위와 같이 Bean으로 등록하여 다이내믹 프록시를 사용할 수 있다.

프록시 팩토리 빈을 사용하여 앞서 데코레이터 패턴에서 발생한 두 가지 문제점을 해결하였다. 프록시 클래스를 일일이 만들어야 하는 번거로움이 줄어들었고, 부가적인 기능이 여러 메서드에 반복적으로 나타나는 코드 중복 문제를 해결하였다.

하지만 프록시 팩토리 빈에는 여전히 몇 가지 문제점이 존재한다.

  1. 한 번에 여러 개의 클래스에 공통적인 부가기능을 부여할 수 없어 팩토리 빈 설정이 중복된다.
  2. 하나의 타깃에 여러 부가기능을 적용할 때에 프록시 팩토리 빈 설정이 매번 증가하는 번거로움이 존재한다.
  3. UppercaseHandler가 팩토리 빈 개수만큼 만들어진다.

2-4. 스프링의 프록시 팩토리 빈

스프링은 이러한 문제를 스프링의 ProxyFactoryBean를 통해 해결한다.

스프링의 ProxyFactoryBean은 앞서 만든 UpperProxyFactoryBean과는 다르게 순수하게 프록시를 생성하는 작업만을 담당하고 프록시를 통해 제공해줄 부가기능은 별도에 빈에 둘 수 있다.

UppercaseAdvice.java

@Component
public class UppercaseAdvice implements MethodInterceptor {

    public Object invoke(MethodInvocation invocation) throws Throwable {
        String ret = (String)invocation.proceed();
        return ret.toUpperCase();
    }
}

Config.java

    @Bean
    public NameMatchMethodPointcut uppercasePointcut() {
        NameMatchMethodPointcut uppercasePointcut = new NameMatchMethodPointcut();
        uppercasePointcut.setMappedName("sayH*");
        return uppercasePointcut;
    }

    @Bean
    public DefaultPointcutAdvisor uppercaseAdvisor() {
        return new DefaultPointcutAdvisor(uppercasePointcut(), uppercaseAdvice);
    }

    @Bean
    public Hello hello(HelloImpl helloImpl) {
        ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
        proxyFactoryBean.setBeanFactory(beanFactory);
        proxyFactoryBean.setTarget(helloImpl);
        proxyFactoryBean.setInterceptorNames("uppercaseAdvisor");
        return (Hello) proxyFactoryBean.getObject();
    }

Advice: 타깃이 필요 없는 순수한 부가기능
Pointcut: 부가기능 적용 대상 메서드 선정 방법
Advisor: Pointcut + Advice

스프링의 ProxyFactoryBean은 DI와 템플릿/콜백 패턴, 서비스 추상화 등의 기법이 모두 적용되어 독립적이며, 여러 프록시가 공유할 수 있는 Advice와 Pointcut으로 확장 기능을 분리할 수 있다.

이제 대문자변환 기능이 필요한 새로운 클래스가 생기더라도 이미 만들어둔 Advice와 Pointcut을 재활용하여 쉽게 구현할 수 있게 되었다.

2-5. 자동 프록시 생성

스프링의 ProxyFactoryBean을 도입하여 대부분의 중복을 줄일 수 있었지만 아직 한 가지 문제점이 남아있다.

새로운 타깃이 등장하게 되면 매번 ProxyFactoryBean 빈 설정정보를 추가해줘야 한다.

이는 빈 후처리기를 이용한 자동 프록시 생성기를 통해 해결할 수 있다.

빈 후처리기는 스프링 빈 오브젝트로 만들어지고 난 후에, 빈 오브젝트를 다시 가공할 수 있게 해준다.

빈 후처리기 중 하나인 DefaultAdvisorAutoProxyCreator는 Advisor를 이용한 자동 프록시 생성기이다.

DefaultAdvisorAutoProxyCreator 빈 후처리기가 등록되어 있으면 스프링은 빈 오브젝트를 만들 때마다 후처리기에 빈을 보낸다. DefaultAdvisorAutoProxyCreator는 등록된 모든 Advisor 내의 Pointcut을 이용해 전달받은 빈이 프록시 적용 대상인지 확인하고, 내장된 프록시 생성기를 통해 해당 빈에 대한 프록시를 생성한다. 컨테이너는 최종적으로 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용한다.

아래의 예시를 통해 자동 프록시 생성을 구현하여 보자.

NameMatchClassMethodPointcut.java

public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {

    public void setMappedClassName(String mappedClassName) {
        this.setClassFilter(new SimpleClassFilter(mappedClassName));
    }

    @RequiredArgsConstructor
    static class SimpleClassFilter implements ClassFilter {

        private final String mappedName;

        public boolean matches(Class<?> clazz) {
            return PatternMatchUtils.simpleMatch(mappedName, clazz.getSimpleName());
        }
    }
}

앞서 사용한 NameMatchClassMethodPointcut은 클래스 필터 기능이 존재하지 않는다. (모든 클래스를 하용하는 기본 필터만 존재) NameMatchClassMethodPointcut 확장하여 클래스 필터 설정 메서드를 추가할 수 있다.

Config.java

  @Bean
    public NameMatchClassMethodPointcut uppercasePointcut() {
        NameMatchClassMethodPointcut nameMatchClassMethodPointcut = new NameMatchClassMethodPointcut();
        nameMatchClassMethodPointcut.setMappedClassName("*Impl");
        nameMatchClassMethodPointcut.setMappedName("sayH*");
        return nameMatchClassMethodPointcut;
    }
    
    @Bean
    public DefaultPointcutAdvisor uppercaseAdvisor() {
        return new DefaultPointcutAdvisor(uppercasePointcut(), uppercaseAdvice);
    }

    @Bean
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        return new DefaultAdvisorAutoProxyCreator();
    }
    

Advisor와 Advice는 수정할 것이 없다.

이제 DefaultAdvisorAutoProxyCreator에 의해 자동으로 Advisor가 수집되고 프록시가 만들어져 빈으로 등록된다.

2-6. 그래서 AOP가 뭔데

관심사가 같은 코드를 분리하여 모으는 것은 소프트웨어 개발의 가장 기본이 되는 원칙이다.

코드를 분리하고, 모으고, 인터페이스를 도입하고, DI를 통해 런타임 시에 의존관계를 만들어줌으로써 대부분의 문제를 해결할 수 있다.

하지만 트랜잭션이나 분산락, 로깅 등과 같은 부가기능은 핵심기능과는 같은 방식으로 모듈화하기가 매우 힘들다. 부가기능은 말 그대로 부가기능이기에 각 기능을 부가할 대상인 타깃의 코드 안에 침투하거나 긴밀하게 연결되어 있다.

이러한 부가기능을 독립적인 모듈로 만들기 위해 AOP라는 개념이 등장하였다. 앞서 살펴본 DI, 데코레이터 패턴, 다이내믹 프록시, 오브젝트 생성 후처리, 자동 프록시 생성과 같은 기법이 이러한 문제를 해결하기 위한 대표적인 방법이다. 덕분에 부가기능은 Advice로 모듈화할 수 있었다.

Advice는 독립적으로 모듈화되어 있기에 중복되지 않으며, 변경이 필요한 부분만 수정하면 된다. 또한 Pointcut을 통해 부가기능을 부여할 대상을 선정할 수 있었다.

앞서 만든 Advice와 Pointcut을 결합한 Advisor를 통해 부가기능을 분리한 원시적인 형태의 모듈을 만들 수 있었다.

이렇게 만든 모듈을 객체지향 기슬에서 사용하는 오브젝트와는 다르게 특별한 이름인 Aspect라고 부른다.

앞서 진행한 작업처럼 애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 Aspect라는 모듈로 만들어 설계하고 개발하는 방법을 Aspect Oriented Programming 즉, AOP라고 부른다.

3. Spring의 AOP

Spring AOP에서는 두 가지 방식으로 proxy 객체를 생성한다.

3.1 JDK Dynamic Proxy

JDK Porxy는 앞서 설명한 InvocationHandler를 사용한 방식으로 타깃의 인터페이스를 기반으로 리플랙션을 활용하여 Proxy 객체를 생성한다.

JDK Dynamic Proxy가 Proxy 객체를 생성해주는 과정은 다음과 같다.

  1. 타깃의 인터페이스를 자체적인 검증 로직을 통해 ProxyFactory에 의해 타깃의 인터페이스를 상속한 Proxy 객체 생성
  2. Proxy 객체에 InvocationHandler를 포함시켜 하나의 객체로 반환

JDK Dynamic Proxy 방식은 다음과 같은 단점이 있다.

  • Reflection API를 사용하여 Proxy 객체를 생성하기 때문에 비교적 느리다.
  • 인터페이스를 기반으로 Proxy 객체를 생성하기 때문에 인터페이스가 반드시 존재해야한다.
  • Invocation Hanlder를 재정의한 invoke 코드를 직접 구현해줘야 부가기능이 추가된다.

3.2 CGLib(Code Generator Library) Proxy

CGLib은 Code Generator Library의 약자로, 클래스의 바이트코드를 조작하여 Proxy 객체를 생성해주는 라이브러리다.
Spring은 CGLib를 통해 인터페이스 없이도 Proxy를 생성할 수 있는 방식을 제공한다.

CGLib는 다음과 같이 타깃의 클래스를 상속받아 Proxy를 생성한다.

CGlib은 바이트 코드로 조작하여 Proxy를 생성해주기 때문에 성능에 대한 부분이 JDK Dynamic Proxy보다 좋다.

CGLib 방식에는 다음과 같은 한계가 존재한다.

  • net.sf.cglib.proxy.Enhancer 의존성 추가
  • default 생성자의 강제
  • 타깃의 생성자 두 번 호출

하지만 Spring 3.2 버전 부터는 Objensis 라이브러리의 도움을 받아 default 생성자 없이도 Proxy를 생성할 수 있게 되었고, 생성자가 2 번 호출되던 상황도 같이 개선이 되어 Spring boot에는 CGLib 방식을 기본 방식으로 채택하여 사용하고 있다.

3.3 @Aspect를 활용한 AOP 사용

실제 스프링에서 AOP를 활용하여 개발하는 방식은 어노테이션을 활용하여 앞서 진행한 방식보다 훨씬 간단하다.

대문자변환 기능을 AOP를 통해 구현하여 보자.

UppercaseAspect

@Aspect
@Component
public class UppercaseAspect {

   @Around("execution(* *..*Impl.sayH*(..))")
   public Object convertToUppercase(ProceedingJoinPoint joinPoint) throws Throwable {
       Object result = joinPoint.proceed();
       if (result instanceof String strResult) {
           return strResult.toUpperCase();
       }
       return result;
   }
}

@Around를 메서드에 붙여 통해 Target의 앞, 뒤에 공통관심부분의 코드 (Advice)를 작성할 수 있다.
메서드에서 Target 메서드가 실행될 지점에 joinPoint.proceed()를 호출해주면 된다.

즉, @Aspect가 달린 클래스를 하나의 Advisor로 볼 수 있다.

@Around만 사용해도 모든게 가능하지만, 필요에 따라 범위를 제한해둘 수 있다.

참고

토비의 스프링 3.1

profile
개발일지

0개의 댓글