Spring으로 백엔드를 개발하다 보면 @Transactional 하나만 붙여도 트랜잭션이 알아서 시작되고 커밋·롤백된다. 어떻게 메서드에 애너테이션 하나 붙였을 뿐인데 그 안에 코드가 자동으로 끼워지는 걸까? 이 질문의 답 밑바닥에는 항상 하나의 구조가 있다 — 프록시(Proxy). AOP도, @Transactional도, 결국은 프록시라는 하나의 디자인 패턴 위에 지어진 집이다. 그래서 AOP를 제대로 이해하려면 프록시부터 짚고 넘어가고자 한다.
클라이언트가 어떤 객체의 기능을 쓰고 싶을 때, 가장 단순한 방법은 그 객체에 직접 접근하는 것이다. 그런데 다음과 같은 상황에서는 "직접 접근"이 곤란해진다.
생성 비용이 큰 객체: 고해상도 이미지, 대용량 데이터 조회 결과처럼 만드는 데 시간이나 자원이 많이 드는 객체를, 정말 필요한 순간까지 만들지 않고 미루고 싶다.
-> 이런 무거운 객체를 필요 여부와 상관없이 전부 미리 만들어서 붙들고 있으면, 실제로는 안 쓰이는 것까지 메모리를 차지한다. 특히 서버 시작 시점처럼 짧은 시간에 여러 개를 한꺼번에 만들면 그 순간 힙 사용량이 급증해 OOM(Out Of Memory)으로 이어질 수 있다.
접근을 통제해야 하는 객체: 아무나 호출하면 안 되고, 권한이 있는 클라이언트만 실제 기능에 닿을 수 있어야 한다.
호출 전후에 뭔가를 더 하고 싶은 경우: 로깅, 캐싱, 트랜잭션 시작/종료처럼 진짜 하려는 일(비즈니스 로직) 앞뒤에 부가적인 작업을 끼우고 싶다.
세 상황의 공통점은 이거다 — 클라이언트의 코드는 그대로 두고 싶다. 클라이언트가 "이 객체는 사실 아직 안 만들어졌어요"라거나 "지금 권한 검사 중이에요"라는 사정을 알 필요는 없다. 클라이언트는 여전히 똑같은 방식으로 호출하는데, 그 호출과 진짜 객체 사이에 누군가 끼어들어 이 사정들을 대신 처리해주면 된다 — 그 "누군가"가 프록시다.
프록시 패턴의 핵심은 클라이언트가 진짜 객체와 대리인을 구분하지 못한다는 데 있다. 이게 가능한 이유는 프록시가 진짜 객체(RealSubject)와 똑같은 인터페이스(Subject)를 구현하기 때문이다.
interface MessageSender {
void send(String message);
}
class SmsMessageSender implements MessageSender {
public void send(String message) {
System.out.println("[SMS 발송] " + message);
}
}
클라이언트는 이렇게 쓴다.
MessageSender sender = new SmsMessageSender();
sender.send("주문이 완료되었습니다");
클라이언트는 MessageSender라는 타입만 보고 코드를 짠다. 이 자리에 SmsMessageSender가 오든, 앞으로 만들 프록시가 오든 클라이언트 코드는 한 글자도 바뀌지 않는다 — 프록시 패턴이 성립하려면 이 조건이 반드시 지켜져야 한다.

가장 단순한 방법은 프록시 클래스를 직접 만드는 것이다. MessageSender를 호출하기 전후로 로그를 남기는 프록시를 만들어보자.
class LoggingMessageSenderProxy implements MessageSender {
private final MessageSender target; // 진짜 객체(RealSubject)에 대한 참조
LoggingMessageSenderProxy(MessageSender target) {
this.target = target;
}
@Override
public void send(String message) {
long start = System.currentTimeMillis();
System.out.println("[로그] send 호출 시작");
target.send(message); // 실제 작업은 진짜 객체에게 위임
long elapsed = System.currentTimeMillis() - start;
System.out.println("[로그] send 호출 종료 - " + elapsed + "ms");
}
}
MessageSender sender = new LoggingMessageSenderProxy(new SmsMessageSender());
sender.send("주문이 완료되었습니다");
// 클라이언트는 여전히 MessageSender 타입만 사용한다 — 프록시가 끼어든 걸 전혀 모른다
이렇게 직접 만든 프록시를 정적 프록시(Static Proxy)라고 부른다. send 메서드가 하나뿐이라 코드가 짧아 보이지만, 실제로는 두 가지 문제가 금방 드러난다.
첫째, 인터페이스에 메서드가 10개면 로깅 코드를 10번 복사해서 각 메서드마다 "시작 로그 → 위임 → 종료 로그"를 반복해서 써야 한다. 둘째, 이 로깅을 OrderService나 PaymentService에도 붙이고 싶다면, 그때마다 LoggingOrderServiceProxy, LoggingPaymentServiceProxy를 새로 만들어야 한다. "로깅"이라는 관심사 하나가 대상 클래스 수만큼 복제되는 것이다.

