한 줄 요약: Spring AOP는 프록시 기반으로 로깅·트랜잭션 같은 횡단 관심사를 비즈니스 로직과 분리하며, BeanPostProcessor가 그 자동화를 담당합니다.

1. 실무 시나리오 — 100개 서비스에 로그를 추가하라

비유: 100명의 직원이 있는 회사에서 “모든 직원의 출퇴근 시간을 기록하라”는 지시가 내려왔다. 직접 방식은 직원 100명에게 각자 일지를 쓰라고 한다. 이후 기록 양식이 바뀌면 100명 모두 새 양식을 배워야 한다. AOP 방식은 출입구에 자동 카드리더기를 설치한다. 직원들은 아무것도 바꾸지 않아도 되고, 양식이 바뀌면 카드리더기 설정만 바꾸면 된다.

어느 날 팀장이 요청합니다. “모든 서비스 메서드에 실행 시간 로그를 추가해 주세요.” 서비스 클래스가 100개라면 어떻게 할까요?

직접 수정하면 100개 파일을 열어야 합니다. 나중에 로그 포맷을 바꾸면 또 100곳을 수정해야 합니다. 측정 방식을 System.currentTimeMillis()에서 StopWatch로 바꾸기로 했다면? 다시 100곳.

이처럼 여러 클래스에 걸쳐 반복되는 코드를 횡단 관심사(Cross-Cutting Concern)라고 합니다. 비즈니스 로직과는 무관하지만 모든 곳에 필요합니다. Spring AOP는 이 문제를 프록시 패턴으로 해결합니다.


2. 횡단 관심사 — 문제를 눈으로 확인하기

2.1 코드 중복이 얼마나 심각해지는가

// 로그를 남기고 싶은 서비스들 — 모두 동일한 패턴 반복
public class OrderService {
    public void createOrder(Order order) {
        log.info("OrderService.createOrder 시작");   // 중복!
        long start = System.currentTimeMillis();     // 중복!

        // 핵심 비즈니스 로직 (단 1줄)
        orderRepository.save(order);

        long end = System.currentTimeMillis();
        log.info("OrderService.createOrder 종료 — {}ms", end - start); // 중복!
    }
}

public class MemberService {
    public void join(Member member) {
        log.info("MemberService.join 시작");  // 또 중복!
        long start = System.currentTimeMillis();
        memberRepository.save(member);
        long end = System.currentTimeMillis();
        log.info("MemberService.join 종료 — {}ms", end - start);
    }
}

핵심 로직은 orderRepository.save(order) 한 줄인데, 그것을 감싸는 로그 코드가 6줄입니다. 서비스가 100개라면 로그 관련 코드만 600줄입니다. 그리고 이 600줄은 모두 완벽히 동일한 패턴입니다.

만약 AOP 없이 이대로 가면? 로그 포맷 하나를 바꾸는 작업이 “100개 파일 수정 + 테스트 + 배포”가 됩니다. 실수로 일부 파일을 빠뜨리면 로그가 일관성 없어집니다. 신규 개발자가 새 서비스를 만들 때 이 패턴을 모르면 로그가 누락됩니다.

2.2 AOP로 관심사를 분리하면

graph TD
    subgraph "AOP 적용 후 — 각자 자기 일만"
        A["OrderService"] --> F["핵심 비즈니스 로직만"]
        B["MemberService"] --> F
        C["ItemService"] --> F
    end

    subgraph "Aspect — 한 곳에서 관리"
        G["LogAspect\n로그 처리"]
        H["TransactionAspect\n트랜잭션 처리"]
    end

    G -. "자동 적용 (프록시)" .--> A
    G -. "자동 적용 (프록시)" .--> B
    G -. "자동 적용 (프록시)" .--> C

    classDef green fill:#d4edda,stroke:#28a745
    class G,H green

LogAspect 하나만 바꾸면 100개 서비스에 일괄 적용됩니다. 새 서비스를 추가해도 자동으로 로그가 붙습니다. 비즈니스 로직에는 단 한 줄도 추가할 필요가 없습니다.


3. 프록시 패턴 기초 — AOP의 토대

3.1 프록시가 무엇인지 정확히 이해하기

비유: 연예인(RealSubject)에게 직접 연락하려면 매니저(Proxy)를 통해야 한다. 매니저는 불필요한 요청을 걸러내고(접근 제어), 일정 관리나 계약 검토 같은 부가 업무를 처리한다(부가 기능). 외부에서 보면 연예인인지 매니저인지 구별하기 어렵다 — 둘 다 같은 인터페이스를 구현하기 때문이다.

