Spring AOP – 관점 지향 프로그래밍 개념부터 실전 활용까지


Spring AOP, 개념은 들어봤는데 막상 “AOP가 무엇인가요?”라는 면접 질문 앞에서 말문이 막히는 분들이 많습니다. 트랜잭션 처리에 쓰이는 @Transactional이 사실 AOP의 대표 활용 사례라는 걸 알고 나면 “아, 이미 쓰고 있었구나” 하고 놀라는 분들도 많습니다. AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 로깅, 트랜잭션, 보안, 성능 측정처럼 여러 계층에 반복해서 등장하는 공통 처리 코드를 비즈니스 로직에서 완전히 분리하는 강력한 설계 기법입니다. 이 글에서는 AOP가 왜 필요한지부터 Aspect, Pointcut, Advice, JoinPoint 핵심 개념, 프록시 동작 원리, 실전 코드까지 순서대로 완벽하게 정리합니다.


목차

  1. Spring AOP란 무엇인가 – 기초 개념과 탄생 배경
  2. Spring AOP 핵심 용어와 동작 원리 완전 분석
  3. Spring AOP가 가져다주는 장점과 실무 활용 사례
  4. Spring AOP의 한계와 주의해야 할 함정
  5. 실전 단계별 활용법 – @Aspect로 AOP 직접 구현하기
  6. 전문가 관점 – 프록시 내부 원리와 AspectJ 비교

1. Spring AOP란 무엇인가 – 기초 개념과 탄생 배경

횡단 관심사 – AOP가 탄생한 이유

좋은 코드는 단일 책임 원칙(SRP)에 따라 각 클래스가 하나의 역할만 맡아야 합니다. 그런데 실제 프로젝트를 들여다보면 로깅, 트랜잭션 관리, 실행 시간 측정, 권한 확인 같은 코드가 서비스·컨트롤러·리포지토리 할 것 없이 모든 곳에 반복해서 등장합니다. 이처럼 여러 모듈에 걸쳐 공통으로 나타나는 부가 기능을 횡단 관심사(Cross-cutting Concerns) 라고 합니다.

java

// AOP 없이 모든 서비스에 반복되는 공통 코드 (나쁜 예)
@Service
public class OrderService {

    public Order createOrder(OrderDto dto) {
        // ① 로깅 – 모든 서비스에 반복
        log.info("[OrderService] createOrder 시작: {}", dto);
        long startTime = System.currentTimeMillis();

        // ② 권한 확인 – 모든 서비스에 반복
        if (!securityContext.hasRole("ORDER_WRITE")) {
            throw new AccessDeniedException("주문 생성 권한이 없습니다.");
        }

        // ③ 트랜잭션 시작 – 모든 서비스에 반복
        TransactionStatus tx = transactionManager.getTransaction(...);
        try {
            // ★ 실제 비즈니스 로직 (단 3줄)
            Order order = Order.of(dto);
            orderRepository.save(order);
            return order;

        } catch (Exception e) {
            transactionManager.rollback(tx);
            throw e;
        } finally {
            transactionManager.commit(tx);
            // ④ 실행 시간 측정 – 모든 서비스에 반복
            log.info("[OrderService] createOrder 완료: {}ms",
                System.currentTimeMillis() - startTime);
        }
    }
}

이 코드에서 실제 비즈니스 로직은 단 세 줄뿐입니다. 나머지는 모두 부가 기능입니다. 그리고 이 부가 기능 코드들은 UserServiceProductServicePaymentService에도 똑같이 반복됩니다. 클래스가 100개라면 동일한 코드가 100곳에 흩어집니다.

AOP는 이 횡단 관심사를 핵심 비즈니스 로직에서 완전히 분리하여 한 곳에 모으는 기법입니다.

[횡단 관심사 시각화]

                  로깅    트랜잭션   보안    성능측정
                   ↓        ↓        ↓       ↓
OrderService ─────┼────────┼────────┼───────┼──────
UserService  ─────┼────────┼────────┼───────┼──────
ProductService ───┼────────┼────────┼───────┼──────
PaymentService ───┼────────┼────────┼───────┼──────
                  │        │        │       │
              ←─────────────────────────────────→
                       횡단 관심사 (Cross-cutting)

AOP의 정의

AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍) 는 핵심 비즈니스 로직(Core Concerns)과 횡단 관심사(Cross-cutting Concerns)를 분리하여 모듈화하는 프로그래밍 패러다임입니다. OOP(객체지향 프로그래밍)가 “무엇을 할 것인가” 를 클래스 단위로 모듈화한다면, AOP는 “어디에나 반복되는 공통 처리를 어떻게 분리할 것인가” 를 Aspect 단위로 모듈화합니다.

Spring AOP는 Spring Framework에 내장된 AOP 구현으로, 프록시(Proxy) 패턴을 기반으로 동작합니다. AspectJ처럼 바이트코드를 직접 조작하지 않고, 런타임에 프록시 객체를 생성하여 부가 기능을 끼워 넣는 방식입니다.


2. Spring AOP 핵심 용어와 동작 원리 완전 분석

Spring AOP를 이해하는 데 반드시 알아야 할 다섯 가지 핵심 용어를 실제 코드와 함께 설명합니다.

① Aspect – 횡단 관심사를 모듈화한 단위

Aspect는 횡단 관심사를 하나의 클래스로 모듈화한 것입니다. 로깅 Aspect, 트랜잭션 Aspect, 보안 Aspect처럼 각 공통 기능이 하나의 Aspect로 표현됩니다. Java에서는 @Aspect 어노테이션으로 선언합니다.

java

@Aspect        // 이 클래스가 Aspect임을 선언
@Component     // Spring Bean으로 등록
public class LoggingAspect {
    // Pointcut + Advice = Aspect의 구성 요소
}

② JoinPoint – Advice를 끼워 넣을 수 있는 지점

JoinPoint는 Aspect가 적용될 수 있는 프로그램의 특정 시점입니다. 메서드 호출, 메서드 실행 완료, 예외 발생 등이 JoinPoint가 될 수 있습니다. Spring AOP에서는 메서드 실행 시점만 JoinPoint로 지원합니다(AspectJ는 필드 접근, 생성자 호출 등 더 다양한 JoinPoint를 지원).

java

@Around("execution(* com.example.service.*.*(..))")
public Object log(ProceedingJoinPoint joinPoint) throws Throwable {
    // joinPoint: 현재 실행 중인 메서드에 대한 정보를 담은 객체
    String className  = joinPoint.getTarget().getClass().getSimpleName();
    String methodName = joinPoint.getSignature().getName();
    Object[] args     = joinPoint.getArgs(); // 메서드 파라미터

    log.info("[{}] {}{} 호출", className, methodName, Arrays.toString(args));
    Object result = joinPoint.proceed(); // 실제 메서드 실행
    log.info("[{}] {} 반환: {}", className, methodName, result);
    return result;
}

③ Pointcut – JoinPoint를 선택하는 표현식

Pointcut은 수많은 JoinPoint 중에서 Advice를 실제로 적용할 JoinPoint를 선별하는 필터 표현식입니다. “어떤 클래스의 어떤 메서드에 Advice를 적용할 것인가”를 정의합니다.

java

@Aspect
@Component
public class PointcutExamples {

    // ① execution: 가장 많이 사용하는 표현식
    // 반환타입 패키지.클래스.메서드(파라미터)
    @Pointcut("execution(* com.example.service.*.*(..))")
    public void allServiceMethods() {}
    // com.example.service 패키지의 모든 클래스의 모든 메서드

    @Pointcut("execution(public String com.example.service.OrderService.create*(..))")
    public void orderCreateMethods() {}
    // OrderService의 create로 시작하는 public String 반환 메서드

    // ② within: 특정 타입 내의 모든 JoinPoint
    @Pointcut("within(com.example.service..*)")
    public void withinServicePackage() {}
    // service 패키지 및 하위 패키지의 모든 메서드

    // ③ @annotation: 특정 어노테이션이 붙은 메서드
    @Pointcut("@annotation(com.example.annotation.Loggable)")
    public void loggableAnnotated() {}
    // @Loggable 어노테이션이 붙은 메서드

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

    // ⑤ args: 특정 타입의 파라미터를 받는 메서드
    @Pointcut("args(java.lang.String, ..)")
    public void methodsWithStringFirstArg() {}
    // 첫 번째 파라미터가 String인 메서드

    // ⑥ Pointcut 조합 (&&, ||, !)
    @Pointcut("allServiceMethods() && !loggableAnnotated()")
    public void serviceMethodsWithoutLoggable() {}
    // 서비스 메서드 중 @Loggable이 없는 메서드
}

Pointcut 표현식 execution 문법 상세 분석

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

