이 글에서 얻는 것

  • Spring AOP가 “마법”이 아니라 프록시 기반이라는 점을 이해하고, 왜 특정 상황에서 동작하지 않는지 설명할 수 있습니다.
  • 포인트컷/어드바이스/조인포인트 같은 용어를 외우는 수준을 넘어, 실무에서 로그/트랜잭션/권한/메트릭에 적용할 수 있습니다.
  • self-invocation, final, 실행 순서(@Order) 같은 함정을 알고 설계/디버깅 실수를 줄일 수 있습니다.

들어가며

AOP (Aspect-Oriented Programming, 관점 지향 프로그래밍)는 Spring Framework의 핵심 기능 중 하나입니다. 횡단 관심사(Cross-Cutting Concerns)를 모듈화하여 코드 중복을 제거하고 유지보수성을 높일 수 있습니다.


1. AOP 핵심 개념

1.1 횡단 관심사 (Cross-Cutting Concerns)

문제 상황:

// 모든 서비스 메서드에 로깅 코드가 중복
@Service
public class UserService {
    public User findUser(Long id) {
        log.info("findUser called with id: {}", id);  // 중복 1
        try {
            User user = userRepository.findById(id);
            log.info("findUser returned: {}", user);  // 중복 2
            return user;
        } catch (Exception e) {
            log.error("findUser failed", e);  // 중복 3
            throw e;
        }
    }

    public void createUser(User user) {
        log.info("createUser called");  // 중복 1
        try {
            userRepository.save(user);
            log.info("createUser completed");  // 중복 2
        } catch (Exception e) {
            log.error("createUser failed", e);  // 중복 3
            throw e;
        }
    }
}

AOP 적용 후:

// 비즈니스 로직만 집중
@Service
public class UserService {
    public User findUser(Long id) {
        return userRepository.findById(id);
    }

    public void createUser(User user) {
        userRepository.save(user);
    }
}

// 로깅 관심사를 Aspect로 분리
@Aspect
@Component
public class LoggingAspect {
    @Around("execution(* com.example.service.*.*(..))")
    public Object logMethod(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("Method called: {}", joinPoint.getSignature());
        try {
            Object result = joinPoint.proceed();
            log.info("Method returned: {}", result);
            return result;
        } catch (Exception e) {
            log.error("Method failed", e);
            throw e;
        }
    }
}

1.2 AOP 용어

Target Object (대상 객체)
│
├─ Join Point (결합점)
│   └─ 메서드 실행, 필드 접근 등
│       Advice를 적용할 수 있는 모든 지점
│
├─ Pointcut (포인트컷)
│   └─ Join Point 중 실제로 Advice가 적용될 지점
│       예: "execution(* com.example.service.*.*(..))"
│
├─ Advice (어드바이스)
│   └─ 실제로 실행되는 코드
│       - Before: 메서드 실행 전
│       - After: 메서드 실행 후
│       - Around: 메서드 실행 전후
│
├─ Aspect (관점)
│   └─ Advice + Pointcut의 조합
│       @Aspect 클래스
│
└─ Weaving (위빙)
    └─ Aspect를 Target에 적용하는 과정
        - 컴파일 타임 (AspectJ)
        - 로드 타임 (AspectJ)
        - 런타임 (Spring AOP - Proxy 생성)

2. Spring AOP 동작 원리 (Proxy 패턴)

2.1 JDK Dynamic Proxy vs CGLIB

JDK Dynamic Proxy (인터페이스 기반):

// 1. 인터페이스 정의
public interface UserService {
    User findUser(Long id);
}

// 2. 실제 구현체
@Service
public class UserServiceImpl implements UserService {
    @Override
    public User findUser(Long id) {
        return userRepository.findById(id);
    }
}

// 3. Spring이 생성하는 Proxy
public class UserServiceProxy implements UserService {
    private UserServiceImpl target;

    @Override
    public User findUser(Long id) {
        // Before Advice
        log.info("Before findUser");

        // Target 메서드 호출
        User result = target.findUser(id);

        // After Advice
        log.info("After findUser");

        return result;
    }
}

// 4. Bean 주입 시 Proxy가 주입됨
@Autowired
private UserService userService;  // ← Proxy 주입 (실제 타입: UserServiceProxy)