classDiagram
    class Subject {
        <<interface>>
        +operation() String
    }
    class RealSubject {
        +operation() String
    }
    class Proxy {
        -target: RealSubject
        +operation() String
    }

    Subject <|-- RealSubject
    Subject <|-- Proxy
    Proxy --> RealSubject : "실제 작업 위임"

    note for Proxy "클라이언트는 Subject만 알면 됨\n프록시인지 실제 객체인지 몰라도 됨"

클라이언트는 Subject 인터페이스만 알고 있습니다. 실제로 RealSubject를 받든 Proxy를 받든 코드를 바꿀 필요가 없습니다. Spring이 이 원리를 이용해서, 기존 OrderService 코드를 건드리지 않고 앞에 프록시를 세워 로그를 추가합니다.

역할 1: 접근 제어 (Cache Proxy)

public class CacheProxy implements Subject {
    private Subject target;
    private String cacheValue;

    @Override
    public String operation() {
        if (cacheValue == null) {
            // 처음 호출 시에만 실제 객체 호출
            cacheValue = target.operation();
        }
        // 이후에는 캐시에서 반환 — 실제 객체 미호출
        return cacheValue;
    }
}

역할 2: 부가 기능 추가 (Decorator Proxy)

public class TimeDecorator implements Subject {
    private Subject target;

    @Override
    public String operation() {
        long start = System.currentTimeMillis();
        String result = target.operation(); // 실제 객체에 위임
        log.info("실행 시간: {}ms", System.currentTimeMillis() - start);
        return result;
    }
}

3.2 프록시 체인 — 여러 기능을 조합하는 방법

sequenceDiagram
    participant C as Client
    participant T as TimeDecorator
    participant CP as CacheProxy
    participant R as RealSubject

    C->>T: operation()
    T->>T: 1️⃣ 시간 측정 시작
    T->>CP: operation() 위임
    CP->>CP: 2️⃣ 캐시 확인
    CP->>R: operation() (캐시 없을 때만)
    R-->>CP: 결과 반환
    CP-->>T: 결과 반환 (캐시 저장)
    T->>T: 3️⃣ 시간 측정 종료, 로그 출력
    T-->>C: 결과 반환

시간 측정 → 캐시 → 실제 로직 순서로 연결됩니다. 순서를 바꾸고 싶거나 새 기능을 추가하고 싶으면 체인에 하나만 추가하면 됩니다. RealSubject는 전혀 건드리지 않습니다.


4. JDK 동적 프록시 vs CGLIB — Spring이 두 가지 방식을 쓰는 이유

4.1 JDK 동적 프록시 — 인터페이스가 있을 때

비유: 채용 공고(인터페이스)에 맞춰 가상의 직원을 만드는 것이다. 채용 공고가 있어야만 만들 수 있다.

public interface MemberService {
    String hello(String name);
}

// InvocationHandler — 모든 메서드 호출이 여기를 통과
public class LogInvocationHandler implements InvocationHandler {
    private final Object target;

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        log.info("[로그 시작] {}", method.getName());
        Object result = method.invoke(target, args); // 실제 메서드 호출
        log.info("[로그 종료] {}", method.getName());
        return result;
    }
}

// 프록시 생성
MemberService proxy = (MemberService) Proxy.newProxyInstance(
    target.getClass().getClassLoader(),
    new Class[]{MemberService.class},
    new LogInvocationHandler(target)
);

한 개의 InvocationHandler가 모든 메서드 호출을 가로챕니다. 메서드가 100개여도 InvocationHandler 하나만 구현하면 됩니다.

제약: 인터페이스가 없으면 사용할 수 없습니다. ConcreteService extends Object처럼 인터페이스 없이 클래스만 있으면 JDK 동적 프록시로는 프록시를 만들 수 없습니다.

4.2 CGLIB — 클래스만 있어도 프록시 가능

비유: 기존 직원(클래스)을 복제해서 확장된 직원을 만드는 것이다. 원본을 상속받아 메서드를 오버라이드한다.

// 인터페이스 없어도 프록시 가능 — 클래스를 상속
public class ConcreteService {
    public String call() {
        return "ConcreteService 호출";
    }
}

public class TimeMethodInterceptor implements MethodInterceptor {
    private final Object target;

