- 프록시(Proxy)와 Spring AOP
- 프록시(Proxy)란?
- Spring AOP와 프록시(Proxy)
- JDK Proxy
- CGLib
- 프록시 생성 시 주의점
- 참고자료
프록시 객체는 원래 객체를 감싸고 있는 객체로, 프록시 객체가 원래 객체를 감싸서 클라이언트의 요청을 처리하게 하는 패턴이다.
프록시 객체를 사용해 접근 제어 및 부가 기능을 추가할 수 있다.
프록시 코드를 직접 작성해 구현할 수도 있지만, 이는 중복 코드 증가, 유지보수의 어려움, 확장성 부족 등의 문제로 이어질 수 있다. 이를 해결하기 위해 Spring에서는 AOP를 통해 런타임 시 동적으로 프록시 객체를 생성할 수 있다.
Spring에서는 크게 두 가지 종류의 프록시 구현체를 사용하는데, JDK Proxy(또는 Dynamic Proxy) 와 CGLib이다. AOP에서는 이것을 기반으로 IoC 컨테이너에 의해 AOP를 할 수 있는 Proxy Bean을 제공한다.
Spring AOP는 런타임에서 특정 호출 시점에 IoC 컨테이너에 AOP를 할 수 있는 Proxy Bean을 등록한다.(일반적인 빈과 다르게 빌드 시에 IoC 컨테이너에 등록되는 것이 아니다.)
동적으로 생성된 Proxy Bean은 타깃의 메서드가 호출되는 시점에 부가기능을 추가할 메서드를 자체적으로 판단해 이를 가로채고 부가기능을 주입한다. 이를 런타임 위빙(Runtime Weaving)이라고 한다.
그렇다면 Spring에서는 JDK Proxy와 CGLib중 어떤 방식을 어떤 기준으로 구분해서 사용할까?
정답은 interface의 유무이다.

JDK Proxy가 Proxy 객체를 생성하는 방식은 위와 같다.
타겟의 인터페이스를 검증해 ProxyFactory에 의해 타겟의 인터페이스를 상속한 Proxy객체를 생성하고, Proxy 객체에 InvocationHandler를 포함시켜서 하나의 객체로 반환한다.
프록시 빈을 주입하기 위해서는, 반드시 타입을 인터페이스로 선언해야 한다.
과거에는 JDK 동적 프록시를 사용하는 것이 일반적이었기에, 서비스 클래스를 인터페이스와 구현 클래스로 분리하여 ServiceImpl을 만드는 패턴이 사용되었다. 그러나 Spring Boot 2.0 이후로 CGLib 방식이 기본으로 변경되었기에 이렇게 구현하지 않아도 된다.
사용자의 요청이 Proxy를 통해 호출될 때, 내부의 invoke 메서드를 통해 주입된 타겟 객체에 대한 검증이 이루어진다. 아래는 GPT를 통해 생성한 예시 코드이다.
@Override
public Object invoke(Object proxy, Method proxyMethod, Object[] args) throws Throwable {
Method targetMethod = null;
// 주입된 타깃 객체에 대한 검증 코드 (메서드 캐싱 및 확인)
if (!cachedMethodMap.containsKey(proxyMethod)) {
try {
// 타깃 객체의 메서드 찾기
targetMethod = target.getClass().getMethod(proxyMethod.getName(), proxyMethod.getParameterTypes());
cachedMethodMap.put(proxyMethod, targetMethod); // 메서드 캐싱
} catch (NoSuchMethodException e) {
throw new RuntimeException("Method not found in target object: " + proxyMethod.getName());
}
} else {
targetMethod = cachedMethodMap.get(proxyMethod); // 캐시된 메서드 사용
}
// 타깃 객체의 메서드 실행
return targetMethod.invoke(target, args);
}
invoke의 return을 보면, targetMethod.invoke()를 호출하고 있는데, 이것은 리플렉션을 통해 타겟 객체의 메서드를 호출하기 위함이다. 리플렉션을 사용하기 때문에 여기서 성능 오버헤드가 발생할 수 있다.