CGLIB (클래스 기반):

// 1. 구체 클래스만 있음 (인터페이스 없음)
@Service
public class UserService {
    public User findUser(Long id) {
        return userRepository.findById(id);
    }
}

// 2. Spring이 생성하는 Proxy (CGLIB)
public class UserService$$EnhancerBySpringCGLIB extends UserService {
    private UserService target;

    @Override
    public User findUser(Long id) {
        // Before Advice
        log.info("Before findUser");

        // Target 메서드 호출
        User result = super.findUser(id);  // 부모 클래스 호출

        // After Advice
        log.info("After findUser");

        return result;
    }
}

Proxy 선택 기준:

JDK Dynamic Proxy:
- 인터페이스가 있는 경우
- 빠른 Proxy 생성
- Java 표준 기술

CGLIB:
- 인터페이스가 없는 경우
- 클래스 상속으로 Proxy 생성
- @Configuration 클래스 (Spring은 CGLIB 사용)
- Spring Boot 2.0+: 기본값

설정:
spring.aop.proxy-target-class=true  # CGLIB 강제 사용
spring.aop.proxy-target-class=false # JDK Proxy 사용 (인터페이스 있을 때)

2.2 Proxy 확인 방법

@SpringBootTest
public class ProxyTest {
    @Autowired
    private UserService userService;

    @Test
    public void checkProxy() {
        System.out.println("Proxy class: " + userService.getClass());
        // 출력: com.example.service.UserService$$EnhancerBySpringCGLIB$$12345

        // Proxy 여부 확인
        boolean isProxy = AopUtils.isAopProxy(userService);
        System.out.println("Is Proxy: " + isProxy);  // true

        // CGLIB Proxy 확인
        boolean isCglibProxy = AopUtils.isCglibProxy(userService);
        System.out.println("Is CGLIB Proxy: " + isCglibProxy);  // true
    }
}

3. Pointcut Expression (포인트컷 표현식)

3.1 execution 지시자

기본 문법:

execution(modifiers? return-type declaring-type?method-name(param-types) throws?)

modifiers: public, private 등 (생략 가능)
return-type: 반환 타입 (* = 모든 타입)
declaring-type: 패키지 및 클래스 (생략 가능)
method-name: 메서드 이름 (* = 모든 메서드)
param-types: 파라미터 타입 (.. = 모든 파라미터)
throws: 예외 타입 (생략 가능)

예시:

@Aspect
@Component
public class PointcutExamples {

    // 1. 모든 public 메서드
    @Around("execution(public * *(..))")
    public Object allPublicMethods(ProceedingJoinPoint joinPoint) { }

    // 2. com.example.service 패키지의 모든 메서드
    @Around("execution(* com.example.service.*.*(..))")
    public Object allServiceMethods(ProceedingJoinPoint joinPoint) { }

    // 3. com.example.service 패키지와 하위 패키지의 모든 메서드
    @Around("execution(* com.example.service..*.*(..))")
    public Object allServiceAndSubPackages(ProceedingJoinPoint joinPoint) { }

    // 4. UserService의 모든 메서드
    @Around("execution(* com.example.service.UserService.*(..))")
    public Object allUserServiceMethods(ProceedingJoinPoint joinPoint) { }

    // 5. 메서드 이름이 find로 시작하는 모든 메서드
    @Around("execution(* find*(..))")
    public Object allFindMethods(ProceedingJoinPoint joinPoint) { }

    // 6. 파라미터가 Long 타입 1개인 메서드
    @Around("execution(* *(Long))")
    public Object methodsWithLongParam(ProceedingJoinPoint joinPoint) { }

    // 7. 파라미터가 Long으로 시작하는 메서드
    @Around("execution(* *(Long, ..))")
    public Object methodsStartingWithLong(ProceedingJoinPoint joinPoint) { }

    // 8. User 타입을 반환하는 모든 메서드
    @Around("execution(com.example.domain.User *(..))")
    public Object methodsReturningUser(ProceedingJoinPoint joinPoint) { }
}

3.2 @annotation 지시자

Custom Annotation 정의:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime {
}

Aspect 적용:

