AOP란 무엇인가

AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 핵심 비즈니스 로직횡단 관심사(Cross-Cutting Concerns) 를 분리하는 프로그래밍 패러다임입니다.

횡단 관심사란 여러 모듈에 걸쳐 반복적으로 등장하는 공통 기능을 말합니다.

  • 로깅
  • 트랜잭션 관리
  • 보안 인증/인가
  • 실행 시간 측정
  • 예외 처리

이런 기능들을 각 서비스마다 직접 작성하면 코드 중복이 발생하고 유지보수가 어려워집니다. AOP는 이 문제를 해결합니다.

핵심 용어 정리

용어 설명
Aspect 횡단 관심사를 모듈화한 클래스. Pointcut + Advice의 조합
Advice 실제로 실행될 부가 기능 코드 (언제 실행할지)
Pointcut JoinPoint에 매칭되는 술어(predicate). Advice는 Pointcut 표현식과 연결되어 매칭된 JoinPoint에서 실행됨
JoinPoint Advice가 끼어들 수 있는 실행 지점 (Spring AOP에서는 메서드 실행만 해당)
Target Object Advice가 적용되는 실제 객체
Proxy Target을 감싸는 대리 객체. AOP의 핵심 동작 주체
Weaving Aspect를 Target에 적용하는 과정

Spring AOP 동작 방식

Spring AOP는 런타임 Proxy 방식으로 동작합니다.

Client → Proxy (Advice 실행) → Target Object (핵심 로직)

Proxy 구현체는 두 가지입니다.

  • JDK Dynamic Proxy : Target이 하나 이상의 인터페이스를 구현한 경우
  • CGLIB Proxy : Target이 인터페이스를 구현하지 않은 경우 (서브클래스 방식)

Spring Framework 자체는 인터페이스 유무에 따라 위 두 가지를 자동으로 선택합니다. Spring Bootspring.aop.proxy-target-class의 기본값이 true이므로 CGLIB를 기본으로 사용하며, JDK 프록시를 사용하려면 이 값을 false로 설정해야 합니다.

의존성 추가

spring-boot-starter-aop 를 추가하면 됩니다.

<!-- Maven -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
// Gradle
implementation 'org.springframework.boot:spring-boot-starter-aop'

Aspect 작성 기본 구조

import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect        // 이 클래스가 Aspect임을 선언
@Component     // Spring Bean으로 등록
public class LoggingAspect {
    // Advice 메서드들이 여기에 위치합니다
}

Advice 종류별 예제

Before

메서드 실행 에 동작합니다. 실행 흐름을 막을 수는 없지만, 예외를 던지면 JoinPoint로의 진행을 막을 수 있습니다.

@Before("execution(* com.example.service.*.*(..))")
public void beforeAdvice(JoinPoint joinPoint) {
    System.out.println("[Before] 메서드 실행 전: " + joinPoint.getSignature().getName());
}

AfterReturning

메서드가 정상적으로 반환된 후 동작합니다. 반환값을 읽을 수 있지만, 완전히 다른 참조로 교체하는 것은 불가능합니다.

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

AfterThrowing

메서드에서 예외가 발생한 후 동작합니다. 예외를 잡아서 처리하거나 로깅할 수 있습니다.

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

After

메서드 실행 후 정상/예외 여부 관계없이 항상 동작합니다. 공식 문서에서는 "after finally advice"로 표현하며, Java의 finally 블록과 동일한 의미입니다. 주로 리소스 해제 등에 사용됩니다.

@After("execution(* com.example.service.*.*(..))")
public void afterAdvice(JoinPoint joinPoint) {
    System.out.println("[After] 항상 실행: " + joinPoint.getSignature().getName());
}

Around

메서드 실행 전·후를 모두 제어할 수 있는 가장 강력한 Advice입니다. ProceedingJoinPoint를 통해 실제 메서드 실행 여부를 직접 결정합니다. 공식 문서는 요구사항을 충족하는 가장 약한 형태의 Advice를 사용하도록 권장합니다. @Before로 충분하다면 @Around를 사용하지 않는 것이 좋습니다.

@Around("execution(* com.example.service.*.*(..))")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
    System.out.println("[Around] 실행 전");

    Object result = joinPoint.proceed(); // 실제 메서드 실행

    System.out.println("[Around] 실행 후, 반환값: " + result);
    return result;
}

joinPoint.proceed() 를 호출하지 않으면 실제 메서드가 실행되지 않습니다. 이를 이용해 캐싱, 권한 검사 등을 구현할 수 있습니다.

Pointcut 표현식

execution 표현식이 가장 많이 사용됩니다.

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

주요 예시

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

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

// 특정 메서드명
execution(* com.example.service.UserService.findById(..))

// 반환타입 지정
execution(String com.example.service.*.*(..))

// 파라미터 없는 메서드
execution(* com.example.service.*.*())

// 첫 번째 파라미터가 Long인 메서드
execution(* com.example.service.*.*(Long, ..))

와일드카드 의미

기호 의미
* 하나의 아무 값 (패키지 한 단계, 메서드명, 반환타입 등)
.. 0개 이상 (파라미터 여러 개 또는 패키지 여러 단계)

Pointcut 재사용

@Aspect
@Component
public class LoggingAspect {

    // Pointcut 별도 정의
    @Pointcut("execution(* com.example.service.*.*(..))")
    public void serviceLayer() {}

    // 재사용
    @Before("serviceLayer()")
    public void beforeAdvice(JoinPoint joinPoint) {
        // ...
    }

    @After("serviceLayer()")
    public void afterAdvice(JoinPoint joinPoint) {
        // ...
    }
}

JoinPoint 활용

JoinPoint 객체를 통해 현재 실행 중인 메서드의 정보를 얻을 수 있습니다.

@Before("execution(* com.example.service.*.*(..))")
public void beforeAdvice(JoinPoint joinPoint) {

    // 메서드 시그니처
    String methodName = joinPoint.getSignature().getName();

    // 클래스명
    String className = joinPoint.getTarget().getClass().getSimpleName();

    // 전달된 인자값
    Object[] args = joinPoint.getArgs();

    System.out.println("클래스: " + className + ", 메서드: " + methodName);
    System.out.println("인자: " + Arrays.toString(args));
}

주의사항

Self-invocation 문제

같은 클래스 내부에서 메서드를 호출하면 Proxy를 거치지 않아 AOP가 동작하지 않습니다.

@Service
public class OrderService {

    public void placeOrder() {
        // 내부 호출 → Proxy 미경유 → AOP 미적용
        this.validateOrder();
    }

    @LogExecutionTime  // 적용 안 됨
    public void validateOrder() {
        // ...
    }
}

해결 방법으로는 별도 클래스로 분리하거나, ApplicationContext에서 자기 자신 Bean을 주입받아 호출하는 방식을 사용합니다.

final 클래스/메서드

CGLIB는 상속을 통해 Proxy를 생성하므로 final 클래스나 final 메서드에는 AOP를 적용할 수 없습니다.

Spring Bean이 아닌 객체

new 키워드로 직접 생성한 객체는 Spring이 관리하지 않으므로 AOP가 적용되지 않습니다. 반드시 Spring Bean으로 등록된 객체에만 적용됩니다.