CGLib는 클래스의 바이트 코드를 조작해 Proxy 객체를 생성해주는 라이브러리이다.
CGLib은 원본 클래스를 상속받는 자식 클래스를 동적으로 생성하며, 생성된 자식 클래스에서 모든 메서드를 오버라이드하고, AOP 인터셉터 로직을 삽입한다. 이후 Enhancer 클래스를 사용하여 실제 프록시 객체를 생성한다.
이후 MethodProxy.invokeSuper()를 호출하여 원본 메서드를 실행한다.
class MyMethodInterceptor implements MethodInterceptor {
private final Object target;
public MyMethodInterceptor(Object target) {
this.target = target;
}
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("Before method execution");
// 리플렉션을 사용하여 타깃 메서드 호출
Object result = proxy.invokeSuper(obj, args);
System.out.println("After method execution");
return result;
}
}
public class Main {
public static void main(String[] args) {
// 타깃 객체 생성
MyService myService = new MyService();
// CGLib Enhancer를 사용하여 프록시 객체 생성
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MyService.class); // 타깃 클래스 설정
enhancer.setCallback(new MyMethodInterceptor(myService)); // 메서드 인터셉터 설정
// 프록시 객체 생성
MyService proxy = (MyService) enhancer.create();
// 프록시 메서드 호출
proxy.doSomething();
proxy.doSomethingElse();
}
}
위 코드는 GPT를 통해 생성한 예제 코드이다. 실제로는 AOP를 통해 자동 구현되므로 직접 생성할 필요는 없다.
JDK Proxy와 다른 점이라면, invokeSuper() 메서드를 호출하고 있다는 점이다.
invoke() 메서드 호출 시 메서드 이름과 파라미터 타입을 동적으로 확인하고, 해당 메서드를 찾아서 호출하는 과정에서 오버헤드가 발생한다. 특히, 이 과정에서 타입 검사와 메서드 검색을 리플렉션이 런타임에 처리해야 하므로 속도에 영향을 미칠 수 있다.
그러나 CGLib은 invokeSuper()를 호출하면 타깃 클래스의 메서드를 직접 호출할 수 있다. 원본 클래스를 상속받고, 바이트코드를 직접 조작해 프록시 객체를 생성하기 때문이다. 따라서 리플렉션을 통해 메서드를 찾는 오버헤드가 발생하지 않는다. 따라서 성능이 더 뛰어나다.
- CGLib의 바이트코드 생성 과정
- Enhancer 객체 생성: Enhancer는 타깃 클래스를 상속해 동적 바이트코드를 생성 및 로드하고 프록시 클래스를 생성
- setSuperclass() 메서드: 프록시 객체가 상속할 타깃 클래스를 지정
- setCallback() 메서드: 타깃 클래스의 메서드를 가로챌 MethodInterceptor 또는 Callback을 지정
- create() 메서드: 이 메서드가 호출되면 CGLib은 바이트코드를 생성하여 새로운 클래스를 동적으로 생성하고, 해당 클래스를 메모리상에서 로드
다만 CGLib는 상속을 받는다는 점 때문에 final 메서드 또는 클래스에 대해서 재정의를 할 수 없어 이에 대한 프록시는 생성할 수 없다.
기본적으로 두 방식 모두 private 메서드에 대해서는 프록시를 생성할 수 없다. 따라서 AOP를 사용하려면, 메서드의 접근 지정자가 private으로 선언되면 안 된다.
또한 메서드 내부 호출의 경우에도 프록시를 생성할 수 없다. 프록시는 객체를 통한 호출에 대해서만 동작하며, 같은 클래스 내부에서 메서드를 호출하는 경우, 프록시 객체를 통한 호출이 아니라 자기 자신을 호출하는 것이기 때문에 프록시가 적용되지 않는다.
public class MyService {
public void a() {
System.out.println("Method A");
b(); // 같은 클래스 내에서 b() 메서드를 호출
}
@Transactional // a() 메서드 호출 시 이 어노테이션은 적용되지 않음
public void b() {
System.out.println("Method B");
}
}
위 예시 코드와 같이 내부 호출의 경우 프록시를 생성할 수 없다. 따라서 @Transactional, @CircuitBreaker와 같은 어노테이션을 b 메서드에 선언하고 a를 호출한다고 해도, a 메서드 내부에서 b 메서드를 호출할 때 이것이 적용되지 않는다.