Developer.

[멋사 백엔드 19기] TIL 48일차 Spring Core AOP

📂 목차


📚 본문

Spring AOP

assets/img/cross-cutting-concerns.webp

AOP 개념

횡단 관심사(cross-cutting-concern) 를 모듈화한 단위이며, 로깅, 트랜잭션, 보안 등등이 그 단위이다. 이러한 관심사를 하나로 묶어서 관리하는게 AOP 이다.

Aspect

Spring 에서 Aspect 는 보통 @Aspect 로 표시된 클래스 형태로 만들어지며, 여러 서비스에 동일하게 찍히는 로깅 코드를 그냥 반복하지 말고 Aspect 로 빼내도록 할 수 있다.

Aspect 는 클래스 수준의 어노테이션

Join Point

코드 실행 중에 Aspect끼어들 수 있는 지점을 말한다.

스프링 AOP 에서는 주로 메서드 실행 이 그 지점이며 비즈니스 메서드가 호출될 때 그 지점이 될 수도 있다. 보통은 우리가 관심을 갖는 로깅/트랜잭션/보안은 메서드 진입, 종료 지점이라서 JoinPoint메서드 호출로 한정되며, 이를 알고 설계하면 복잡도가 줄어들 수 있다.

Pointcut

어느 Join Point 들에 Advice 를 적용할 지를 선택하는 필터(Predicate) 이며, 특정 메서드 호출들에 대해서만 이 Aspect 를 적용할 수 있게 정의한다.

스프링에서는 AspectJ 스타일의 포인트컷 표현식(Pointcut Expression) 을 사용하게 된다.

Advice

Aspect 가 실제로 실행하는 행위(action) 이며, 특정 Join Point 에서 이렇게 하라 저렇게 하라 할 수 있는 행위 정의서이다. 이 후에 나오겠지만 Before, After Returning, After Throwing, After, Around 등등이 있다.

Weaving

Aspect(= Advice + Pointcut) 를 실제 객체에 linking 하는 동작이며 설계한 Aspect 로직이 실제 타겟 객체의 Join Point 에 끼워지는 과정이다.

Spring AOP 동작 방식

Spring AOP 는 위 개념들을 토대로 실제 런타임에 프록시 객체(Proxy Object) 를 만들어서 원래의 객체 메서드를 감싸고(Weaving) 메서드 실행 전후로 부가 기능(Advice)을 끼워넣는 방식으로 동작한다.

프록시 기반 AOP

스프링은 AOP 를 컴파일 시점이 아니라 런타임에 적용하게 되는데, 즉 Target Object 를 감싸는 프록시를 생성하여 AOP 기능을 수행한다. 프록시는 타겟 객체 대신 호출을 받아 메서드 실행 전/후/예외 발생 시점에 등록된 Advice 를 실행하고, 그 다음 실제 로직을 실행하게 된다.

클라이언트
   ↓
프록시(Proxy) — Advice 적용 (ex. 로깅, 트랜잭션)
   ↓
실제 대상(Target Object)
JDK Dynamic Proxy

Spring AOP 는 기본적으로 프록시 기반으로 동작한다. 이 중 JDK Dynamic Proxy 는 인터페이스가 존재하는 클래스의 경우 Java 표준 라이브러리(java.lang.reflect.Proxy)를 사용해 런타임 프록시 객체를 생성한다.

interface Service {
    void serve();
}

class RealService implements Service {
    public void serve() {
        System.out.println("Real service logic");
    }
}

Service proxy = (Service) Proxy.newProxyInstance(
    Service.class.getClassLoader(),
    new Class[] { Service.class },
    (proxyObj, method, args) -> {
        System.out.println("Before advice");
        Object result = method.invoke(new RealService(), args);
        System.out.println("After advice");
        return result;
    }
);

여기서 Class<?>.getClassLoader() 는 Class 객체의 메서드로 getClassLoader() 를 호출할 수 있는데 해당 클래스를 로딩했던 클래스 로더를 반환하게 된다. 보통 대부분 AppClassLoader 로 로딩되기 때문에 이를 들고오게 될 것이다.

두번째 인자로는 어떤 인터페이스를 구현할 것인지 그 인터페이스 목록을 지정하게 된다. 프록시는 인터페이스 기반으로 동작하기 때문에 어떤 인터페이스를 프록시가 흉내낼 지를 지정해야한다.

마지막으로는 람다식으로 InvocationHandler 의 내부적으로 invoke() 를 실행할 수 있는 method 가 있다. JDK Proxy 는 프록시 된 객체의 메서드가 호출될 때마다 이 핸들러의 invoke() 메서드를 실행한다.