* com.example.service.OrderService.createOrder(OrderDto)
│ └────────────────────────────┘ └──────────┘ └───────┘
│          패키지.클래스               메서드     파라미터
│
└── * : 모든 반환타입

와일드카드:
  * : 임의의 단일 문자열 (com.example.* = example 패키지 바로 아래)
  ..  : 임의의 파라미터 또는 패키지 (com.example..* = 하위 패키지 포함)
  +   : 해당 타입과 그 하위 타입

예시:
  execution(* *(..))                      ← 모든 메서드
  execution(* com.example..*.*(..))       ← example 하위 전체
  execution(* com.example.service.*.*(String, ..)) ← 첫 파라미터 String
  execution(* *..OrderService+.*(..))     ← OrderService와 그 하위 타입

④ Advice – 실제로 실행되는 부가 기능 코드

Advice는 Pointcut으로 선택된 JoinPoint에서 실행되는 실제 부가 기능 코드입니다. 실행 시점에 따라 다섯 가지로 구분됩니다.

java

@Aspect
@Component
@Slf4j
public class AdviceTypesExample {

    // ① @Before: 메서드 실행 전에 실행 (메서드 실행을 막을 수 없음)
    @Before("execution(* com.example.service.*.*(..))")
    public void beforeAdvice(JoinPoint joinPoint) {
        log.info("[Before] {} 실행 시작", joinPoint.getSignature().getName());
        // 여기서 예외를 던지면 대상 메서드가 실행되지 않음
    }

    // ② @AfterReturning: 메서드가 정상 반환된 후 실행
    @AfterReturning(
        pointcut = "execution(* com.example.service.*.*(..))",
        returning = "result"   // 반환값을 result 파라미터로 받음
    )
    public void afterReturningAdvice(JoinPoint joinPoint, Object result) {
        log.info("[AfterReturning] {} 반환값: {}",
            joinPoint.getSignature().getName(), result);
        // 반환값을 여기서 확인할 수 있지만 수정은 불가
    }

    // ③ @AfterThrowing: 메서드에서 예외가 발생했을 때 실행
    @AfterThrowing(
        pointcut = "execution(* com.example.service.*.*(..))",
        throwing  = "ex"   // 발생한 예외를 ex 파라미터로 받음
    )
    public void afterThrowingAdvice(JoinPoint joinPoint, Exception ex) {
        log.error("[AfterThrowing] {} 예외 발생: {}",
            joinPoint.getSignature().getName(), ex.getMessage());
        // 예외를 잡아서 처리하거나, 다른 예외로 변환 가능
        // 단, 예외를 삼키려면 @Around를 사용해야 함
    }

    // ④ @After: 메서드 실행 후 항상 실행 (정상·예외 무관, Java의 finally와 유사)
    @After("execution(* com.example.service.*.*(..))")
    public void afterAdvice(JoinPoint joinPoint) {
        log.info("[After] {} 실행 완료 (정상/예외 무관)",
            joinPoint.getSignature().getName());
        // 리소스 정리 등에 활용
    }

    // ⑤ @Around: 가장 강력, 메서드 실행 전후 모두 제어 가능
    @Around("execution(* com.example.service.*.*(..))")
    public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[Around] {} 실행 전", joinPoint.getSignature().getName());

        long start = System.currentTimeMillis();
        try {
            Object result = joinPoint.proceed(); // 실제 메서드 실행
            // joinPoint.proceed(newArgs)로 파라미터 변경도 가능
            log.info("[Around] {} 실행 후, 반환값: {}", 
                joinPoint.getSignature().getName(), result);
            return result; // 반환값 수정도 가능
        } catch (Throwable ex) {
            log.error("[Around] {} 예외 발생: {}", 
                joinPoint.getSignature().getName(), ex.getMessage());
            throw ex; // 예외를 다시 던지거나 변환 가능
        } finally {
            log.info("[Around] {} 실행 시간: {}ms",
                joinPoint.getSignature().getName(),
                System.currentTimeMillis() - start);
        }
    }
}

Advice 실행 순서 정리

메서드 호출
    │
    ├── @Before 실행
    │
    ├── @Around (joinPoint.proceed() 이전 코드)
    │        │
    │        ▼
    │   실제 메서드 실행
    │        │
    │   ┌────┴───────────┐
    │   │ 정상 반환       │ 예외 발생
    │   ▼                ▼
    ├── @AfterReturning  @AfterThrowing
    │
    ├── @After (항상 실행)
    │
    └── @Around (joinPoint.proceed() 이후 코드)