    @Override
    public Object intercept(Object obj, Method method, Object[] args,
                           MethodProxy methodProxy) throws Throwable {
        long start = System.currentTimeMillis();
        // methodProxy.invoke() 사용 — 리플렉션보다 빠름
        Object result = methodProxy.invoke(target, args);
        log.info("실행 시간: {}ms", System.currentTimeMillis() - start);
        return result;
    }
}

CGLIB은 바이트코드를 조작해서 ConcreteService를 상속받는 서브클래스를 런타임에 생성합니다. 상속받은 모든 메서드를 오버라이드해서 MethodInterceptor를 통해 부가 기능을 실행합니다.

4.3 왜 Spring Boot는 기본적으로 CGLIB을 쓰는가

특성 JDK 동적 프록시 CGLIB
조건 인터페이스 필수 클래스만으로 가능
생성 방식 인터페이스 구현 클래스 상속
성능 리플렉션 기반 바이트코드 생성 (더 빠름)
제약 - final 클래스/메서드 불가
Spring Boot 기본 X O (proxyTargetClass=true)

Spring Boot는 인터페이스가 있어도 CGLIB을 씁니다. 이유는 JDK 동적 프록시는 인터페이스 타입으로만 캐스팅할 수 있기 때문입니다. @Autowired MemberServiceImpl impl처럼 구체 클래스 타입으로 주입받으면 ClassCastException이 발생합니다. CGLIB은 원본 클래스의 서브클래스이므로 구체 클래스 타입으로도 주입받을 수 있습니다.

실무에서 자주 하는 실수 — final 메서드에 @Transactional:

@Service
public class OrderService {

    @Transactional // 이 어노테이션은 완전히 무시됩니다
    public final void createOrder(Order order) { // final이 문제!
        orderRepository.save(order);
    }
}

CGLIB은 서브클래스를 만들어 메서드를 오버라이드하는 방식인데, final 메서드는 오버라이드할 수 없습니다. 그래서 @Transactional이 적용된 프록시 메서드를 만들 수 없고, 결국 트랜잭션이 동작하지 않습니다. 컴파일 오류도, 런타임 예외도 없습니다. DB에 오류가 나도 롤백되지 않는다는 것을 배포 후에야 알게 됩니다.


5. ProxyFactory — Spring이 JDK/CGLIB 선택을 자동화하는 방법

비유: 프록시팩토리는 총기 상점이다. 손님(개발자)이 “방어용 도구 주세요”라고만 하면, 상황에 맞는 총을 알아서 골라준다. 손님은 JDK 동적 프록시인지 CGLIB인지 알 필요가 없다.

flowchart TD
    A["ProxyFactory 생성\nnew ProxyFactory(target)"] --> B{"인터페이스 있음?"}
    B -->|"있음 + proxyTargetClass=false"| C["JDK 동적 프록시 생성"]
    B -->|"없음 또는 proxyTargetClass=true\n(Spring Boot 기본)"| D["CGLIB 생성"]

    E["Advice 추가\n부가 기능 로직"] --> A
    F["Pointcut 설정\n어디에 적용?"] --> A

    classDef green fill:#d4edda,stroke:#28a745
    classDef blue fill:#cce5ff,stroke:#007bff
    class C,D green
    class A blue
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);

proxyFactory.addAdvice((MethodInterceptor) invocation -> {
    log.info("[ProxyFactory] 호출 전: {}", invocation.getMethod().getName());
    Object result = invocation.proceed(); // 실제 메서드 실행
    log.info("[ProxyFactory] 호출 후: {}", invocation.getMethod().getName());
    return result;
});

ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();

ProxyFactory를 직접 쓸 일은 거의 없습니다. 실무에서는 @Aspect로 선언하면 Spring이 내부적으로 ProxyFactory를 사용합니다. 하지만 동작 원리를 이해해야 트러블슈팅이 가능합니다.


6. AOP 핵심 개념 — 용어를 이야기로 이해하기

6.1 용어 정리 — 하나의 시나리오로 연결하기