@Aspect
@Component
public class ExecutionTimeAspect {

    @Around("@annotation(LogExecutionTime)")
    public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();

        Object result = joinPoint.proceed();

        long endTime = System.currentTimeMillis();
        long executionTime = endTime - startTime;

        log.info("{} executed in {}ms",
            joinPoint.getSignature(),
            executionTime
        );

        return result;
    }
}

사용:

@Service
public class UserService {

    @LogExecutionTime  // ✅ Aspect 적용
    public User findUser(Long id) {
        return userRepository.findById(id);
    }

    public void createUser(User user) {
        userRepository.save(user);  // ❌ Aspect 미적용
    }
}

3.3 Pointcut 조합

@Aspect
@Component
public class CombinedPointcuts {

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

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

    @Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
    public void transactionalMethod() {}

    // 조합 1: AND (&&)
    @Around("serviceLayer() && transactionalMethod()")
    public Object serviceAndTransactional(ProceedingJoinPoint joinPoint) {
        // Service 계층의 @Transactional 메서드에만 적용
    }

    // 조합 2: OR (||)
    @Around("serviceLayer() || repositoryLayer()")
    public Object serviceOrRepository(ProceedingJoinPoint joinPoint) {
        // Service 또는 Repository 계층에 적용
    }

    // 조합 3: NOT (!)
    @Around("serviceLayer() && !transactionalMethod()")
    public Object serviceNotTransactional(ProceedingJoinPoint joinPoint) {
        // Service 계층 중 @Transactional이 없는 메서드
    }
}

4. Advice 타입

4.1 @Before (메서드 실행 전)

@Aspect
@Component
@Slf4j
public class BeforeAdviceExample {

    @Before("execution(* com.example.service.UserService.createUser(..))")
    public void beforeCreateUser(JoinPoint joinPoint) {
        Object[] args = joinPoint.getArgs();
        User user = (User) args[0];

        log.info("Creating user: {}", user.getName());

        // 검증 로직 추가
        if (user.getAge() < 18) {
            throw new IllegalArgumentException("User must be at least 18 years old");
        }
    }
}

4.2 @AfterReturning (정상 반환 후)

@Aspect
@Component
@Slf4j
public class AfterReturningAdviceExample {

    @AfterReturning(
        pointcut = "execution(* com.example.service.UserService.findUser(..))",
        returning = "user"  // 반환값 바인딩
    )
    public void afterReturningFindUser(JoinPoint joinPoint, User user) {
        log.info("User found: {}", user);

        // 통계 업데이트
        statisticsService.incrementUserFindCount(user.getId());
    }
}

4.3 @AfterThrowing (예외 발생 후)

@Aspect
@Component
@Slf4j
public class AfterThrowingAdviceExample {

    @AfterThrowing(
        pointcut = "execution(* com.example.service.*.*(..))",
        throwing = "ex"  // 예외 바인딩
    )
    public void afterThrowingService(JoinPoint joinPoint, Exception ex) {
        log.error("Method {} threw exception: {}",
            joinPoint.getSignature(),
            ex.getMessage()
        );

        // Slack 알림
        slackService.sendAlert(
            "Exception in " + joinPoint.getSignature() + ": " + ex.getMessage()
        );
    }
}

4.4 @After (finally와 유사)

@Aspect
@Component
@Slf4j
public class AfterAdviceExample {

    @After("execution(* com.example.service.*.*(..))")
    public void afterService(JoinPoint joinPoint) {
        // 성공/실패 여부와 관계없이 항상 실행
        log.info("Method {} completed", joinPoint.getSignature());

        // 리소스 정리
        cleanupResources();
    }
}

4.5 @Around (가장 강력, 메서드 실행 전후 제어)

@Aspect
@Component
@Slf4j
public class AroundAdviceExample {

    @Around("execution(* com.example.service.*.*(..))")
    public Object aroundService(ProceedingJoinPoint joinPoint) throws Throwable {
        // 1. Before 로직
        log.info("Before method: {}", joinPoint.getSignature());
        long startTime = System.currentTimeMillis();

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

            // 3. AfterReturning 로직
            log.info("Method returned: {}", result);

        } catch (Exception e) {
            // 4. AfterThrowing 로직
            log.error("Method threw exception", e);
            throw e;

        } finally {
            // 5. After 로직
            long endTime = System.currentTimeMillis();
            log.info("Execution time: {}ms", endTime - startTime);
        }