⑤ Weaving – Aspect를 실제 코드에 적용하는 과정

Weaving은 Aspect의 코드를 핵심 비즈니스 로직에 실제로 적용(결합)하는 과정입니다. Spring AOP는 런타임 위빙(Runtime Weaving) 방식을 사용합니다. 애플리케이션 실행 중에 프록시 객체를 생성하여 부가 기능을 끼워 넣습니다.

[Spring AOP Weaving 시점]

컴파일 타임 위빙  : 소스코드 컴파일 시 Aspect 적용 (AspectJ, ajc 컴파일러 필요)
클래스 로딩 위빙  : 클래스 로더가 .class 파일 로드 시 적용 (AspectJ LTW)
런타임 위빙      : 실행 중 프록시 생성으로 적용 (Spring AOP 방식) ← 여기

3. Spring AOP가 가져다주는 장점과 실무 활용 사례

핵심 장점: 비즈니스 로직의 순수성 유지

AOP를 적용하면 서비스 클래스는 오직 비즈니스 로직에만 집중할 수 있습니다. 앞서 살펴본 OrderService가 AOP 적용 후 어떻게 달라지는지 확인해 보겠습니다.

java

// AOP 적용 후 – 비즈니스 로직만 남은 깨끗한 서비스
@Service
@Transactional          // AOP가 트랜잭션 처리를 알아서 해줌
public class OrderService {

    public Order createOrder(OrderDto dto) {
        // 비즈니스 로직 100%
        Order order = Order.of(dto);
        orderRepository.save(order);
        return order;
        // 로깅, 트랜잭션, 권한 확인, 성능 측정 – 모두 AOP가 처리
    }
}

로깅, 트랜잭션, 권한 확인, 실행 시간 측정이 모두 사라졌습니다. 이 기능들은 Aspect로 분리되어 OrderService뿐 아니라 모든 서비스에 일관되게 적용됩니다.

실무에서 AOP가 활용되는 대표적인 사례

사례 1 – @Transactional (Spring의 대표 AOP 활용)

java

// @Transactional은 TransactionInterceptor라는 Advice가 적용된 AOP
// 개발자는 어노테이션 하나만 붙이면 트랜잭션 시작/커밋/롤백이 자동화됨
@Service
public class TransferService {

    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to   = accountRepository.findById(toId).orElseThrow();
        from.debit(amount);
        to.credit(amount);
        // AOP가 자동으로:
        // - 트랜잭션 시작
        // - 정상 완료 시 커밋
        // - RuntimeException 발생 시 롤백
    }
}

사례 2 – 실행 시간 측정 및 슬로우 쿼리 감지

java

@Aspect
@Component
@Slf4j
public class PerformanceAspect {

    private static final long SLOW_THRESHOLD_MS = 500L;

    @Around("@annotation(com.example.annotation.MeasureTime)" +
            "|| execution(* com.example.repository.*.*(..))")
    public Object measureExecutionTime(ProceedingJoinPoint joinPoint)
            throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            String method = joinPoint.getSignature().toShortString();

            if (elapsed >= SLOW_THRESHOLD_MS) {
                log.warn("[SLOW] {} → {}ms (임계값: {}ms)",
                    method, elapsed, SLOW_THRESHOLD_MS);
                // 슬랙 알림, 메트릭 수집 등 추가 가능
            } else {
                log.debug("[PERF] {} → {}ms", method, elapsed);
            }
        }
    }
}

사례 3 – 감사 로그(Audit Log) 자동 기록

java

@Aspect
@Component
@Slf4j
public class AuditAspect {

    private final AuditLogRepository auditLogRepository;
    private final SecurityContextHolder securityContextHolder;

    // @Audit 어노테이션이 붙은 메서드에만 적용
    @Around("@annotation(audit)")
    public Object recordAuditLog(ProceedingJoinPoint joinPoint,
                                  Audit audit) throws Throwable {
        String currentUser = getCurrentUsername();
        String methodName  = joinPoint.getSignature().getName();
        String action      = audit.action(); // 어노테이션 속성값 활용
        Object[] args      = joinPoint.getArgs();

        try {
            Object result = joinPoint.proceed();

            // 성공 감사 로그 기록
            auditLogRepository.save(AuditLog.success(
                currentUser, action, methodName,
                Arrays.toString(args), LocalDateTime.now()
            ));
            return result;

        } catch (Exception ex) {
            // 실패 감사 로그 기록
            auditLogRepository.save(AuditLog.failure(
                currentUser, action, methodName,
                ex.getMessage(), LocalDateTime.now()
            ));
            throw ex;
        }
    }

