스프링 AOP의 프록시 동작: JDK, CGLIB, 그리고 ProxyFactoryBean정리 ✨

성종호·2025년 1월 5일
1

공통 기능(AOP)을 핵심 로직에 깔끔하게 적용하고 싶다면, 결국 프록시가 중요한 역할을 합니다.

스프링을 사용하다 보면 AOP(Aspect-Oriented Programming)를 통해 로그, 보안, 트랜잭션 같은 공통 기능을 핵심 비즈니스 로직에서 분리되어 있습니다.
그렇다면 ‘어떻게’ 분리가 가능하냐고 물으신다면, 그 비결은 바로 프록시(Proxy)를 이용하기 때문입니다.
이 글에서는 스프링 AOP가 사용하는 JDK 동적 프록시, CGLIB 프록시, 그리고 그 중심에서 활약하는 InvocationHandler, MethodInterceptor, ProxyFactoryBean이 어떻게 맞물려 돌아가는지 정리해보겠습니다.


1. AOP와 프록시, 간단히 맛보기 🍿

AOP(Aspect-Oriented Programming) 란?

  • 핵심 로직과 상관없는 공통 기능(로깅, 트랜잭션 등)이 자꾸 섞여서 코드가 지저분해지는 걸 방지하기 위한 방법입니다.
  • 어디에(포인트컷), 무엇을(어드바이스) 적용할지 분리해서 관리해 코드 재사용성을 높입니다.
    어디에(포인트컷) +무엇을(어드바이스) => 어디에 + 무엇을(어드바이저)
  • 스프링 AOP는 주로 프록시라는 중간 통로를 통해 이 부가기능을 구현합니다.

프록시(Proxy)가 뭐지? 🤔

  • 진짜 객체를 대신해서 앞단을 지키는, 일종의 대리자 객체입니다.
  • 스프링 AOP에서 프록시가 메서드를 가로채고, 필요하면 공통 기능을 수행한 뒤에(혹은 수행 전에) 실제 타겟 객체 메서드를 호출합니다.

2. JDK 동적 프록시와 CGLIB 프록시 ⚙️

스프링 AOP의 프록시 생성 방식에는 대표적으로 두 가지가 있습니다.

(1) JDK 동적 프록시

  • JDK가 기본 제공하는 방식.
  • 인터페이스가 있는 경우에만 프록시를 만들 수 있음(인터페이스 기반).
  • 메서드 호출 가로채기는 InvocationHandler가 담당.

장점

  1. 가볍고, 구현 클래스가 바뀌어도 인터페이스만 유지되면 손쉽게 대응 가능.
  2. 표준 라이브러리에 포함되어 있어 추가 의존성이 적음.

단점

  1. 인터페이스가 없으면 사용할 수 없음.
  2. 인터페이스에 선언되지 않은 메서드는 프록시가 불가능.

(2) CGLIB 프록시

  • 바이트코드 조작 라이브러리인 CGLIB을 활용.
  • 클래스를 상속받아서 프록시 객체(서브클래스)를 만듦.
  • 메서드 호출 가로채기는 MethodInterceptor가 담당.

장점

  1. 인터페이스가 없어도 OK(그냥 클래스만 있으면 됨).
  2. 클래스의 모든 메서드를 자유롭게 프록시할 수 있음.

단점

  1. final로 선언된 클래스나 메서드는 상속 불가 → 프록시 불가능.
  2. 클래스 상속 방식을 쓰므로 JDK 동적 프록시에 비해 다소 무겁다(메모리나 퍼포먼스 측면).

3. InvocationHandler & MethodInterceptor의 역할 🏗️

스프링 AOP에서는 내부적으로 JDK 동적 프록시를 쓸 땐 InvocationHandler를, CGLIB를 쓸 땐 MethodInterceptor를 통해 메서드를 가로챕니다.
두 녀석 다 메서드가 호출됐을 때 무슨 작업(어드바이스, 로직)을 어떻게 껴넣을지 를 담당합니다