        return result;
    }
}

Advice 실행 순서:

@Around (Before 부분)
    ↓
@Before
    ↓
Target 메서드 실행
    ↓
@AfterReturning (정상) 또는 @AfterThrowing (예외)
    ↓
@After
    ↓
@Around (After 부분)

5. 실전 예제

5.1 트랜잭션 로깅

@Aspect
@Component
@Slf4j
public class TransactionLoggingAspect {

    @Around("@annotation(org.springframework.transaction.annotation.Transactional)")
    public Object logTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().toShortString();

        log.info("[TX START] {}", methodName);
        long startTime = System.currentTimeMillis();

        try {
            Object result = joinPoint.proceed();

            long endTime = System.currentTimeMillis();
            log.info("[TX COMMIT] {} ({}ms)", methodName, endTime - startTime);

            return result;

        } catch (Exception e) {
            log.error("[TX ROLLBACK] {} - {}", methodName, e.getMessage());
            throw e;
        }
    }
}

5.2 API 응답 시간 측정

@Aspect
@Component
@Slf4j
public class PerformanceMonitoringAspect {

    @Autowired
    private MeterRegistry meterRegistry;

    @Around("@within(org.springframework.web.bind.annotation.RestController)")
    public Object monitorPerformance(ProceedingJoinPoint joinPoint) throws Throwable {
        String controllerName = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = joinPoint.getSignature().getName();

        Timer.Sample sample = Timer.start(meterRegistry);

        try {
            return joinPoint.proceed();

        } finally {
            sample.stop(Timer.builder("api.response.time")
                .tag("controller", controllerName)
                .tag("method", methodName)
                .register(meterRegistry));
        }
    }
}

5.3 캐싱 Aspect

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Cacheable {
    String key();
    int ttl() default 3600;  // 기본 1시간
}

@Aspect
@Component
@Slf4j
public class CachingAspect {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Around("@annotation(cacheable)")
    public Object cache(ProceedingJoinPoint joinPoint, Cacheable cacheable) throws Throwable {
        // 1. 캐시 키 생성
        String cacheKey = generateCacheKey(joinPoint, cacheable.key());

        // 2. 캐시 조회
        Object cachedValue = redisTemplate.opsForValue().get(cacheKey);
        if (cachedValue != null) {
            log.info("Cache hit: {}", cacheKey);
            return cachedValue;
        }

        // 3. 캐시 미스 → 메서드 실행
        log.info("Cache miss: {}", cacheKey);
        Object result = joinPoint.proceed();

        // 4. 캐시에 저장
        redisTemplate.opsForValue().set(
            cacheKey,
            result,
            Duration.ofSeconds(cacheable.ttl())
        );

        return result;
    }

    private String generateCacheKey(ProceedingJoinPoint joinPoint, String keyExpression) {
        // SpEL 또는 간단한 키 생성 로직
        Object[] args = joinPoint.getArgs();
        return keyExpression + ":" + Arrays.toString(args);
    }
}

// 사용
@Service
public class UserService {

    @Cacheable(key = "user", ttl = 1800)  // 30분
    public User findUser(Long id) {
        return userRepository.findById(id);
    }
}

5.4 재시도 (Retry) Aspect

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
    int maxAttempts() default 3;
    long delay() default 1000;  // ms
}

@Aspect
@Component
@Slf4j
public class RetryAspect {

    @Around("@annotation(retry)")
    public Object retryOnFailure(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
        int maxAttempts = retry.maxAttempts();
        long delay = retry.delay();

        int attempt = 1;
        while (true) {
            try {
                return joinPoint.proceed();

            } catch (Exception e) {
                if (attempt >= maxAttempts) {
                    log.error("All {} retry attempts failed", maxAttempts);
                    throw e;
                }

                log.warn("Attempt {} failed: {}. Retrying in {}ms...",
                    attempt,
                    e.getMessage(),
                    delay
                );

                Thread.sleep(delay);
                attempt++;
            }
        }
    }
}