    private String getCurrentUsername() {
        return Optional.ofNullable(
            SecurityContextHolder.getContext().getAuthentication()
        ).map(Authentication::getName).orElse("anonymous");
    }
}

// 감사 로그가 필요한 메서드에 어노테이션만 붙이면 끝
@Service
public class AdminService {

    @Audit(action = "USER_DELETE")
    public void deleteUser(Long userId) {
        userRepository.deleteById(userId);
        // 누가 언제 어떤 파라미터로 이 메서드를 호출했는지 자동 기록
    }

    @Audit(action = "ROLE_CHANGE")
    public void changeUserRole(Long userId, String newRole) {
        userRepository.updateRole(userId, newRole);
    }
}

4. Spring AOP의 한계와 주의해야 할 함정

함정 1 – 자기 호출(Self-Invocation) 문제

Spring AOP는 프록시 기반으로 동작합니다. 같은 클래스 내부에서 메서드를 호출하면 프록시를 거치지 않고 직접 호출되기 때문에 AOP가 적용되지 않습니다. 이것이 Spring AOP에서 가장 많이 발생하는 함정입니다.

java

@Service
public class OrderService {

    // ① 외부에서 호출: 프록시 → AOP 적용 → createOrder() 실행 (정상)
    @Transactional
    public Order createOrder(OrderDto dto) {
        Order order = Order.of(dto);
        orderRepository.save(order);
        sendConfirmationEmail(order); // ② 내부 호출
        return order;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendConfirmationEmail(Order order) {
        // 이 메서드의 @Transactional이 적용되지 않음!
        // createOrder()가 직접 this.sendConfirmationEmail()을 호출하므로
        // 프록시를 거치지 않아 새 트랜잭션이 시작되지 않음
        emailService.send(order.getCustomerEmail(), "주문 확인");
    }
}
[자기 호출 문제 시각화]

외부 호출자
    │
    ▼
[OrderService Proxy]         ← AOP 프록시 (트랜잭션 시작)
    │
    ▼
[OrderService 실제 객체]
    ├── createOrder() 실행
    │       │
    │       └── this.sendConfirmationEmail() 직접 호출
    │                   ↑
    │           프록시를 거치지 않음! AOP 미적용
    │
    └── (프록시를 거치지 않으므로 REQUIRES_NEW 동작 안 함)

해결책: 구조 분리 또는 빈 자기 주입

java

// 해결책 1 – 권장: 별도 클래스로 분리 (가장 깔끔)
@Service
public class EmailNotificationService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendConfirmationEmail(Order order) {
        emailService.send(order.getCustomerEmail(), "주문 확인");
        // 이제 외부 빈 호출이므로 프록시를 거쳐 AOP 정상 적용
    }
}

@Service
public class OrderService {

    private final EmailNotificationService emailNotificationService;

    @Transactional
    public Order createOrder(OrderDto dto) {
        Order order = Order.of(dto);
        orderRepository.save(order);
        emailNotificationService.sendConfirmationEmail(order); // 외부 빈 호출
        return order;
    }
}

// 해결책 2 – 자기 자신을 빈으로 주입 (제한적으로 사용)
@Service
public class OrderService {

    @Autowired
    @Lazy
    private OrderService self; // 자기 자신의 프록시를 주입

    @Transactional
    public Order createOrder(OrderDto dto) {
        Order order = Order.of(dto);
        orderRepository.save(order);
        self.sendConfirmationEmail(order); // 프록시를 통한 호출
        return order;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendConfirmationEmail(Order order) {
        emailService.send(order.getCustomerEmail(), "주문 확인");
    }
}

함정 2 – private 메서드에 AOP가 적용되지 않는다

Spring AOP는 프록시 기반이므로 private 메서드에는 적용할 수 없습니다. 프록시는 상속이나 인터페이스 구현을 통해 동작하는데, private 메서드는 오버라이드가 불가능하기 때문입니다.

java

@Service
public class UserService {

    public void registerUser(UserDto dto) {
        validateUser(dto); // private 메서드 호출
        userRepository.save(User.of(dto));
    }