(1) InvocationHandler

public class JdkDynamicAopProxy implements InvocationHandler {
    private final AdvisedSupport advised; 

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        List<MethodInterceptor> chain = advised.getInterceptorsAndDynamicInterceptionAdvice(method);

        if (chain.isEmpty()) {
            // 적용할 어드바이스가 없다면 바로 타겟 메서드 호출
            return method.invoke(advised.getTargetSource().getTarget(), args);
        } else {
            // 어드바이스가 있다면 순서대로 실행
            MethodInvocation invocation = new ReflectiveMethodInvocation(
                advised.getTargetSource().getTarget(), method, args, chain);
            return invocation.proceed();
        }
    }
}
  • JDK 동적 프록시에서 핵심.
  • 모든 메서드 호출이 이 invoke로 몰려옴 → 어떤 어드바이스를 적용할지 판단하고, 타겟 메서드 실행 여부를 조정합니다.

(2) MethodInterceptor

public class MyMethodInterceptor implements MethodInterceptor {
    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        System.out.println("Before " + method.getName());

        Object result = proxy.invokeSuper(obj, args);

        System.out.println("After " + method.getName());
        return result;
    }
}
  • CGLIB에서 사용하는 인터페이스.
  • 메서드 실행 전후, 혹은 예외 발생 시점에도 필요한 로직(예: 로깅, 트랜잭션)을 끼워넣을 수 있습니다.

4. ProxyFactoryBean: “프록시 생성, 내가 다 해줄게!” 🤖

스프링이 제공하는 ProxyFactoryBean은 프록시 설정을 간단하게 해주는 공장 같은 클래스입니다.

  • setTarget(타겟 객체 지정)
  • addAdvice(어드바이스 추가)
  • setProxyTargetClass(true = CGLIB, false = JDK 동적 프록시)

간단 예시

@Configuration
public class AppConfig {

    @Bean
    public ProxyFactoryBean myServiceProxy() {
        ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
        proxyFactoryBean.setTarget(myServiceImpl()); 
        proxyFactoryBean.addAdvice(new LoggingInterceptor()); 
        proxyFactoryBean.setProxyTargetClass(false); // JDK 동적 프록시
        return proxyFactoryBean;
    }

    @Bean
    public MyServiceImpl myServiceImpl() {
        return new MyServiceImpl();
    }
}

설정 끝나면, myServiceProxy Bean을 주입받았을 때 사실은 프록시 객체가 들어오게 됩니다.


5. 메서드 호출 흐름: 프록시가 중간에서 껴들 때 🌀

(1) 단일 프록시

  1. 클라이언트가 프록시 객체의 메서드를 호출.
  2. 프록시는 InvocationHandler/MethodInterceptor에게 위임 하고 넘김.
  3. 어드바이스(부가 기능) 체인 확인.
    • 비어있으면? 바로 타겟 메서드 실행.
    • 있으면? 순서대로 실행 후 마지막에 타겟 메서드 호출.
  4. 결과를 클라이언트에게 반환.

(2) 중첩된(프록시 안에 프록시) 경우

  1. 클라이언트 → 외부 프록시 메서드 호출.
  2. 외부 프록시에서 어드바이스 처리 후, 내부 프록시로 메서드 위임.
  3. 내부 프록시도 자기 어드바이스 처리 후, 실제 타겟 메서드 호출.
  4. 결과는 내부 → 외부 → 클라이언트 순으로 전달.

6. “왜 어드바이스가 안 먹힐까?” 체인이 비어 있는 경우 🤷‍♀️

아무리 프록시가 있어도, 포인트컷(적용 지점)에서 벗어나면 어드바이스가 동작하지 않습니다. 즉, MethodInterceptor 체인이 비어있는 경우에는 타겟 메서드가 바로 호출돼버립니다.

  1. 포인트컷이 매칭되지 않는 메서드 → 어드바이스 미적용.
  2. 애초에 어드바이저(Aspect)를 안 붙인 빈 → 당연히 어드바이스 없음.
  3. ProxyFactoryBean 썼지만 addAdvice()를 안 해놨다면? → 어드바이스가 없으니 바로 타겟 호출.