new InvocationHandler() {
    @Override
    public Object invoke(Object proxyObj, Method method, Object[] args) throws Throwable {
        System.out.println("Before advice");
        Object result = method.invoke(new RealService(), args);
        System.out.println("After advice");
        return result;
    }
}

람다식 내부의 구조는 메서드 호출 전후로 로직을 삽입하므로, @Around 어드바이스의 동작 원리를 단순화 해 직접 구현한 형태라고 볼 수 있다.

스프링에서는 @Aspect 가 붙은 Advice 들을 이런 방식으로 자동으로 연결해준다. 단, 대상 클래스가 반드시 인터페이스를 구현해야 JDK Proxy 를 쓸 수 있고 인터페이스가 없을 경우에는 Spring 이 자동으로 CGLIB 을 사용하여 클래스 기반 프록시를 생성한다.

CGLIB Proxy

대상 클래스가 인터페이스를 구현하지 않은 경우에 사용되며, 클래스를 상속(subclassing) 하여 프록시 객체를 만든다. 내부적으로는 바이트 코드를 조작하여 프록시 클래스를 동적으로 생성하게 된다(RealService 를 상속한 RealService$$EnhancerBySpringCGLIB 같은 클래스가 만들어짐) CGLIB 은 ASM 기반으로 동작하며, final 메서드나 final 클래스는 프록시가 불가하다.

클래스 RealService
   
CGLIB Enhancer  서브클래스 생성 (RealService$$EnhancerBySpringCGLIB)
   
Advice 적용 (ex. @Before, @Around )

AOP 적용 방법

이제 활용해보자.

@Aspect 어노테이션

@AspectPointcut + Advice 를 결합한 형태로 이 클래스가 AOP 의 핵심 단위인 Aspect 임을 명시하게 된다. 이때 @Aspect 만 한다면 그냥 단순히 이는 Aspect 라고 표시만 할 뿐, AOP 가 실젤 동작하려면 스프링이 이 클래스를 빈으로 관리하도록 해야하므로 @Component@Bean 을 통해 등록해줘야 한다.

주요 메서드들

  • @Before: 메서드 실행 전 동작
  • @After: 메서드 실행 후 무조건 동작
  • @AfterReturning: 정상 리턴 시만 동작
  • @AfterThrowing: 예외 발생 시 동작
  • @Around: 메서드 전후 제어 가능

코드는 이따가 보자.

@EnableAspectJAutoProxy
import org.springframework.context.annotation.EnableAspectJAutoProxy;

스프링 AOP 기능을 활성화하는 스위치 역할을 하며, @Aspect 로 정의한 클래스를 실제 프록시로 연결할 수 있게 한다. 내부적으로 AnnotationAwareAspectJAutoProxyCreator 빈을 등록하여, 모든 빈을 스캔하며 AOP 설정이 있으면 자동으로 프록시 객체를 생성해준다.

Spring Boot 에는 AutoConfiguration 기능이 있기에 자동으로 AOP 관련 설정을 포함하는 AopAutoConfiguration 클래스를 로딩시켜서 굳이 어노테이션을 추가하지 않아도 된다.

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ EnableAspectJAutoProxy.class, Aspect.class })
@ConditionalOnProperty(prefix = "spring.aop", name = "auto", havingValue = "true", matchIfMissing = true)
@EnableAspectJAutoProxy(proxyTargetClass = false)
public class AopAutoConfiguration {
    ...
}

Advice 종류별 예제

@Before

실행 시점은 대상 메서드 실행 전이며, 입력값 검증, 권한 체크, 로깅 시작 등등의 기능으로서 사용할 수 있다.

@Aspect
@Component
public class LoggingAspect {

    // service 패키지의 모든 메서드 실행 전에 동작
    @Before("execution(* com.example.service..*(..))")
    public void beforeAdvice(JoinPoint joinPoint) {
        System.out.println("[Before] 실행 메서드: " + joinPoint.getSignature().getName());
    }
}

JoinPoint 객체를 통해 실행된 메서드 명, 인자 값 등 메타데이터 접근이 가능하다. before 안에 들어가는 pointcut 표현식은 이따가 본다.

@After

대상 메서드 실행 후 수행되며, 리소스 해제, 로그 마무리, 공통 후처리 등의 기능을 수행하도록 할 수 있다.

@Aspect
@Component
public class LoggingAspect {

    @After("execution(* com.example.service..*(..))")
    public void afterAdvice(JoinPoint joinPoint) {
        System.out.println("[After] 메서드 종료: " + joinPoint.getSignature().getName());
    }
}

예외가 발생되어도 이는 실행된다.

@AfterReturning

메서드가 정상적으로 리턴될 때만 실행되며, 결과값 로딩, 정상 처리 이후 추가 로직 등을 넣고 싶을때 사용할 수 있다.

