로깅, 트랜잭션, 보안 체크를 모든 서비스 메서드에 넣어야 한다면, 수백 개의 메서드를 일일이 수정해야 할까요? 코드를 건드리지 않고 기능을 추가하는 방법은 없을까요?

개념 정의

AOP(Aspect-Oriented Programming) 는 애플리케이션 전반에 걸쳐 반복되는 횡단 관심사(cross-cutting concern)를 핵심 비즈니스 로직에서 분리하는 프로그래밍 패러다임입니다. 스프링 AOP는 프록시 패턴 을 기반으로 동작합니다.

왜 필요한가

트랜잭션 관리를 AOP 없이 구현하면 이렇게 됩니다.

JAVA
public class OrderService {
    public void createOrder(OrderRequest request) {
        TransactionStatus tx = txManager.getTransaction(new DefaultTransactionDefinition());
        try {
            // 핵심 로직
            Order order = new Order(request);
            orderRepository.save(order);
            paymentService.pay(order);

            txManager.commit(tx);
        } catch (Exception e) {
            txManager.rollback(tx);
            throw e;
        }
    }

    // 모든 메서드에 동일한 트랜잭션 코드 반복...
}

AOP를 사용하면 @Transactional 하나로 해결됩니다. 핵심 로직과 부가 기능이 분리되어 코드가 깔끔해집니다.

내부 동작

AOP 용어 정리

PLAINTEXT
Aspect     = 횡단 관심사를 모듈화한 것 (예: 트랜잭션 관리)
Join Point = 어드바이스가 적용될 수 있는 지점 (스프링에서는 메서드 실행)
Pointcut   = 어드바이스를 적용할 조인포인트를 선별하는 표현식
Advice     = 실제로 실행할 부가 기능 (Before, After, Around 등)
Advisor    = Pointcut + Advice (하나의 포인트컷과 하나의 어드바이스 조합)
Weaving    = 어드바이스를 핵심 로직에 적용하는 과정

프록시 기반 AOP

스프링 AOP는 런타임 프록시 를 사용합니다.

  1. 스프링은 빈을 생성할 때 AOP 대상인지 확인합니다.
  2. 대상이면 원본 객체를 프록시로 감쌉니다.
  3. 외부에서 빈을 호출하면 프록시가 먼저 어드바이스를 실행합니다.
  4. 어드바이스 실행 후 원본 메서드를 호출하고, 결과를 반환합니다.
PLAINTEXT
클라이언트 → 프록시 → [Before Advice] → 원본 메서드 → [After Advice] → 응답

JDK Dynamic Proxy vs CGLIB

JAVA
// 인터페이스가 있는 경우
public interface OrderService { void createOrder(); }

@Service
public class OrderServiceImpl implements OrderService {
    public void createOrder() { ... }
}

JDK Dynamic Proxy: OrderService 인터페이스를 구현하는 프록시 객체 생성

JAVA
// 인터페이스가 없는 경우
@Service
public class OrderService {
    public void createOrder() { ... }
}

CGLIB: OrderService를 상속하는 서브클래스 프록시 생성

구분JDK Dynamic ProxyCGLIB
기반인터페이스 구현클래스 상속
제약인터페이스 필수final 클래스/메서드 불가
성능약간 느림약간 빠름

스프링부트는 ** 기본적으로 CGLIB**을 사용합니다. spring.aop.proxy-target-class=false로 JDK 프록시로 변경할 수 있지만, 실무에서는 거의 변경하지 않습니다.

어드바이스 종류

JAVA
@Aspect
@Component
public class LoggingAspect {

    // 메서드 실행 전
    @Before("execution(* com.example.service.*.*(..))")
    public void before(JoinPoint joinPoint) {
        log.info("호출: {}", joinPoint.getSignature());
    }

이어서 나머지 구현 부분입니다.

JAVA
    // 메서드 정상 반환 후
    @AfterReturning(pointcut = "execution(* com.example.service.*.*(..))",
                    returning = "result")
    public void afterReturning(JoinPoint joinPoint, Object result) {
        log.info("반환: {}", result);
    }

    // 예외 발생 시
    @AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))",
                   throwing = "ex")
    public void afterThrowing(JoinPoint joinPoint, Exception ex) {
        log.error("예외: {}", ex.getMessage());
    }

이어서 나머지 구현 부분입니다.

JAVA
    // 항상 실행 (finally)
    @After("execution(* com.example.service.*.*(..))")
    public void after(JoinPoint joinPoint) {
        log.info("완료: {}", joinPoint.getSignature());
    }

    // 메서드 실행 전/후 모두 제어 (가장 강력)
    @Around("execution(* com.example.service.*.*(..))")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            Object result = joinPoint.proceed(); // 원본 메서드 실행
            return result;
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            log.info("{} 실행 시간: {}ms", joinPoint.getSignature(), elapsed);
        }
    }
}

포인트컷 표현식

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

// 특정 어노테이션이 붙은 메서드
@Pointcut("@annotation(com.example.annotation.LogExecutionTime)")

// 특정 어노테이션이 붙은 클래스의 모든 메서드
@Pointcut("@within(org.springframework.stereotype.Service)")

