Theme:

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

개념 정의

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

스프링이 프록시를 만드는 방식은 두 가지입니다. 식당에 비유하면 이렇습니다 — 손님(Controller)이 요리사(Service)에게 직접 주문하는 게 아니라, 웨이터(Proxy) 가 중간에서 주문을 받아 테이블 세팅(트랜잭션 시작)을 하고, 요리가 끝나면 서빙(커밋)합니다. 이 "웨이터"를 만드는 방법이 두 가지인 거예요.

JDK Dynamic Proxy — 인터페이스 기반

Java 표준 라이브러리(java.lang.reflect.Proxy)에 내장된 방식입니다. 대상 클래스가 구현한 ** 인터페이스 **를 기반으로 프록시를 런타임에 생성합니다.

JAVA
// 인터페이스가 있어야 함
public interface OrderService { void createOrder(); }

@Service
public class OrderServiceImpl implements OrderService {
    public void createOrder() { ... }
}
// → 프록시는 OrderService 인터페이스를 구현한 별도 객체
// → 메서드 호출 시 InvocationHandler를 통해 리플렉션으로 원본 호출

CGLIB — 클래스 상속 기반

바이트코드를 조작해서 대상 클래스의 ** 서브클래스(자식 클래스)**를 런타임에 생성합니다. 인터페이스가 없어도 동작합니다.

JAVA
// 인터페이스 없어도 OK
@Service
public class OrderService {
    public void createOrder() { ... }
}
// → CGLIB이 OrderService를 상속한 OrderService$$SpringCGLIB$$0을 만듬
// → 오버라이드된 메서드에서 부가 기능 실행 후 super.createOrder() 호출
구분JDK Dynamic ProxyCGLIB
프록시 생성 방식인터페이스 구현 (java.lang.reflect.Proxy)클래스 상속 (바이트코드 생성)
조건대상이 인터페이스를 구현해야 함인터페이스 없어도 됨
제약인터페이스에 정의된 메서드만 프록시 가능final 클래스/메서드는 상속 불가 → 프록시 불가
성능메서드 호출 시 리플렉션 사용 → 약간 느림생성된 바이트코드 직접 호출 → 약간 빠름

Spring Boot 2.0부터 기본값이 CGLIB 으로 바뀌었습니다(spring.aop.proxy-target-class=true). 인터페이스가 있어도 CGLIB을 씁니다. 왜 바꿨냐면, JDK 프록시는 인터페이스 타입으로만 주입받을 수 있어서 구체 클래스 타입으로 주입하면 BeanNotOfRequiredTypeException이 발생했기 때문입니다.

JAVA
// JDK 동적 프록시에서의 문제
@Autowired
private UserServiceImpl userService; // ← 에러! 프록시는 인터페이스 구현체라 타입이 안 맞음

// CGLIB에서는 OK
@Autowired
private UserServiceImpl userService; // ← OK! 프록시가 서브클래스라 타입이 맞음

프록시가 조용히 무시되는 경우

프록시 기반이라서 생기는 함정이 몇 가지 있습니다. 에러 없이 조용히 무시 되기 때문에 발견하기 어렵습니다.

상황원인결과
final 클래스에 @TransactionalCGLIB이 상속할 수 없음트랜잭션 미적용
final 메서드에 @Cacheable오버라이드 불가캐시 미적용
private 메서드에 AOP 어노테이션프록시가 가로챌 수 없음AOP 미적용
같은 클래스 내부에서 호출 (this.method())프록시를 거치지 않음AOP 미적용

특히 Kotlin은 클래스가 기본이 final 이라 open 키워드를 붙이거나 kotlin-spring 플러그인이 필요합니다.

어드바이스 종류

JAVA
@Aspect
@Component
public class LoggingAspect {

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

    // 메서드 정상 반환 후
    @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());
    }

    // 항상 실행 (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();

        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가 적용될 때 @Order 어노테이션으로 실행 순서를 제어합니다. 값이 작을수록 먼저 실행되며, @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에서 참조
@Aspect
@Component
@Order(1) // SecurityAspect가 먼저, LoggingAspect(@Order(2))보다 우선
public class SecurityAspect {
    @Before("com.example.aop.Pointcuts.serviceLayer()")
    public void checkAuth(JoinPoint joinPoint) {
        // 인증 체크
    }
}

주의할 점

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 적용 불가 — 프록시가 가로챌 수 없음
댓글 로딩 중...