시나리오: 회사 출입구에 자동 카드리더기를 설치한다.

  • Aspect(측면): 출입 기록 시스템 전체 (관심사 모듈)
  • Join Point(결합 지점): 직원이 출입문을 통과하는 모든 순간 (적용 가능한 모든 지점)
  • Pointcut(포인트컷): “지하주차장 출입구와 정문만” (어디에 적용할지 선택)
  • Advice(어드바이스): 카드를 찍는 실제 동작 (부가 기능 로직)
  • Target(대상): 출입하는 직원들 (어드바이스 받는 객체)
  • Proxy(프록시): 카드리더기가 설치된 출입문 (AOP 적용 결과)
  • Weaving(위빙): 출입문에 카드리더기를 설치하는 작업
graph TD
    A["AOP 개념 체계"] --> B["Aspect\n부가 기능 모듈"]
    A --> C["Join Point\n적용 가능 지점"]
    A --> D["Pointcut\n적용 위치 선택"]
    A --> E["Advice\n실제 부가 기능"]
    A --> F["Weaving\n어드바이스 적용"]

    B -->|"구현"| I["@Aspect 클래스"]
    C -->|"Spring AOP 지원"| J["메서드 실행 시점만"]
    D -->|"표현식"| K["execution(* hello..*.*(..))"]
    E -->|"종류"| L["@Before, @After, @Around"]
    F -->|"시점"| M["런타임 (프록시 생성)"]

    classDef blue fill:#cce5ff,stroke:#007bff
    classDef green fill:#d4edda,stroke:#28a745
    class B,D,E green
    class C,F blue

왜 Spring AOP는 메서드 실행 시점만 지원하는가? AspectJ는 필드 접근, 생성자 호출, 정적 메서드 등 모든 지점에 적용할 수 있습니다. 하지만 그만큼 설정이 복잡하고 컴파일 시점 위빙이 필요합니다. Spring AOP는 런타임 프록시만 사용하므로 별도 설정 없이 쓸 수 있고, 실무에서 필요한 99%의 케이스(서비스 메서드 감싸기)를 커버합니다.

6.2 Advice 5가지 — 실행 순서와 적합한 용도

@Aspect
@Component
@Slf4j
public class AllLogAspect {

    // @Before: 메서드 실행 전
    // 용도: 파라미터 로깅, 권한 사전 체크
    @Before("execution(* hello.service.*.*(..))")
    public void before(JoinPoint joinPoint) {
        log.info("[Before] {} 시작, args={}",
            joinPoint.getSignature().toShortString(),
            Arrays.toString(joinPoint.getArgs()));
    }

    // @AfterReturning: 정상 반환 후 (예외 시 실행 안 됨)
    // 용도: 반환값 로깅, 캐시 저장
    @AfterReturning(value = "execution(* hello.service.*.*(..))", returning = "result")
    public void afterReturning(JoinPoint joinPoint, Object result) {
        log.info("[AfterReturning] {} 완료, result={}",
            joinPoint.getSignature().toShortString(), result);
    }

    // @AfterThrowing: 예외 발생 후 (정상 완료 시 실행 안 됨)
    // 용도: 예외 알림, 에러 로깅
    @AfterThrowing(value = "execution(* hello.service.*.*(..))", throwing = "ex")
    public void afterThrowing(JoinPoint joinPoint, Exception ex) {
        log.error("[AfterThrowing] {} 예외 발생: {}",
            joinPoint.getSignature().toShortString(), ex.getMessage());
    }

    // @After: 정상/예외 관계없이 항상 (finally와 유사)
    // 용도: 리소스 해제, ThreadLocal 정리
    @After("execution(* hello.service.*.*(..))")
    public void after(JoinPoint joinPoint) {
        log.info("[After] {} 종료 (정상/예외 무관)",
            joinPoint.getSignature().toShortString());
    }

    // @Around: 가장 강력 — 실행 전/후 모두 제어
    // 용도: 실행 시간 측정, 트랜잭션, 재시도 로직
    @Around("execution(* hello.service.*.*(..))")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[Around 전] {}", joinPoint.getSignature().toShortString());
        try {
            Object result = joinPoint.proceed(); // 이 한 줄이 실제 메서드 실행
            log.info("[Around 후] 결과={}", result);
            return result;
        } catch (Exception e) {
            log.error("[Around 예외] {}", e.getMessage());
            throw e; // 반드시 다시 던져야 함 (삼키면 예외 은폐)
        }
    }
}

@Around에서 joinPoint.proceed()를 호출하지 않으면? 실제 메서드가 실행되지 않습니다. 컴파일도 되고 아무 오류도 없지만, 비즈니스 로직이 통째로 건너뜁니다. 이 버그는 찾기 매우 어렵습니다. 항상 proceed()를 호출하고 결과를 반환하는지 확인해야 합니다.