// 특정 빈의 모든 메서드
@Pointcut("bean(orderService)")

// 조합
@Pointcut("execution(* com.example.service.*.*(..)) && !execution(* com.example.service.InternalService.*(..))")

코드 예제

실행 시간 측정 AOP

JAVA
// 커스텀 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime {}

// Aspect 구현
@Aspect
@Component
public class ExecutionTimeAspect {

    @Around("@annotation(LogExecutionTime)")
    public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().toShortString();
        long start = System.nanoTime();

이어서 나머지 구현 부분입니다.

JAVA
        try {
            return joinPoint.proceed();
        } finally {
            long elapsed = (System.nanoTime() - start) / 1_000_000;
            log.info("[실행시간] {} = {}ms", methodName, elapsed);
        }
    }
}

// 사용
@Service
public class OrderService {
    @LogExecutionTime
    public Order createOrder(OrderRequest request) {
        // 비즈니스 로직만 집중
    }
}

@Order로 Aspect 실행 순서 제어

JAVA
@Aspect
@Component
@Order(1) // 먼저 실행
public class SecurityAspect {
    @Before("execution(* com.example.service.*.*(..))")
    public void checkAuth(JoinPoint joinPoint) {
        // 인증 체크
    }
}

@Aspect
@Component
@Order(2) // 나중에 실행
public class LoggingAspect {
    @Before("execution(* com.example.service.*.*(..))")
    public void log(JoinPoint joinPoint) {
        // 로깅
    }
}

@Order 값이 작을수록 먼저 실행됩니다. @Around의 경우 양파 껍질처럼 감싸는 순서가 됩니다.

PLAINTEXT
SecurityAspect.around() 시작
  LoggingAspect.around() 시작
    원본 메서드 실행
  LoggingAspect.around() 종료
SecurityAspect.around() 종료

포인트컷 재사용

JAVA
@Aspect
@Component
public class Pointcuts {

    @Pointcut("execution(* com.example.service.*.*(..))")
    public void serviceLayer() {}

    @Pointcut("execution(* com.example.repository.*.*(..))")
    public void repositoryLayer() {}

    @Pointcut("serviceLayer() || repositoryLayer()")
    public void businessLogic() {}
}

이어서 @Aspect을 적용한 나머지 구현부입니다.

JAVA
@Aspect
@Component
public class LoggingAspect {

    // 다른 Aspect에서 정의한 포인트컷 참조
    @Around("com.example.aop.Pointcuts.businessLogic()")
    public Object log(ProceedingJoinPoint joinPoint) throws Throwable {
        // ...
    }
}

프록시 체인 동작

하나의 메서드에 여러 어드바이스가 적용되면, 프록시가 이들을 체인으로 연결합니다.

PLAINTEXT
클라이언트 → 프록시
  → SecurityAdvice (Order 1)
    → LoggingAdvice (Order 2)
      → TransactionAdvice (Order 3)
        → 원본 메서드
      ← TransactionAdvice
    ← LoggingAdvice
  ← SecurityAdvice
← 프록시 → 클라이언트

주의할 점

1. @Around에서 proceed()를 빠뜨리면 원본 메서드가 실행되지 않는다

@Around 어드바이스에서 joinPoint.proceed()를 호출하지 않으면 원본 메서드가 아예 실행되지 않습니다. 조건문 분기에서 실수로 proceed()를 생략하면, 서비스 로직이 동작하지 않는데 에러도 발생하지 않아 원인을 찾기 매우 어렵습니다. 반환값이 있는 메서드라면 null이 반환되어 NullPointerException으로 이어집니다.

2. private 메서드에는 AOP가 적용되지 않는다

스프링 AOP는 프록시 기반이므로 private 메서드에는 어드바이스가 동작하지 않습니다. 포인트컷 표현식이 매칭되는 것처럼 보여도 실제로 프록시가 가로챌 수 없어 로깅이나 트랜잭션이 누락됩니다. protectedpublic으로 변경하거나, 필드 접근/생성자 레벨 위빙이 필요하면 AspectJ를 사용해야 합니다.

3. 포인트컷 표현식이 너무 광범위하면 성능이 저하된다

execution(* com.example..*.*(..)) 같은 광범위한 포인트컷은 모든 빈의 모든 메서드에 프록시 로직을 적용합니다. 어드바이스가 무겁거나 빈 수가 많으면 애플리케이션 시작 시간과 런타임 성능 모두 저하됩니다. 포인트컷은 필요한 범위로 최소화하고, 커스텀 어노테이션 기반(@annotation)으로 명시적으로 적용하는 것이 안전합니다.

정리

항목설명
동작 방식런타임 프록시가 메서드 호출을 가로채 어드바이스 실행
기본 프록시스프링부트는 CGLIB (클래스 상속 기반)
어드바이스 선택@Around가 가장 강력, 단순하면 @Before/@AfterReturning
실행 순서@Order로 Aspect 간 순서 제어 (값 작을수록 먼저)
제약메서드 실행 조인포인트만 지원. 필드/생성자는 AspectJ 필요
private 메서드AOP 적용 불가 — 프록시가 가로챌 수 없음
댓글 로딩 중...