이 반복을 없애려면 질문을 바꿔야 한다 — "프록시 클래스 자체를 자바가 런타임에 대신 만들어주면 안 될까?" JDK Dynamic Proxy는 정확히 이걸 해준다.
핵심은 InvocationHandler다. 프록시의 어떤 메서드가 호출되든, 그 호출은 전부 InvocationHandler.invoke() 한 곳으로 모인다.
class LoggingInvocationHandler implements InvocationHandler {
private final Object target;
LoggingInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
long start = System.currentTimeMillis();
System.out.println("[로그] " + method.getName() + " 호출 시작");
Object result = method.invoke(target, args); // 리플렉션으로 실제 메서드 호출
long elapsed = System.currentTimeMillis() - start;
System.out.println("[로그] " + method.getName() + " 호출 종료 - " + elapsed + "ms");
return result;
}
}
MessageSender target = new SmsMessageSender();
MessageSender proxy = (MessageSender) Proxy.newProxyInstance(
MessageSender.class.getClassLoader(),
new Class[]{MessageSender.class},
new LoggingInvocationHandler(target)
);
proxy.send("주문이 완료되었습니다");
이제 LoggingMessageSenderProxy라는 클래스를 직접 쓰지 않았다. Proxy.newProxyInstance()가 MessageSender 인터페이스를 구현하는 클래스를 런타임에 즉석에서 만들어내고, 그 인스턴스의 모든 메서드 호출은 LoggingInvocationHandler 하나로 다 처리된다 — 메서드가 몇 개든, 대상 클래스가 몇 개든 새로 만들어야 하는 건 target을 갈아 끼우는 것뿐이다.
다만 Proxy.newProxyInstance가 인터페이스 배열을 받는다는 것 자체가 이 방식의 제약을 말해준다. JDK Dynamic Proxy는 인터페이스 기반이라, 대상 클래스가 구현하는 인터페이스가 있어야만 동작한다. OrderService가 인터페이스 없이 클래스 하나로만 존재한다면, 이 방식으로는 프록시를 만들 수 없다.
인터페이스가 없는 클래스에도 프록시가 필요할 때 쓰는 게 CGLIB다. JDK Dynamic Proxy가 인터페이스를 "구현"하는 방식이라면, CGLIB는 대상 클래스를 "상속"하는 서브클래스를 런타임에 만들어 메서드를 오버라이드하는 방식이다.
class OrderService { // 인터페이스가 없는 클래스
void placeOrder() {
System.out.println("[주문 처리]");
}
}
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OrderService.class);
enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> {
System.out.println("[로그] " + method.getName() + " 호출 시작");
Object result = proxy.invokeSuper(obj, args); // 부모(진짜 객체)의 메서드 호출
System.out.println("[로그] " + method.getName() + " 호출 종료");
return result;
});
OrderService proxy = (OrderService) enhancer.create();
proxy.placeOrder();
이 방식은 상속을 전제로 하기 때문에 명확한 제약이 따라온다. OrderService가 final class라면 애초에 상속이 불가능해 프록시를 만들 수 없다. 같은 이유로 개별 메서드가 final이면 그 메서드만 오버라이드가 안 되는데, 이때는 에러가 나는 게 아니라 프록시를 통한 가로채기가 조용히 무시된다. final 메서드에 @Transactional을 걸면 트랜잭션이 하나도 걸리지 않는데, 컴파일도 되고 실행도 되니 알아채기가 특히 어렵다.
지금까지 만든 건 전부 "호출 전후에 로그를 남기는" 프록시였다. 그런데 프록시가 그 자리에서 하는 일이 로깅이 아니라 다른 것이어도 구조는 완전히 같다. 목적에 따라 이름이 붙을 뿐이다.
LAZY 연관관계를 조회할 때 실제 엔티티 대신 프록시 객체를 돌려주고, 그 프록시의 필드에 처음 접근하는 순간에야 진짜 SELECT 쿼리를 날리는 것과 정확히 같은 구조다.이름은 다섯 가지로 다르지만 구조는 하나다 — 클라이언트는 인터페이스만 보고, 진짜 객체 앞단에서 뭔가(지연·검사·전송·캐싱·기록)를 대신 처리하는 대리인을 세운다.
프록시는 결국 "클라이언트의 코드를 바꾸지 않고, 진짜 객체 앞단에서 뭔가를 더 하거나 걸러내는" 구조다. 그런데 3~5번에서 본 것처럼 이걸 손으로 만들면(정적 프록시) 대상 클래스마다 새 프록시 클래스가 필요하고, 메서드 수만큼 위임 코드가 반복된다. JDK Dynamic Proxy와 CGLIB는 이 반복을 "런타임에 프록시를 자동으로 생성하는" 방식으로 없앴다.
그런데 아직 남은 질문이 있다. InvocationHandler나 MethodInterceptor 안에 "로깅을 할지, 트랜잭션을 걸지, 권한을 검사할지"를 여전히 코드로 직접 써야 한다. 이걸 코드가 아니라 선언적으로 — "이 패키지의 이 메서드들에는 이 부가 기능을 붙여라"는 식으로 지정할 수 있게 만든 것이 AOP(Aspect-Oriented Programming)다. Spring은 이 선언을 보고 내부적으로 JDK Dynamic Proxy와 CGLIB 중 무엇을 쓸지도 알아서 고른다. AOP에 대해서는 다음 글에서 자세하게 다룰 예정이다.