6.3 Advice 실행 순서

sequenceDiagram
    participant C as Client
    participant AR as @Around (전)
    participant B as @Before
    participant T as Target Method
    participant ARF as @Around (후)
    participant AF as @AfterReturning/@AfterThrowing
    participant A as @After

    C->>AR: 호출
    AR->>B: proceed() 이전
    Note over B: @Before 실행
    B->>T: 대상 메서드 실행
    T-->>ARF: 결과 반환
    Note over ARF: @Around 후처리
    ARF->>AF: @AfterReturning 또는 @AfterThrowing
    AF->>A: @After (항상)
    A-->>C: 최종 반환

7. Pointcut 표현식 — 정확히 어디에 적용할지

7.1 execution 표현식 — 가장 많이 사용하는 형식

execution(접근제어자? 반환타입 선언타입?메서드이름(파라미터) 예외?)
# ?는 생략 가능
// 가장 구체적인 것부터 가장 넓은 것까지

// 딱 이 메서드 하나
execution(public String hello.service.OrderService.createOrder(Long, String, int))

// OrderService의 모든 메서드
execution(* hello.service.OrderService.*(..))

// hello.service 패키지의 모든 클래스, 모든 메서드
execution(* hello.service.*.*(..))

// hello.service와 하위 패키지(..)의 모든 메서드
execution(* hello.service..*.*(..))

// create로 시작하는 모든 메서드
execution(* create*(..))

// String 파라미터 정확히 1개
execution(* *(String))

// 모든 파라미터 (없어도 됨)
execution(* *(..))

너무 넓게 잡으면 (예: execution(* *.*(..))) Spring 프레임워크 내부 클래스에도 Aspect가 적용되어 예기치 않은 동작이 생깁니다. 너무 좁게 잡으면 새 서비스를 추가할 때마다 Pointcut도 수정해야 합니다. 패키지 단위로 잡는 execution(* hello.service..*.*(..)) 방식이 균형점입니다.

7.2 커스텀 어노테이션으로 AOP 적용 — 실무에서 가장 깔끔한 방법

비유: “이 팻말(@Retry)이 붙은 문에는 자동 재시도 시스템을 달아라”는 규칙이다. 팻말이 어디 붙었는지 AOP가 스캔해서 자동으로 처리해준다.

// 1. 어노테이션 정의 — 의도를 명확히 표현
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME) // 런타임에 읽어야 하므로 RUNTIME 필수
public @interface Retry {
    int maxAttempts() default 3;
    long delayMs() default 1000;
}

// 2. 서비스에서 사용 — 비즈니스 로직이 깔끔해짐
@Service
public class ExternalApiService {

    @Retry(maxAttempts = 5, delayMs = 500) // "이 메서드는 최대 5번까지 재시도"
    public String callExternalApi(String param) {
        return restTemplate.getForObject(apiUrl, String.class);
    }
}

// 3. Aspect 구현
@Aspect
@Component
@Slf4j
public class RetryAspect {

    @Around("@annotation(retry)")
    public Object retry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
        int maxAttempts = retry.maxAttempts();
        Exception lastException = null;

        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                return joinPoint.proceed();
            } catch (Exception e) {
                lastException = e;
                log.warn("[Retry] {}/{} 실패: {}", attempt, maxAttempts, e.getMessage());
                if (attempt < maxAttempts) {
                    Thread.sleep(retry.delayMs());
                }
            }
        }
        log.error("[Retry] 최대 재시도 횟수({}) 초과", maxAttempts);
        throw lastException;
    }
}

이 방식의 장점은 코드를 읽을 때 @Retry가 붙은 것만 봐도 “이 메서드는 재시도 로직이 있다”는 것을 즉시 알 수 있다는 점입니다. 반면 execution 기반은 어떤 메서드에 AOP가 적용되는지 Aspect 파일을 직접 열어봐야 합니다.


8. 어드바이저 — Pointcut + Advice의 결합

어드바이저는 “어디에(Pointcut) 무엇을(Advice) 적용할지”의 쌍입니다.

graph LR
    A["Advisor"] --> B["Pointcut\n어디에?"]
    A --> C["Advice\n무엇을?"]
    B --> D["execution 표현식\n@annotation 등"]
    C --> E["로깅, 트랜잭션\n캐싱, 보안 등"]

    classDef blue fill:#cce5ff,stroke:#007bff
    classDef green fill:#d4edda,stroke:#28a745
    class B blue
    class C green