@Aspect
@Component
public class LoggingAspect {

    @AfterReturning(
        pointcut = "execution(* com.example.service..*(..))",
        returning = "result"
    )
    public void afterReturningAdvice(JoinPoint joinPoint, Object result) {
        System.out.println("[AfterReturning] 메서드: " + joinPoint.getSignature().getName());
        System.out.println("[AfterReturning] 반환값: " + result);
    }
}

returning 속성을 통해 Object 값을 받아올 수 있다. 이때 타입 검사는 해주어야 한다.

@AfterThrowing

대상 메서드 실행 중 예외가 발생했을 때만 수행되며, 예외 로깅, 에러 감시, 트랜잭션 롤백 등을 처리하고 싶을때 사용하게 된다.

@Aspect
@Component
public class ExceptionAspect {

    @AfterThrowing(
        pointcut = "execution(* com.example.service..*(..))",
        throwing = "ex"
    )
    public void afterThrowingAdvice(JoinPoint joinPoint, Exception ex) {
        System.out.println("[AfterThrowing] 메서드: " + joinPoint.getSignature().getName());
        System.out.println("[AfterThrowing] 예외 발생: " + ex.getMessage());
    }
}

예외를 잡아서 처리할 수도 있지만, 기본적으로는 로깅이나 감시용으로 사용한다.

@Around

실행 시점은 메서드 전/후/예외 모두 제어가 가능하며, 가장 강력한 어드바이스다(모든 시점에 개입이 가능하기 때문). 실행 시간 측정, 트랜잭션 관리, 캐싱 등을 사용하고 싶을때 사용하게 된다.

@Aspect
@Component
public class PerformanceAspect {

    @Around("execution(* com.example.service..*(..))")
    public Object aroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();

        System.out.println("[Around] Before - 메서드: " + pjp.getSignature().getName());

        Object result = null;
        try {
            result = pjp.proceed();  // 실제 대상 메서드 호출
        } catch (Exception e) {
            System.out.println("[Around] 예외 발생: " + e.getMessage());
            throw e;
        }

        long end = System.currentTimeMillis();
        System.out.println("[Around] After - 실행시간: " + (end - start) + "ms");

        return result;
    }
}

ProceedingJoinPoint.proceed() 를 호출해야 실제 타겟 메서드가 실행되며, 이 호출 전후로 원하는 코드들을 넣을 수 있어 try-finally 처럼 작동시킬 수 있다.

Pointcut 표현식

이제 Advice 를 어디에 적용시켜야 할지를 알려주는 개념인 Pointcut 을 보자. 표현식으로는 AsspectJ 표현식 문법을 사용하게 된다.

execution()

execution 은 메서드 실행 시점을 기준으로 이 메서드가 호출될 때 Advice 를 적용하여라 라고 정의하는 표현식이다.

execution([접근제어자] 리턴타입 [패키지명.]클래스명.메서드명(파라미터))

다음과 같이 다양한 예제들을 포함시켰다:

// 1. 모든 public 메서드
execution(public * *(..))

// 2. com.example.service 패키지의 모든 메서드
execution(* com.example.service.*.*(..))

// 3. com.example.service 하위 패키지까지 포함한 모든 메서드
execution(* com.example.service..*(..))

// 4. 특정 클래스의 모든 메서드
execution(* com.example.service.UserService.*(..))

// 5. 특정 이름의 메서드만
execution(* com.example.service.UserService.get*(..))

// 6. 특정 타입의 파라미터를 가진 메서드
execution(* com.example..*(String, ..))

주의: .. 하위 패키지들(하위 패키지 전부), * 는 해당 패키지 내에서의 클래스들

within, this, target, args

execution 외에도 다양한 포인트컷이 있으며, 각각은 매칭 기준이 무엇이냐에 따라 다르다.

  • within: 특정 타입(클래스 또는 패키지) 내부 메서드
    • within(com.example.service.*)
  • this: 프록시 객체(Spring 이 만든 AOP 프록시) 의 타입이 지정 타입과 일치
    • this(com.example.service.UserService)
  • target: 실제 대상 객체(프록시가 감싸고 있는 원본 객체) 의 타입이 지정 타입과 일치
    • target(com.example.service.UserService)
  • args: 메서드의 파라미터 타입에 따라 매칭
    • args(String, int) 또는 args(.., String)
// service 패키지 안에 있는 모든 메서드
@Pointcut("within(com.example.service.*)")
public void serviceLayer() {}

// 프록시 객체가 UserService 타입일 때
@Pointcut("this(com.example.service.UserService)")
public void proxyType() {}

// 실제 대상 객체가 UserService 타입일 때
@Pointcut("target(com.example.service.UserService)")
public void targetType() {}

