이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • AOP는 단순히 중복 코드를 줄이는 문법인가, 아니면 설계 관점인가?
  • Spring AOP는 왜 필드 접근이나 생성자 호출이 아니라 메서드 실행 지점을 중심으로 동작하는가?
  • 언제 AOP를 쓰고, 언제 일반 객체지향 설계나 인터셉터, 필터, 데코레이터를 선택해야 하는가?

개요

Spring 공식 문서는 AOP를 여러 클래스에 걸쳐 나타나는 관심사를 모듈화하는 방식으로 설명한다. 트랜잭션 관리는 대표적인 횡단 관심사이며, Spring AOP의 Aspect는 일반 클래스 또는 @Aspect가 붙은 클래스로 구현된다.

Spring AOP는 모든 AOP 기능을 포괄하려는 도구가 아니다. 공식 문서 기준에서 Spring AOP의 목표는 Spring IoC와 잘 통합되어 엔터프라이즈 애플리케이션에서 흔한 문제를 해결하는 것이다. 그래서 Spring AOP는 순수 Java 기반 런타임 프록시 방식이고, Spring이 관리하는 Bean의 메서드 실행 Join point에 초점을 둔다.

원리

AOP를 이해할 때 가장 먼저 버려야 할 생각은 “AOP는 공통 코드를 빼는 기술”이라는 단순화다. 공통 코드 제거는 결과에 가깝고, 본질은 관심사의 축을 다르게 잡는 것이다. 객체지향이 도메인 개념을 중심으로 모듈을 나눈다면, AOP는 여러 도메인 객체를 가로지르는 정책과 실행 규칙을 별도의 축으로 분리한다.

예를 들어 OrderService, PaymentService, MemberService는 서로 다른 비즈니스 책임을 가진다. 그러나 세 서비스가 모두 트랜잭션, 권한 검사, 감사 로그, 실행 시간 측정, 예외 변환을 필요로 할 수 있다. 이 기능들은 특정 도메인 객체 하나의 본질이라기보다 여러 객체 위에 공통으로 덧씌워지는 실행 정책이다. AOP는 이 실행 정책을 Aspect라는 단위로 모은다.

Spring AOP의 특징은 프록시 기반이라는 점이다. 원본 클래스 바이트코드를 직접 바꾸지 않고, Spring Container가 관리하는 Bean 앞에 대리 객체를 세운다. 호출자는 원본 Target을 호출한다고 생각하지만 실제로는 Proxy를 호출할 수 있다. Proxy는 Pointcut을 기준으로 필요한 Advice를 실행하고, 마지막에 Target 메서드를 호출한다.

이 방식은 Spring 애플리케이션에서 매우 자연스럽다. Spring 애플리케이션의 대부분의 협력은 Bean 주입을 통해 이루어지므로, Bean을 주입하는 지점에서 원본 대신 Proxy를 넣을 수 있기 때문이다. 반대로 Spring이 관리하지 않는 객체, 직접 new로 만든 객체, 같은 객체 내부의 this 호출은 이 프록시 흐름 밖에 있을 수 있다.

AOP가 잘 맞는 문제

  • 여러 서비스 계층 메서드에 동일한 트랜잭션 정책이나 메서드 실행 정책을 적용해야 한다.
  • 컨트롤러나 서비스의 실행 시간을 관찰하고 싶지만 본문 코드를 더럽히고 싶지 않다.
  • 특정 애너테이션이 붙은 메서드에 감사 로그를 일관되게 남기고 싶다.
  • 외부 클라이언트나 인프라 계층의 예외를 공통 애플리케이션 예외로 변환하거나 실패 알림을 보내야 한다.
  • 메서드 보안, 캐싱, 재시도, 모니터링처럼 비즈니스 로직 앞뒤에 붙는 정책을 중앙에서 관리하고 싶다.

AOP가 잘 맞지 않는 문제

  • 핵심 비즈니스 분기 자체를 Aspect로 숨기려는 경우에는 적합하지 않다. 주문 가능 여부, 결제 승인 조건, 재고 차감 규칙은 도메인 로직으로 드러나야 한다.
  • 객체 내부의 아주 세밀한 필드 접근이나 생성자 호출까지 제어하려면 Spring AOP보다 AspectJ weaving이 더 맞을 수 있다.
  • 웹 요청의 가장 앞단에서 HTTP 요청과 응답 자체를 다루려면 Servlet Filter가 더 직접적이고, Handler 정보가 필요한 MVC 흐름 처리는 Spring MVC HandlerInterceptor가 더 자연스럽다.
  • 특정 객체 하나에만 구조적으로 기능을 덧붙이고 싶다면 Decorator나 명시적 Composition이 더 읽기 쉬울 수 있다. 이 경우 기능이 코드 구조에 드러나므로 AOP보다 디버깅 비용이 낮을 수 있다.

코드로 보는 문제

@Service
public class OrderService {
 