여러 Advisor가 같은 메서드에 적용될 때는 @Order로 순서를 지정합니다. 트랜잭션 Advisor가 로그 Advisor보다 바깥에 있어야 “로그 기록 → 트랜잭션 시작 → 비즈니스 로직 → 트랜잭션 커밋 → 로그 종료” 순서가 됩니다. 반대로 하면 트랜잭션이 커밋되기 전에 로그가 끝납니다.


9. 빈 후처리기 (BeanPostProcessor) — AOP 자동화의 실제 메커니즘

9.1 동작 원리 — “빈이 완성된 직후 가공한다”

비유: 자동차 공장 컨베이어 벨트 끝에 품질 검사관이 있다. 자동차(빈)가 완성되면 검사관(BeanPostProcessor)이 확인하고, 필요한 경우 부품을 추가하거나 아예 다른 차로 교체한다. Spring은 여기서 “AOP 대상이면 프록시로 교체”한다.

sequenceDiagram
    participant C as Spring Container
    participant O as 원본 빈
    participant BPP as BeanPostProcessor
    participant P as 프록시 빈

    Note over C,O: 1️⃣ 빈 생성
    C->>O: 빈 생성 (new)
    C->>O: 의존관계 주입
    C->>O: @PostConstruct 실행

    Note over C,BPP: 2️⃣ 초기화 후 후처리 ← 프록시 생성 시점!
    C->>BPP: postProcessAfterInitialization(bean, beanName)
    BPP->>BPP: Pointcut과 매칭 확인
    BPP->>P: 매칭되면 ProxyFactory로 프록시 생성
    C-->>P: 컨테이너에 프록시 등록 (원본 빈 대신!)

    Note over C: 이후 getBean() 호출 시 프록시가 반환됨

이 사실이 중요한 이유: @PostConstruct는 원본 빈에서 실행됩니다. 하지만 컨테이너에 등록되는 것은 프록시입니다. 즉, @PostConstruct 안에서 this는 원본 객체이고, 외부에서 주입받는 것은 프록시입니다. 대부분의 경우 문제없지만, @PostConstruct 안에서 프록시 메서드를 호출해야 하는 상황이라면 주의가 필요합니다.

9.2 자동 프록시 생성기 — @Aspect를 쓰면 내부에서 무슨 일이 일어나는가

@Aspect를 선언하면 Spring이 내부적으로 AnnotationAwareAspectJAutoProxyCreator라는 BeanPostProcessor 구현체를 등록합니다. 이 클래스가 모든 빈의 생성을 감시하다가, Pointcut과 매칭되는 빈이 있으면 자동으로 프록시를 생성합니다.

flowchart TD
    A["빈 생성 완료"] --> B["AnnotationAwareAspectJAutoProxyCreator\n(BeanPostProcessor)"]
    B --> C{"등록된 Advisor의\nPointcut과 매칭?"}
    C -->|"매칭 (하나 이상)"| D["ProxyFactory로 프록시 생성\n매칭된 Advisor 모두 적용"]
    C -->|"미매칭"| E["원본 빈 그대로 등록"]
    D --> F["프록시 빈을 컨테이너에 등록"]
    E --> F

    classDef green fill:#d4edda,stroke:#28a745
    classDef blue fill:#cce5ff,stroke:#007bff
    class D green
    class B blue

개발자가 해야 할 일은 @Aspect 클래스를 작성하고 @Component로 등록하는 것뿐입니다. 나머지는 Spring이 알아서 합니다. 그러나 내부 동작을 모르면, “왜 이 메서드에는 AOP가 안 먹히지?”라는 문제를 해결하기 어렵습니다.


10. @Aspect 실전 예시들

10.1 실행 시간 측정 — 슬로우 메서드 감지

@Aspect
@Component
@Slf4j
public class ExecutionTimeAspect {

    private static final long SLOW_THRESHOLD_MS = 100;

    @Around("execution(* hello..*(..))")
    public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        try {
            return joinPoint.proceed();
        } finally {
            stopWatch.stop();
            long elapsed = stopWatch.getTotalTimeMillis();

            if (elapsed > SLOW_THRESHOLD_MS) {
                // 100ms 초과 시 경고 로그 — 성능 이슈 탐지
                log.warn("[SLOW] {} — {}ms (임계값: {}ms)",
                    joinPoint.getSignature().toShortString(),
                    elapsed, SLOW_THRESHOLD_MS);
            }
        }
    }
}

