AOP에 대해서 알아볼 때 수동 프록시로 구현했던 AOP를 스프링으로 바꿔보자.

스프링을 사용하면 내가 직접 프록시 클래스를 만들고 그 프록시 인스턴스를 호출하지 않아도 된다.
DiscountPolicy 구현체(비즈니스 로직)와 AOP 설정(@Aspect, 포인트컷/어드바이스)만 있으면, 스프링이 컨테이너 초기화 시점에 프록시 객체를 자동으로 생성해준다.
그래서 겉보기에는 discountPolicy.discount(...)를 호출한 것처럼 보이지만,
실제로는 프록시가 먼저 실행되어 부가 기능을 수행한 뒤 실제 DiscountPolicy의 discount(...) 메서드를 호출한다.
호출 흐름은 이렇게 이해하면 된다.
- (수동 프록시) 클라이언트 → 내가 만든 Proxy → 실제 DiscountPolicy
- (스프링 AOP) 클라이언트 → 스프링이 만든 프록시 → 실제 DiscountPolicy
프록시 생성·적용을 스프링이 대신 처리해주므로,
개발자는 비즈니스 로직은 DiscountPolicy 구현체에 집중하고, 횡단 관심사는 @Aspect로 분리해 선언만 하면 된다.
@Component
public class FixedDiscountPolicy implements DiscountPolicy {
@Override public int discount(int price) { return 1000; }
}
@Component
public class RateDiscountPolicy implements DiscountPolicy {
@Override public int discount(int price) { return (int)(price * 0.1); }
}
@Component
public class SpecialDiscountPolicy implements DiscountPolicy {
@Override public int discount(int price) { return (price >= 50000) ? 5000 : 0; }
}
Aspect
@Aspect
@Component
public class DiscountLogAspect {
// DiscountPolicy의 discount(..) 메서드에만 적용
@Around("execution(int com.example..DiscountPolicy.discount(..))")
public Object logDiscount(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
String target = pjp.getTarget().getClass().getSimpleName();
Object[] args = pjp.getArgs();
System.out.println("[LOG] " + target + " 시작 price=" + args[0]);
try {
Object result = pjp.proceed(); // 핵심 로직 실행
System.out.println("[LOG] 결과 amount=" + result);
return result;
} finally {
long end = System.currentTimeMillis();
System.out.println("[LOG] 실행 시간=" + (end - start) + "ms");
}
}
}
기존 LoggingDiscountPolicyProxy 대신 DiscountLogAspect를 만들었다.
이 클래스를 통해 AOP 설정을 해봤는데, 우선 @Aspect를 통해 이 클래스가 AOP 설정 클래스(애스펙트)라는 것을 스프링에게 알려준다. 그리고 애스펙트도 결국 스프링 빈으로 관리되기 때문에 @Component를 붙여 빈으로 등록한다.
Advice
그 다음 보이는 어노테이션은 @Around이다. 여기에는 포인트컷이 들어간다.
포인트컷은 말 그대로 어떤 조인 포인트에 적용할지 선택하는 필터 조건이다. 지금 예시에서는 반환 타입이 int이고, com.example 패키지 이하의 DiscountPolicy 구현체에서 discount() 메서드를 호출할 때 AOP가 적용된다는 뜻이다.
여기서 중요한 의문점이 든다.
“포인트컷은 어떤 조인 포인트를 선택하는 것인데, 스프링은 애초에 조인 포인트를 어떻게 정의하지?”
조인 포인트(Join Point)는 부가 기능(Advice)이 적용될 수 있는 실행 지점을 말한다.
AspectJ 같은 완전한 AOP 프레임워크에서는 생성자 호출, 필드 접근, 예외 처리 등 다양한 조인 포인트를 지원한다.
하지만 Spring AOP는 프록시 기반으로 동작하기 때문에, 프록시로 가로챌 수 있는 지점만 조인 포인트로 인정한다.
따라서 스프링 AOP에서 조인 포인트 = 스프링 컨테이너에 등록된 빈의 public 메서드 실행 시점이다.
정리하면 스프링은 모든 빈을 관리하고 있으므로, 컨테이너 안의 빈 메서드 실행 자체를 조인 포인트로 간주하고, 그 중에서 포인트컷 표현식에 매칭되는 메서드에만 어드바이스(부가 기능)를 적용하는 것이다.
Spring AOP에서 어드바이스는 조인 포인트(=메서드 실행 시점)에 끼워 넣을 수 있는 “부가 기능”이다.
어떤 애노테이션을 쓰느냐에 따라 동작 시점과 구현 방식이 달라진다.
@Before
- 대상 메서드 실행 직전에 실행된다.
- 반환값을 바꾸거나 예외를 처리할 수는 없다.
- proceed() 같은 개념이 없으므로 흐름 제어는 불가능하다.
- 주로 인자 검증, 접근 권한 확인, 사전 로깅에 사용된다.
@AfterReturning
- 메서드가 정상적으로 리턴된 후 실행된다.
- 리턴 값을 파라미터로 받아서 추가 처리(가공, 로깅)가 가능하다.
- 예외가 발생하면 실행되지 않는다.
@AfterThrowing
- 대상 메서드 실행 중 예외가 던져졌을 때만 실행된다.
- 예외를 잡아 다른 예외로 바꿔 던지거나, 예외를 로깅하는 데 유용하다.
@After
- 정상/예외 구분 없이 항상 마지막에 실행된다.
- finally 블록처럼 정리 작업(리소스 해제, 로그 종료) 등에 주로 쓴다.
@Around
- 메서드 실행 전/후/정상/예외 모두 제어할 수 있는 가장 강력한 어드바이스다.
- ProceedingJoinPoint.proceed()를 호출해야 실제 대상 메서드가 실행된다.
- 호출 시점과 결과를 완전히 제어할 수 있어 시간 측정, 트랜잭션 경계, 캐싱 같은 복합적인 부가 기능에 사용된다.
- ProceedingJoinPoint : @Around 어드바이스에서만 사용 가능한 특별한 매개변수다.
- proceed() : 실제 대상 메서드를 실행한다. 호출하지 않으면 핵심 로직이 아예 실행되지 않는다.
- getArgs() : 현재 메서드에 넘어온 인자 배열을 가져온다.
- getTarget() : 실제 대상 객체(원본 빈).
- getThis() : 프록시 객체(스프링이 감싼 껍데기).
- getSignature() : 메서드 이름, 선언 정보, 반환 타입 등 시그니처 정보.
전체 흐름 보기
빈이 등록되는 순서는 다음과 같다.
1. 빈 정의 읽기 : 컴포넌트 스캔이나 @Bean 메서드 등을 통해 빈 후보를 스캔
2. 인스턴스 생성 (Instantiation) : new를 호출해서 실제 객체를 생성
3. 의존성 주입 (Dependency Injection) : 생성자 주입, 필드 주입, 세터 주입 등으로 필요한 빈 넣기
4. 빈 후처리기(BeanPostProcessor) 적용
5. 초기화 콜백 (afterPropertiesSet, @PostConstruct 등) : 빈이 준비된 후 초기화 로직 실행
6. 빈 사용 가능 : ApplicationContext에서 getBean() 하면 프록시(혹은 원본)가 반환
7. 종료 시점 : @PreDestroy, DisposableBean 같은 종료 콜백 실행
3번까지 진행되면 의존성 주입이 모두 끝난 상태다. 그 다음 빈 후처리기 단계에서 스프링은 @Aspect가 적용된 설정을 확인하고, AOP를 적용해야 한다고 판단하면 포인트컷 정보를 바탕으로 어떤 객체와 메서드에 부가기능을 적용할지 결정한다. 이때 원본 객체를 그대로 컨테이너에 올리는 것이 아니라, 프록시 객체를 새로 만들어 원본 대신 컨테이너에 등록한다. 이후 5번 과정(초기화 콜백)이 끝나면 모든 빈 등록이 완료된다.
따라서 우리가 컨테이너에서 빈을 꺼내 메서드를 호출할 때는 이미 프록시 객체가 들어가 있어서, 먼저 부가기능이 실행되고 그 다음에 실제 비즈니스 로직이 수행된다.
즉, 실행 시점에 교체되는 것이 아니라 등록 시점에 이미 프록시로 교체된 상태라는 점이 중요하다.
실행 흐름 보기
빈 등록이 끝난 후 실제 로직이 실행되는 과정은 다음과 같다.
클라이언트 코드
↓
프록시 객체 (스프링이 만든 대리인)
↓
ReflectiveMethodInvocation (어드바이스 체인 실행)
↓
[@Before 어드바이스 실행]
↓
[@Around 어드바이스: proceed() 호출 전]
↓
[실제 대상 메서드 실행: FixedDiscountPolicy.discount()]
↓
[@Around 어드바이스: proceed() 호출 후]
↓
[@AfterReturning / @AfterThrowing / @After 실행]
↓
결과 반환
클라이언트가 빈을 호출하면 원본 객체가 바로 실행되는 것이 아니라, 먼저 프록시 객체가 호출을 가로채고 내부의 ReflectiveMethodInvocation이 어드바이스 체인을 순서대로 실행한다. 그리고 우리가 어떤 어드바이스 애노테이션(@Before, @Around, @AfterReturning, @AfterThrowing, @After)을 정의했는지에 따라 실행 순서가 달라지며, 그 결과 핵심 로직과 부가 기능이 결합된 실행 흐름이 만들어진다.
'Spring' 카테고리의 다른 글
| Page와 Slice (0) | 2025.11.26 |
|---|---|
| 스프링에서 서블릿을 사용하는 방법 (0) | 2025.09.21 |