    private final OrderRepository orderRepository;
    private final PaymentClient paymentClient;
 
    public OrderService(OrderRepository orderRepository, PaymentClient paymentClient) {
        this.orderRepository = orderRepository;
        this.paymentClient = paymentClient;
    }
 
    public OrderResult order(OrderCommand command) {
        long start = System.nanoTime();
        try {
            validate(command);
            Order order = orderRepository.save(command.toOrder());
            paymentClient.pay(order.paymentRequest());
            return OrderResult.success(order.id());
        } catch (RuntimeException ex) {
            log.warn("order failed. command={}", command, ex);
            throw ex;
        } finally {
            long elapsed = System.nanoTime() - start;
            log.info("OrderService.order elapsed={}ns", elapsed);
        }
    }
}

이 코드는 기능적으로는 동작하지만 핵심 업무와 실행 정책이 섞여 있다. validate, save, pay는 주문의 업무 흐름이다. 시간 측정, 실패 로그, 예외 처리 정책은 여러 서비스에 반복될 수 있는 횡단 관심사다.

@Aspect
@Component
public class ServiceTimingAspect {
 
    @Around("execution(* com.example..service..*(..))")
    public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.nanoTime();
        try {
            return joinPoint.proceed();
        } finally {
            long elapsed = System.nanoTime() - start;
            log.info("{} elapsed={}ns", joinPoint.getSignature().toShortString(), elapsed);
        }
    }
}

AOP로 분리하면 서비스 코드는 업무 흐름을 더 선명하게 표현하고, 실행 시간 측정 정책은 한곳에서 관리된다. 중요한 점은 AOP가 업무 로직을 “마법처럼 해결”하는 것이 아니라 호출 경계 주변의 공통 정책을 분리한다는 것이다.

설계 판단 기준

  • 여러 곳에서 같은 정책이 반복되면 AOP 후보로 본다.
  • 정책이 메서드 실행 전, 후, 정상 반환, 예외 발생, 전체 실행 흐름 중 어디에 붙는지 먼저 정한다.
  • 정책을 적용할 범위를 패키지, 타입, 메서드 이름, 애너테이션 중 무엇으로 표현할지 정한다.
  • 해당 정책을 코드에서 명시적으로 보여야 하는지, 숨겨도 되는지 판단한다.
  • 디버깅할 때 호출 흐름이 감춰져도 팀원이 쉽게 추론할 수 있는지 확인한다.

실전 팁

  • AOP는 “도메인 규칙을 숨기는 기술”이 아니라 “도메인 규칙 주변의 실행 정책을 걷어내는 기술”로 사용한다.
  • 넓은 패키지 Pointcut보다 의미 있는 애너테이션 Pointcut이 유지보수에 유리한 경우가 많다. 예를 들어 모든 서비스 메서드가 아니라 @Auditable이 붙은 메서드만 감사 로그를 남기면 의도가 코드에 남는다.
  • Aspect 이름은 기술이 아니라 정책을 드러내게 짓는다. CommonAspect보다 AuditLoggingAspect, ServiceTimingAspect, RetryAspect가 낫다.
  • 처음부터 @Around로 모든 것을 해결하지 않는다. 단순 전처리는 @Before, 성공 결과 관찰은 @AfterReturning, 예외 관찰은 @AfterThrowing이 더 안전하다.
  • AOP를 적용한 기능은 순수 단위 테스트만으로 놓치기 쉽다. 실제 Proxy가 만들어지는 Spring 테스트에서 적용 대상과 비적용 대상을 최소 한 번씩 검증한다.

위험 신호!

  • Aspect 안에서 핵심 비즈니스 결정을 많이 한다.
  • Pointcut이 너무 넓어 어떤 메서드에 적용되는지 팀원이 즉시 알기 어렵다.
  • 내부 호출 때문에 Advice가 실행되지 않는 문제를 자주 만난다.
  • Advice 순서에 의존하는 정책이 많아진다.
  • 로그, 보안, 트랜잭션 같은 정책이 Aspect와 서비스 코드 양쪽에 중복되어 있다.

확인 질문

확인 질문

  • AOP가 분리하려는 것은 무엇인가?
    • 여러 핵심 로직에 반복해서 붙는 실행 정책과 횡단 관심사다.
  • Spring AOP가 가장 자연스럽게 적용되는 대상은 무엇인가?
    • Spring Container가 관리하는 Bean의 메서드 실행이다.
  • AOP를 쓰면 안 되는 대표적인 경우는 무엇인가?
    • 핵심 비즈니스 규칙 자체를 Aspect 안에 숨기려는 경우다.
  • Spring AOP와 AspectJ의 큰 차이는 무엇인가?
    • Spring AOP는 주로 런타임 프록시 기반이고, AspectJ는 컴파일 또는 로드 타임 weaving으로 더 넓은 Join point를 다룰 수 있다.

참고 문서