이 Aspect 하나만 등록하면, 모든 메서드의 실행 시간을 모니터링할 수 있습니다. 슬로우 쿼리나 느린 외부 API 호출을 자동으로 감지합니다. 기존 서비스 코드는 한 줄도 수정하지 않습니다.

10.2 분산 추적 — 호출 계층을 시각화하기

@Aspect
@Component
@Slf4j
public class DistributedTracingAspect {

    private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
    private static final ThreadLocal<Integer> DEPTH = new ThreadLocal<>();

    @Around("execution(* hello..*(..))")
    public Object trace(ProceedingJoinPoint joinPoint) throws Throwable {
        boolean isRoot = TRACE_ID.get() == null;

        if (isRoot) {
            TRACE_ID.set(UUID.randomUUID().toString().substring(0, 8));
            DEPTH.set(0);
        }

        String traceId = TRACE_ID.get();
        int depth = DEPTH.get();
        String indent = "  ".repeat(depth);

        log.info("[{}] {}>>> {}", traceId, indent, joinPoint.getSignature().toShortString());
        DEPTH.set(depth + 1);

        try {
            Object result = joinPoint.proceed();
            log.info("[{}] {}<<< {} (완료)", traceId, indent, joinPoint.getSignature().toShortString());
            return result;
        } catch (Exception e) {
            log.error("[{}] {}!!! {} 예외: {}", traceId, indent, joinPoint.getSignature().toShortString(), e.getMessage());
            throw e;
        } finally {
            DEPTH.set(depth);
            if (isRoot) {
                TRACE_ID.remove(); // 루트에서만 정리 — 메모리 누수 방지
                DEPTH.remove();
            }
        }
    }
}

출력 예시:

[a1b2c3d4] >>> OrderController.createOrder(..)
[a1b2c3d4]   >>> OrderService.createOrder(..)
[a1b2c3d4]     >>> OrderRepository.save(..)
[a1b2c3d4]     <<< OrderRepository.save(..) (완료)
[a1b2c3d4]   <<< OrderService.createOrder(..) (완료)
[a1b2c3d4] <<< OrderController.createOrder(..) (완료)

ThreadLocal을 쓰는 이유: 여러 요청이 동시에 들어올 때 각 요청의 traceId와 depth가 서로 섞이지 않기 위해서입니다. 각 스레드는 자신만의 ThreadLocal 값을 가집니다. 반드시 루트에서 remove()를 호출해야 합니다. 안 하면 스레드 풀에서 스레드가 재사용될 때 이전 요청의 traceId가 남아있게 됩니다.


11. AOP 내부 호출 문제 — 가장 흔하고 치명적인 실수

11.1 문제 상황 — 왜 this.method() 호출이 위험한가

비유: 국회의원(핵심 로직)과 경호원(프록시)이 있다. 외부에서 “국회의원 만나고 싶다”고 하면 경호원이 먼저 받고(AOP 적용), 확인 후 국회의원에게 연결해준다. 하지만 국회의원이 자기 비서에게 직접 전화할 때는 경호원을 거치지 않는다. 내부 호출은 프록시를 우회한다.

@Service
public class CallService {

    public void external() {
        log.info("external 호출");
        internal(); // this.internal() 와 동일 — 프록시를 거치지 않음!
    }

    @Transactional // 외부에서 직접 호출할 때만 적용됨
    public void internal() {
        log.info("internal 호출");
        orderRepository.save(order); // 트랜잭션 없이 실행됨!
    }
}
sequenceDiagram
    participant C as Client
    participant P as CallService Proxy
    participant T as CallService (원본)

    C->>P: external() 호출
    P->>T: external() 위임 (AOP 없음, external은 @Transactional 없음)
    T->>T: this.internal() 직접 호출
    Note over T: 프록시를 거치지 않음!
    Note over T: @Transactional 완전히 무시됨!
    Note over T: DB 오류 발생 시 롤백 안 됨

실제로 이런 장애가 발생한다: external()이 여러 단계를 거쳐 internal()을 호출하는 구조에서, internal()@Transactional이 있다고 믿고 예외 처리를 생략했습니다. 실제로는 트랜잭션이 없어서 중간에 예외가 나도 이미 저장된 데이터는 롤백되지 않습니다. 데이터 정합성이 깨집니다. 이 버그는 단일 메서드 단위 테스트에서 절대 발견되지 않습니다.