    @Transactional // 아무 효과 없음! private에는 AOP 미적용
    private void validateUser(UserDto dto) {
        if (dto.getEmail() == null) throw new IllegalArgumentException("이메일 필수");
        // @Transactional이 있어도 트랜잭션이 시작되지 않음
    }
}

함정 3 – final 클래스와 final 메서드에 CGLIB 프록시 적용 불가

java

// CGLIB 프록시는 상속을 통해 프록시를 생성함
// final 클래스나 final 메서드는 상속·오버라이드 불가 → 프록시 생성 불가

@Service
public final class PaymentService { // final 클래스 → CGLIB 프록시 생성 불가!

    @Transactional
    public void processPayment(PaymentDto dto) { ... }
}

// 해결책: final 제거 또는 인터페이스 기반 설계
public interface PaymentService {
    void processPayment(PaymentDto dto);
}

@Service
public class PaymentServiceImpl implements PaymentService {
    @Transactional
    @Override
    public void processPayment(PaymentDto dto) { ... }
    // 인터페이스가 있으면 JDK Dynamic Proxy 사용 → final 문제 없음
}

함정 4 – AOP 적용 범위를 너무 넓게 잡는 경우

java

// 위험한 Pointcut – 너무 넓은 범위
@Around("execution(* *.*(..))")         // 모든 클래스의 모든 메서드
@Around("execution(* com.example..*(..))") // example 하위 모든 메서드

// 이 경우 Spring 내부 메서드, 도메인 객체 메서드까지 AOP가 걸려
// 성능 저하와 예상치 못한 사이드 이펙트 발생

// 권장: 정확한 대상 지정
@Around("execution(* com.example.service..*Service.*(..))")
// service 패키지 하위 *Service로 끝나는 클래스의 모든 메서드

5. 실전 단계별 활용법 – @Aspect로 AOP 직접 구현하기

Step 1 – 의존성 추가

xml

<!-- pom.xml: Spring Boot Starter AOP -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- spring-boot-starter-aop가 spring-aop + AspectJ Weaver를 포함 -->

yaml

# application.yml: Spring AOP 프록시 설정
spring:
  aop:
    proxy-target-class: true  # true: CGLIB 프록시 (기본값, Spring Boot 2.x+)
                               # false: JDK Dynamic Proxy (인터페이스 필요)
    auto: true                # AOP 자동 설정 활성화 (기본값)

Step 2 – 커스텀 어노테이션 정의

java

// AOP 적용 대상을 어노테이션으로 명시적으로 표시
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Loggable {
    String value() default ""; // 로그에 표시할 추가 설명
    boolean includeArgs default true;   // 파라미터 포함 여부
    boolean includeResult default false; // 반환값 포함 여부
}

// 감사 로그용 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Audit {
    String action();            // 감사 행동 코드 (USER_CREATE, ORDER_CANCEL 등)
    String description() default ""; // 추가 설명
}

Step 3 – 종합 로깅 Aspect 구현

java

@Aspect
@Component
@Slf4j
public class ComprehensiveLoggingAspect {

    // Pointcut 재사용을 위한 선언
    @Pointcut("@annotation(com.example.annotation.Loggable)")
    private void loggableMethod() {}

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

    // @Loggable 또는 Service 레이어 메서드에 적용
    @Around("loggableMethod() || serviceLayer()")
    public Object comprehensiveLog(ProceedingJoinPoint joinPoint)
            throws Throwable {

        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Loggable loggable = method.getAnnotation(Loggable.class);

        String className  = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = method.getName();
        String description = loggable != null ? loggable.value() : "";

        // 파라미터 로깅 (민감 정보 마스킹 포함)
        String argsLog = "";
        if (loggable == null || loggable.includeArgs()) {
            argsLog = maskSensitiveArgs(joinPoint.getArgs(), method);
        }

        log.info("▶ [{}.{}] 시작 {} | 파라미터: {}",
            className, methodName, description, argsLog);

        long start = System.currentTimeMillis();
        try {
            Object result = joinPoint.proceed();
            long elapsed = System.currentTimeMillis() - start;

            // 반환값 로깅 여부 확인
            String resultLog = "";
            if (loggable != null && loggable.includeResult()) {
                resultLog = "| 반환: " + result;
            }

            log.info("◀ [{}.{}] 완료 | {}ms {}",
                className, methodName, elapsed, resultLog);
            return result;

        } catch (Throwable ex) {
            long elapsed = System.currentTimeMillis() - start;
            log.error("✕ [{}.{}] 실패 | {}ms | 예외: {} – {}",
                className, methodName, elapsed,
                ex.getClass().getSimpleName(), ex.getMessage());
            throw ex;
        }
    }