// String 타입 인자를 받는 모든 메서드
@Pointcut("args(String)")
public void stringArgMethods() {}

this vs target
this -> 프록시 타입 기준 (Spring AOP Proxy)
target -> 실제 원본 객체 기준
CGLIB 기반 프록시일 때는 거의 같지만, JDK Dynamic Proxy 를 쓸 땐 차이가 발생 가능

복합 표현식

여러 포인트컷을 논리적으로 결합할 수도 있다.

// service 패키지에 있으면서 get으로 시작하는 메서드
execution(* com.example.service..get*(..)) && within(com.example.service.*)

// service 패키지거나 repository 패키지인 경우
within(com.example.service..*) || within(com.example.repository..*)

// 특정 메서드는 제외
execution(* com.example.service..*(..)) && !execution(* com.example.service..delete*(..))

복합 표현식은 &&, ||, ! 를 활용하여 AOP 를 좀 더 세밀하게 제어할 수 있어서 유용하다.

로깅은 모든 메서드에 걸지만, 민감한 데이터 관련 메서드는 따로 제외하는 식으로 조합 가능

실습

Logging AOP
Transaction AOP
Security AOP

AOP 와 Proxy 의 한계

AOP 는 매우 강력하지만, Proxy-based 의 구조적 한계 때문에 몇가지 주의할 점이 존재한다. 내부적으로 프록시 객체가 대상 객체를 감싸서 호출을 가로채는 방식으로 Advice 를 적용하는데에 문제가 있다는 것이다.

내부 호출 문제

AOP 는 프록시 객체를 통해 호출될 때만 동작하게 된다. 즉, 같은 클래스 내부에서 자기 자신의 메서드를 호출하는 경우 프록시를 거치지 않고 직접 호출이 되어버리므로 Advice 가 동작하지 않는다.

@Service
public class UserService {

    @Transactional
    public void outer() {
        inner(); // 내부 호출 → 프록시를 거치지 않음
    }

    @Transactional
    public void inner() {
        System.out.println("DB 작업 중...");
    }
}

outer() 를 호출하면 inner()@Transactional 이 붙어있지만, 스프링은 inner() 호출 시에 프록시를 거치지 않기 때문에 트랜잭션이 실제로 적용되지 않는다.

해결

  1. 메서드 분리: 내부 호출을 가급적 없애고, 다른 빈(클래스)로 분리시킨다.
    • UserService -> InnerService 로 분리 후 의존 주입하여 호출
  2. AspectJ Compile-Time Weaving (CTW) 사용
    • 프록시 기반이 아닌, 바이트 레벨에서 weaving 을 한다.
      (일반적인 Spring Boot 에서는 거의 사용하지 않는다고 한다.)
프록시 방식의 한계

Spring AOP 는 런타입 프록시 기반이기 때문에 다음과 같은 제약이 있다.

  1. 프록시 대상이 Spring Container 관리 빈이어야 한다.
    • @Component, @Service 등으로 등록되지 않은 객체에는 AOP 가 적용되지 않는다.
  2. final 클래스 / final 메서드는 프록시 불가 (CGLIB)
    • CGLIB 은 상속을 통해 프록시를 만들기 때문에 상속이 불가능하면 프록시를 만들지 못한다.
  3. private 메서드에는 Advice 적용 불가
    • Spring AOP 는 기본적으로 public 메서드 호출만을 대상으로 한다.
    • private, static 메서드는 AOP 대상이 안된다.
  4. 프록시 생성 시점 이후에 생성된 객체에는 적용이 불가능하다.
    • Bean 이 컨테이너에 등록될 때 프록시가 만들어지기 때문에 런타임중에 새로 생성한 객체에는 적용이 되지 않는다.

Spring Boot 에서 AOP 사용 시 주의점

위와 같은 문제점 때문에 다음 사항들을 주의해야 한다.

  1. 프록시 전략 설정
    • 기본적으로 JDK Dynamic Proxy 를 사용
    • JDK Dynamic Proxy 가 안될 경우, 즉 인터페이스가 없는 경우에는 CGLIB Proxy 로 자동 전환
    • 강제로 CGLIB 을 사용하도록 하게 하고 싶다면, 다음을 추가한다.
spring.aop.proxy-target-class: true
  1. AOP 는 빈 초기화 이후에 동작
    • Bean 초기화 시점(@PostConstructor) 에는 프록시가 완전히 적용되지 않을 수 있다.
  2. 트랜잭션, 보안, 캐시 등도 전부 AOP 기반
    • 내부 호출이나 final 문제로 인해 @Transactional, @Cacheable 등이 안 먹히는 경우가 대부분 AOP 구조 때문이다.