인터페이스만 주면 그 구현체를 실행 중에 만들어 내는 JDK 표준 기능.
interface OrderService { void save(Order o); }
OrderService proxy = (OrderService) Proxy.newProxyInstance(
OrderService.class.getClassLoader(),
new Class<?>[]{ OrderService.class },
(p, method, args) -> {
long t0 = System.nanoTime();
try {
return method.invoke(realTarget, args); // 진짜 대상 호출
} finally {
System.out.printf("%s %.2fms%n", method.getName(),
(System.nanoTime() - t0) / 1e6);
}
});
JVM 이 $Proxy0 같은 이름의 클래스를 그 자리에서 만들어 로딩한다. 호출자는 그냥 OrderService 를 쓰는 줄 안다.
method.invoke앞뒤에 코드를 넣는 것, 그것이 동적 프록시가 하는 일의 전부다. 스프링의@Transactional·@Cacheable, MyBatis 매퍼 인터페이스, RMI 스텁 — 전부 그 앞뒤에 무엇을 넣느냐만 다르다.
두 가지 방식
| JDK 동적 프록시 | CGLIB · ByteBuddy | |
|---|---|---|
| 방식 | 인터페이스를 구현한 클래스를 생성 | 대상 클래스를 상속한 서브클래스를 생성 |
| 필요 조건 | 인터페이스가 있어야 한다 | 없어도 된다 |
| 못 하는 것 | 클래스만 있으면 불가 | final 클래스·메서드, private 메서드 |
| 생성 클래스명 | $Proxy0 | Foo$$SpringCGLIB$$0 |
코틀린 클래스가 기본 final 이라 프록시를 못 만드는 것도 이 표의 오른쪽 칸 때문이다. all-open 플러그인이 필요한 이유가 여기 있다.
밖에서 들어오는 호출만 가로챈다
public void placeAll(List<Order> orders) {
for (Order o : orders) save(o); // this.save() — 프록시를 안 거친다
}
호출자가 프록시를 거쳐 placeAll() 에 들어온 순간 this 는 이미 진짜 객체다. 그 안에서 자기 메서드를 부르면 프록시를 다시 탈 이유가 없다.
이것은 버그가 아니라 원리적 한계다. 프록시는 객체 '바깥' 에 서 있으므로 객체 '안' 에서 일어나는 호출을 볼 방법이 없다. 부가 기능이 조용히 사라지는 이 현상은 설계로 푼다 — 대상 메서드를 다른 객체로 분리하거나, 자기 프록시를 주입받아 부른다.
네트워크 프록시와 무엇이 다른가
발상은 같다 — 진짜 대상 앞에 서서 요청을 가로채고, 호출자는 진짜인 줄 안다. 가로채는 대상이 다를 뿐이다.
- 동적 프록시 — 메서드 호출을 가로챈다. 같은 JVM 안, 힙 위의 객체다
- 리버스 프록시 — 네트워크 요청을 가로챈다. 별도 프로세스이고 다른 머신일 수도 있다
성능
프록시 한 겹은 메서드 호출 하나가 더 붙는 정도다. 다만 method.invoke 는 리플렉션 호출이라 직접 호출보다 비싸고, 프록시가 여러 겹 쌓이면 스택이 깊어져 스택트레이스가 읽기 어려워진다. 그래서 최근에는 빌드 시점에 코드를 생성해 프록시를 대신하는 방식도 늘고 있다.
면접 함정
- ❌ "프록시는 스프링 기능" → JDK 표준(
java.lang.reflect.Proxy)이다. 스프링은 그 위에 얹었다. - ❌ "자기 호출이 안 걸리는 건 스프링 버그" → 프록시의 원리적 한계다.