    // 비밀번호 등 민감 파라미터 마스킹
    private String maskSensitiveArgs(Object[] args, Method method) {
        if (args == null || args.length == 0) return "[]";

        Parameter[] params = method.getParameters();
        List<String> masked = new ArrayList<>();

        for (int i = 0; i < args.length; i++) {
            String paramName = params[i].getName().toLowerCase();
            if (paramName.contains("password") || paramName.contains("token")
                    || paramName.contains("secret")) {
                masked.add(paramName + "=****");
            } else {
                masked.add(paramName + "=" + args[i]);
            }
        }
        return masked.toString();
    }
}

Step 4 – 재시도(Retry) AOP 구현

java

// 커스텀 재시도 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retryable {
    int maxAttempts() default 3;           // 최대 재시도 횟수
    long delayMs() default 1000L;          // 재시도 간격(ms)
    Class<? extends Exception>[] retryOn() // 재시도할 예외 타입
        default {Exception.class};
}

@Aspect
@Component
@Slf4j
public class RetryAspect {

    @Around("@annotation(retryable)")
    public Object retry(ProceedingJoinPoint joinPoint,
                        Retryable retryable) throws Throwable {

        int maxAttempts = retryable.maxAttempts();
        long delayMs    = retryable.delayMs();
        String method   = joinPoint.getSignature().toShortString();

        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                if (attempt > 1) {
                    log.info("[Retry] {} 재시도 {}/{}회", method, attempt, maxAttempts);
                }
                return joinPoint.proceed();

            } catch (Throwable ex) {
                boolean shouldRetry = Arrays.stream(retryable.retryOn())
                    .anyMatch(type -> type.isAssignableFrom(ex.getClass()));

                if (!shouldRetry || attempt == maxAttempts) {
                    log.error("[Retry] {} 최종 실패 ({}/{}회): {}",
                        method, attempt, maxAttempts, ex.getMessage());
                    throw ex;
                }

                log.warn("[Retry] {} 실패 ({}/{}회), {}ms 후 재시도: {}",
                    method, attempt, maxAttempts, delayMs, ex.getMessage());

                try {
                    Thread.sleep(delayMs);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw ex;
                }
            }
        }
        throw new IllegalStateException("재시도 로직 오류");
    }
}

// 사용 예시
@Service
public class ExternalApiService {

    @Retryable(maxAttempts = 3, delayMs = 500,
               retryOn = {HttpServerErrorException.class})
    public ApiResponse callExternalApi(String endpoint) {
        return restTemplate.getForObject(endpoint, ApiResponse.class);
        // 서버 오류(5xx) 발생 시 최대 3회, 0.5초 간격으로 자동 재시도
    }
}

6. 전문가 관점 – 프록시 내부 원리와 AspectJ 비교

Spring AOP 프록시 두 가지 방식

Spring AOP는 두 가지 프록시 방식을 상황에 따라 선택합니다.

① JDK Dynamic Proxy

대상 클래스가 인터페이스를 구현한 경우 사용
(spring.aop.proxy-target-class=false 설정 시)

[클라이언트]
    │
    ▼
[$Proxy0 – JDK Dynamic Proxy]   ← java.lang.reflect.Proxy로 생성
    │  InvocationHandler.invoke()
    │  → Advice 실행
    │  → 실제 메서드 위임
    ▼
[OrderServiceImpl]               ← 실제 빈

특징:
  - 인터페이스 기반 (인터페이스 없으면 사용 불가)
  - 인터페이스에 정의된 메서드만 AOP 적용 가능
  - JDK 표준 API (별도 라이브러리 불필요)

② CGLIB(Code Generation Library) Proxy

대상 클래스가 인터페이스가 없거나,
proxy-target-class=true (Spring Boot 기본값) 설정 시 사용

[클라이언트]
    │
    ▼
[OrderService$$EnhancerBySpringCGLIB]  ← CGLIB이 생성한 서브클래스
    │  MethodInterceptor.intercept()
    │  → Advice 실행
    │  → super.method() 호출 (부모 메서드 위임)
    ▼
[OrderService]                          ← 실제 빈 (부모 클래스)

특징:
  - 클래스 상속 기반 (인터페이스 없어도 가능)
  - final 클래스/메서드에는 적용 불가 (상속 불가)
  - Spring Boot 2.x 이후 기본 방식
  - 바이트코드 조작으로 서브클래스 생성 (성능 향상)