11.2 해결 방법 — 별도 클래스로 분리 (권장)

@Service
public class ExternalService {

    private final InternalService internalService; // 별도 빈

    public void external() {
        internalService.internal(); // 다른 빈이므로 프록시 통과!
    }
}

@Service
public class InternalService {

    @Transactional // 정상 적용됨 — 외부에서 프록시를 통해 호출되므로
    public void internal() {
        orderRepository.save(order);
    }
}

클래스를 분리하면 internalService는 Spring이 관리하는 프록시 빈입니다. external()internalService.internal()을 호출할 때 프록시를 통과하므로 @Transactional이 정상 적용됩니다.

면접 포인트

Q: @Transactional 내부 호출 문제가 왜 발생하나요?

Spring AOP는 프록시 기반입니다. 외부에서 빈을 호출할 때는 Spring이 만든 프록시가 요청을 가로채지만, 같은 클래스 내부에서 this.method()를 호출하면 원본 객체의 메서드가 직접 호출됩니다. 프록시를 거치지 않으므로 @Transactional, @Cacheable 같은 AOP 기반 어노테이션이 무시됩니다. 해결책은 해당 메서드를 별도 빈으로 분리하는 것입니다.


12. Spring AOP vs AspectJ — 언제 어느 것을 쓸까

특성 Spring AOP AspectJ
위빙 시점 런타임 (프록시 기반) 컴파일 / 로드타임 / 런타임
적용 범위 Spring Bean 메서드만 모든 Java 코드 (생성자, 필드 등)
성능 프록시 오버헤드 더 빠름 (바이트코드 직접 수정)
설정 간단 (@Aspect만으로 가능) 복잡 (별도 컴파일러 필요)
실무 사용 대부분의 경우 극히 드문 특수 상황

실무에서는 Spring AOP로 99% 해결됩니다. @Transactional, @Cacheable, @PreAuthorize는 모두 Spring AOP로 구현되어 있습니다. AspectJ가 필요한 경우는 Spring 빈이 아닌 도메인 객체(예: new Order())에 AOP를 적용해야 하는 상황뿐입니다.


13. 전체 흐름 정리

flowchart TD
    A["Spring Boot 시작"] --> B["@Aspect 클래스 스캔\n@Component로 빈 등록"]
    B --> C["AnnotationAwareAspectJAutoProxyCreator 등록\n(BeanPostProcessor 구현체)"]
    C --> D["모든 빈 생성 시작"]
    D --> E["개별 빈 생성 및 의존관계 주입"]
    E --> F["postProcessAfterInitialization() 호출"]
    F --> G{"등록된 Pointcut과\n매칭되는 Advisor 있음?"}
    G -->|"있음"| H["1️⃣ ProxyFactory 생성\n2️⃣ Advisor 등록\n3️⃣ 프록시 생성 (JDK or CGLIB)"]
    G -->|"없음"| I["원본 빈 그대로 등록"]
    H --> J["프록시 빈을 ApplicationContext에 등록"]
    I --> J
    J --> K["클라이언트 호출"]
    K --> L["1️⃣ 프록시가 요청 가로챔\n2️⃣ Advice 실행 (Before)\n3️⃣ 실제 메서드 실행\n4️⃣ Advice 실행 (After)\n5️⃣ 결과 반환"]

    classDef green fill:#d4edda,stroke:#28a745
    classDef blue fill:#cce5ff,stroke:#007bff
    classDef orange fill:#fff3cd,stroke:#ffc107
    class H green
    class C,F blue
    class L orange

14. 핵심 포인트 정리

개념 역할 만약 이걸 잘못 쓰면?
AOP 횡단 관심사 분리 100개 파일에 중복 코드
CGLIB 클래스 상속 기반 프록시 final 메서드에 @Transactional 무시
BeanPostProcessor 빈 생성 후 프록시로 교체 없으면 @Aspect 자동 등록 불가
@Around 실행 전/후 모두 제어 proceed() 미호출 시 비즈니스 로직 건너뜀
내부 호출 this.method() 프록시 우회 @Transactional 롤백 안 됨, 데이터 정합성 깨짐
ThreadLocal 스레드별 독립 저장소 미정리 시 스레드 풀 오염

카테고리:

업데이트:

댓글