7. 실제 코드 예시

(1) 서비스 인터페이스 & 구현

public interface MyService {
    void performTask();    // AOP 적용 대상
    void anotherTask();    // AOP 미적용 가능
}

@Service
public class MyServiceImpl implements MyService {
    @Override
    public void performTask() {
        System.out.println("Performing Task");
    }

    @Override
    public void anotherTask() {
        System.out.println("Executing Another Task");
    }
}

(2) Aspect 설정

@Aspect
@Component
public class LoggingAspect {
    @Before("execution(* com.example.MyService.performTask(..))")
    public void logBeforePerformTask(JoinPoint joinPoint) {
        System.out.println("Before performTask");
    }
}

(3) ProxyFactoryBean 설정

@Configuration
@EnableAspectJAutoProxy
public class AppConfig {

    @Bean
    public ProxyFactoryBean myServiceProxy() {
        ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
        proxyFactoryBean.setTarget(myServiceImpl());
        // 간단한 Before Advice
        proxyFactoryBean.addAdvice((MethodBeforeAdvice) (method, args, target) -> {
            System.out.println("Before method: " + method.getName());
        });
        proxyFactoryBean.setProxyTargetClass(false); // JDK 동적 프록시
        return proxyFactoryBean;
    }

    @Bean
    public MyServiceImpl myServiceImpl() {
        return new MyServiceImpl();
    }
}

(4) 실제 실행

@SpringBootApplication
public class AopDemoApplication implements CommandLineRunner {

    @Autowired
    private MyService myService;

    public static void main(String[] args) {
        SpringApplication.run(AopDemoApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        myService.performTask();   // 어드바이스 적용
        myService.anotherTask();   // 어드바이스 미적용
    }
}

실행 결과(콘솔 출력):

Before performTask
Before method: performTask
Performing Task
Executing Another Task
  • performTask()에서는 포인트컷에 매칭되고, 프록시가 MethodBeforeAdvice를 실행 후 실제 메서드를 호출합니다.
  • anotherTask()는 포인트컷 매칭이 안 되므로, 어드바이스 체인이 비어있어 바로 실행됩니다.

결론 🚀

  • 스프링 AOP프록시를 통해 메서드 호출을 가로채고, 그 사이에 우리가 원하는 기능을 추가(어드바이스)해줍니다.
  • JDK 동적 프록시 / CGLIB 중 어느 방식을 쓸지는 스프링이 자동으로 결정하기도 하고, @EnableAspectJAutoProxy(proxyTargetClass = true)로 강제도 가능합니다.
  • InvocationHandlerMethodInterceptor는 각각 JDK / CGLIB 방식에서 부가 기능을 실제로 수행하는 핵심 인터페이스입니다.
  • ProxyFactoryBean은 개발자가 직접 프록시 생성 과정을 일일이 코딩하지 않아도, 간편한 설정으로 프록시를 만들어주는 고마운 도구입니다.
  • 프록시가 있다고 해서 모든 메서드에 무조건 어드바이스가 붙는 건 아니니, 포인트컷 매칭 여부나 어드바이스 설정을 꼭 확인해야 합니다.

마무리 🏁

AOP로 로깅, 보안, 트랜잭션 같은 공통 기능을 한 곳에서 관리하고, 핵심 로직을 깔끔하게 유지할 수 있는 이유에는 오늘 살펴본 프록시와 InvocationHandler/MethodInterceptor가 핵심적인 역할을 맡고 있기 때문입니다.

궁금한 점이나 피드백이 있으면 언제든 댓글로 달아주세요!


참고

profile
아자

1개의 댓글

comment-user-thumbnail
2025년 1월 5일

잘 읽었습니다~~

답글 달기

관련 채용 정보