java

// 프록시 타입 확인 코드
@Service
public class ProxyInspector {

    @Autowired
    private OrderService orderService; // 실제로 주입되는 것은 프록시

    public void checkProxy() {
        System.out.println(orderService.getClass().getName());
        // CGLIB 사용 시: com.example.OrderService$$EnhancerBySpringCGLIB$$abc123
        // JDK Proxy 사용 시: com.sun.proxy.$Proxy0

        System.out.println(AopUtils.isAopProxy(orderService));       // true
        System.out.println(AopUtils.isCglibProxy(orderService));     // true/false
        System.out.println(AopUtils.isJdkDynamicProxy(orderService)); // true/false
    }
}

Spring AOP vs AspectJ 비교

비교 항목Spring AOPAspectJ
위빙 방식런타임 프록시컴파일·로드타임 바이트코드 조작
JoinPoint 범위메서드 실행만필드 접근, 생성자, static 메서드 등
private 메서드적용 불가적용 가능
자기 호출 문제발생함발생하지 않음
성능프록시 오버헤드 있음컴파일 시 처리로 오버헤드 최소
설정 복잡도낮음 (Spring 내장)높음 (ajc 컴파일러 필요)
Spring 빈 필요필요불필요
추천 상황대부분의 Spring 애플리케이션성능 critical, private/static 처리 필요 시

추천 학습 도구 및 리소스

도구 / 리소스용도
Spring 공식 문서 (docs.spring.io/spring-framework)AOP 공식 레퍼런스
IntelliJ IDEA AOP 지원Pointcut 매칭 메서드 시각적 표시
Spring Boot Actuator /actuator/beansAOP 프록시 빈 등록 확인
인프런 김영한 – 스프링 핵심 원리 고급편Spring AOP 심화 한국어 강의 (가장 추천)
Baeldung (baeldung.com/spring-aop)Spring AOP 영문 심화 실습
AspectJ 공식 문서Pointcut 표현식 전체 레퍼런스

면접 대비 핵심 답변

“AOP란 무엇이고 왜 필요한가요?” 관점 지향 프로그래밍으로, 로깅·트랜잭션·보안처럼 여러 계층에 반복되는 횡단 관심사를 비즈니스 로직에서 분리하여 Aspect로 모듈화하는 기법입니다. 코드 중복을 제거하고 핵심 비즈니스 로직의 순수성을 유지하기 위해 필요합니다.

“Spring AOP의 동작 원리를 설명해보세요.” Spring AOP는 프록시 패턴 기반으로 동작합니다. @EnableAspectJAutoProxy가 활성화되면 Spring이 @Aspect 클래스를 감지하고, 해당 Pointcut에 매칭되는 빈에 대해 CGLIB 또는 JDK Dynamic Proxy를 생성합니다. 클라이언트가 빈의 메서드를 호출하면 실제 빈이 아닌 프록시가 먼저 요청을 받아 Advice를 실행한 후 실제 메서드에 위임합니다.

“Spring AOP에서 자기 호출 문제가 발생하는 이유와 해결 방법은?” 같은 클래스 내에서 메서드를 호출하면 this참조로 직접 호출되어 프록시를 거치지 않기 때문입니다. 가장 좋은 해결책은 해당 메서드를 별도의 빈(클래스)으로 분리하여 외부 빈 호출로 만드는 것입니다. 불가피한 경우 @Lazy와 함께 자기 자신을 빈으로 주입하는 방법을 사용할 수 있습니다.


결론

Spring AOP는 횡단 관심사를 Aspect로 분리하여 비즈니스 로직의 순수성을 지키는 강력한 설계 기법입니다. @Transactional이 이미 AOP의 대표 사례이며, 로깅·보안·성능 측정·재시도·감사 로그 등 실무에서 반복되는 공통 처리를 깔끔하게 분리할 수 있습니다. Spring AOP를 사용할 때는 자기 호출 문제, private 메서드 미적용, Pointcut 범위 과다 설정이라는 세 가지 함정을 반드시 기억하고, CGLIB과 JDK Dynamic Proxy의 차이를 이해하면 예상치 못한 오작동을 방지할 수 있습니다. 지금 바로 프로젝트에 @Aspect 클래스를 만들고 서비스 메서드 실행 시간을 측정하는 AOP를 직접 구현해 보세요. 코드가 얼마나 깔끔해지는지 직접 경험하면 AOP의 가치가 바로 느껴집니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다