// 사용
@Service
public class ExternalApiService {

    @Retry(maxAttempts = 5, delay = 2000)
    public String callExternalApi() {
        // 외부 API 호출 (실패 시 재시도)
        return restTemplate.getForObject("https://api.example.com/data", String.class);
    }
}

6. AOP 주의사항

6.1 Self-Invocation 문제

// ❌ 문제 코드
@Service
public class UserService {

    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
    }

    public void registerUser(User user) {
        // Self-Invocation → Proxy를 거치지 않음!
        createUser(user);  // ❌ @Transactional 동작 안 함!
    }
}

이유:

this.createUser(user) 호출
→ Proxy가 아닌 실제 객체(this)의 메서드 호출
→ AOP 적용 안 됨!

해결 방법 1: 메서드 분리

@Service
public class UserService {

    @Autowired
    private UserTransactionService transactionService;

    public void registerUser(User user) {
        // 다른 Bean의 메서드 호출 → Proxy를 거침
        transactionService.createUser(user);  // ✅ @Transactional 동작
    }
}

@Service
public class UserTransactionService {

    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
    }
}

해결 방법 2: self-injection (비권장)

@Service
public class UserService {

    @Autowired
    @Lazy  // 순환 참조 방지
    private UserService self;

    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
    }

    public void registerUser(User user) {
        self.createUser(user);  // ✅ Proxy 호출
    }
}

6.2 final 메서드는 AOP 적용 불가 (CGLIB)

@Service
public class UserService {

    // ❌ CGLIB는 final 메서드를 오버라이드할 수 없음
    public final User findUser(Long id) {
        return userRepository.findById(id);
    }
}

해결:

// ✅ final 제거
public User findUser(Long id) {
    return userRepository.findById(id);
}

6.3 Aspect 실행 순서 제어

// 여러 Aspect가 같은 Join Point에 적용될 때
@Aspect
@Order(1)  // 낮을수록 먼저 실행
@Component
public class SecurityAspect {
    @Around("execution(* com.example.service.*.*(..))")
    public Object checkSecurity(ProceedingJoinPoint joinPoint) { }
}

@Aspect
@Order(2)
@Component
public class LoggingAspect {
    @Around("execution(* com.example.service.*.*(..))")
    public Object log(ProceedingJoinPoint joinPoint) { }
}

// 실행 순서:
// SecurityAspect (Before) → LoggingAspect (Before)
//     → Target 메서드
// LoggingAspect (After) → SecurityAspect (After)

요약

AOP 기본 개념

  • 횡단 관심사(Cross-Cutting Concerns): 로깅, 트랜잭션, 보안 등
  • Target: Advice가 적용되는 대상 객체
  • Join Point: Advice 적용 가능한 지점
  • Pointcut: 실제 Advice가 적용되는 지점 선택
  • Advice: 실행되는 코드(Before, After, Around 등)
  • Aspect: Pointcut + Advice

Proxy 패턴

  • JDK Dynamic Proxy: 인터페이스 기반
  • CGLIB: 클래스 상속 기반(Spring Boot 기본)
  • Proxy 생성: 런타임에 동적으로 생성
  • Bean 주입: 실제로는 Proxy 객체가 주입됨

Pointcut Expression

  • execution: 가장 많이 사용, 메서드 시그니처 기반
  • @annotation: Custom Annotation 기반
  • within: 특정 타입 내 모든 메서드
  • 조합: &&, ||, ! 사용 가능

Advice 타입

  • @Before: 메서드 실행 전
  • @AfterReturning: 정상 반환 후
  • @AfterThrowing: 예외 발생 후
  • @After: 항상 실행(finally)
  • @Around: 가장 강력, 전후 제어 가능

실전 활용

  • 트랜잭션 로깅, 성능 모니터링
  • 캐싱, 재시도(Retry) 로직
  • API 응답 시간 측정
  • 예외 알림(Slack, Email)

주의사항

  • Self-Invocation: 같은 클래스 내부 호출 시 AOP 미적용
  • final 메서드/클래스: CGLIB 제약(구조에 따라)
  • @Order: 여러 Aspect 실행 순서 제어
  • 성능: Around Advice는 반드시 proceed() 호출