야생 속의 마법: Spring, Hibernate, Mockito와 같은 Java 거인들이 Dynamic Proxies를 사용하는 방법
Source: Dev.to
시리즈 Java Proxies Unmasked의 두 번째 편에 오신 것을 환영합니다.
이전 글에서는 고전적인 Proxy 패턴에 대해 배우고, static 프록시를 구현했으며, 그것이 왜 확장성이 없는지 살펴보았습니다. 그 후 dynamic proxies를 소개했는데, 이는 런타임에 프록시를 생성하고 static 프록시의 N + 1 문제를 해결합니다.
@Transactional Onion (Spring)
Spring Framework에서 프록시를 가장 흔히 마주치는 곳은 @Transactional 어노테이션입니다.
- 우리에게는 한 줄의 코드일 뿐입니다.
- Spring에게는 빈을 복잡한 “프록시 어니언” 으로 감싸라는 신호입니다.
@Transactional이 붙은 메서드를 호출하면, 실제 코드를 직접 호출하는 것이 아니라 데이터베이스 연결의 라이프사이클을 관리하는 프록시를 호출하게 됩니다.
프록시가 생성되는 과정
-
Bean Post‑Processor – Context Refresh 단계에서 Spring의
BeanPostProcessor가 여러분의 클래스를 여러 단계에 걸쳐 변환합니다. -
InfrastructureAdvisorAutoProxyCreator – Spring이 빈을 스캔하면서
@Transactional이 붙은 클래스를 찾으면, 이 컴포넌트는 다음과 같이 질문합니다:이 빈에 “포인트컷” 매치가 있나요? (예:
@Transactional이 붙어 있는가?)
어떤 “어드바이스”를 적용해야 할까요? (이 경우TransactionInterceptor.)
생성 단계 (래퍼)
Spring은 ProxyFactory를 사용합니다. 설정과 클래스 구조에 따라 두 가지 경로 중 하나를 선택합니다:
| 프록시 유형 | 사용되는 경우 |
|---|---|
| JDK Dynamic Proxy | 서비스가 인터페이스를 구현하고 있을 때 (예: UserServiceImpl implements UserService). Spring은 java.lang.reflect.Proxy를 이용해 프록시를 생성합니다. |
| CGLIB Proxy | 서비스가 인터페이스 없이 구체 클래스일 때. Spring은 런타임에 서브클래스를 생성하기 위해 CGLIB를 사용합니다. |
Note: JDK Dynamic Proxy에 대해서는 다음 포스트에서 다룰 예정입니다. CGLIB(구식 기술)는 최신 Spring 버전에서 ByteBuddy로 대체되고 있습니다.
클라이언트가 서비스를 호출하면 프록시의 invoke()(JDK) 혹은 intercept()(CGLIB) 메서드가 실행됩니다:
// Pseudo‑code for a JDK dynamic proxy
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 1. TransactionAspectSupport.invokeWithinTransaction(...)
// 2. Execute the target method
// 3. Commit / rollback as needed
}
비밀 소스: TransactionAspectSupport
실제 “엔진룸”은 TransactionAspectSupport.java에 있으며, 특히 invokeWithinTransaction 메서드 안에 여러분의 코드를 감싸는 try‑catch 블록이 존재합니다.
이 때문에 self‑invocation이 동작하지 않습니다. UserService 내부에서 this.otherMethod()와 같이 다른 @Transactional 메서드를 호출하면, 호출이 프록시를 거치지 않으므로 트랜잭션 로직이 적용되지 않습니다.
지연 로딩 (Hibernate)
Hibernate는 프록시를 사용하여 대규모 성능 문제인 지연 로딩을 해결합니다.
예를 들어 User 엔티티에 List<Order>가 있다고 가정해 보세요. 사용자를 조회할 때마다 모든 주문을 로드하면 데이터베이스에 큰 부담이 됩니다. Hibernate는 @OneToMany와 @ManyToMany 연관 관계에 대해 기본적으로 지연 로딩을 적용하며, 접근할 때만 데이터를 가져오는 빈 객체를 반환합니다.
작동 방식
- Hibernate는 엔티티 클래스를 상속하는 프록시를 반환합니다.
- 이 프록시는 ByteBuddy로 생성되며,
Order클래스의 경우Order$HibernateProxy$Q4X0SwcL와 같은 이름의 클래스를 볼 수 있습니다.
생성된 서브클래스에 포함되는 내용
LazyInitializer필드 – 엔티티 ID,Session에 대한 참조, 그리고 지연 초기화 로직을 저장합니다.- 처음 접근될 때 SQL 쿼리를 트리거하고, 이후 결과를 캐시하는 오버라이드된 접근자 메서드들.
프록시에서 getter를 호출하면 ByteBuddy가 호출을 가로채어 데이터가 로드되었는지 확인하고, 로드되지 않은 경우 SQL 쿼리를 실행하여 객체를 채웁니다.
핵심 비법: ByteBuddyProxyFactory
Hibernate 소스에 있는 팩토리 클래스 ByteBuddyProxyFactory.java는 ByteBuddy가 빈 서브클래스를 어떻게 생성하고 getter/setter를 LazyInitializer에 연결할지 구성합니다.
Mockito 스파이
Mockito 스파이는 실제 객체를 감싸는 역할을 합니다. 동작이 전혀 없는 목(mock)과 달리, 스파이는 명시적으로 스텁하지 않는 한 메서드 호출을 기본 객체에 위임합니다.
List list = new ArrayList<>();
List spyList = spy(list); // 실제 리스트를 감싸는 프록시 생성
// 프록시가 가로채지만 실제 메서드에 위임
spyList.size(); // → 0
// 프록시를 스텁하여 커스텀 값 반환
doReturn(100).when(spyList).size();
spyList.size(); // → 100
Mockito가 스파이를 생성하는 방법
Mockito.spy(myObject)는 ByteBuddy를 사용해 런타임에 서브클래스를 생성합니다(예: PaymentService$MockitoMock$cG76HxJ8v). 생성된 클래스는 실제 객체의 상태를 복사하여, 프록시가 호출을 위임하면서도 가로채고 스텁할 수 있게 합니다.
TL;DR
| 프레임워크 | 프록시를 사용하는 이유 | 프록시 기술 |
|---|---|---|
Spring (@Transactional) | 트랜잭션 경계를 투명하게 관리 | JDK 동적 프록시 또는 CGLIB (ProxyFactory에 의해 선택됨) |
| Hibernate | 불필요한 DB 접근을 피하기 위해 연관 관계를 지연 로드 | ByteBuddy가 생성한 서브클래스 ($HibernateProxy$…) |
| Mockito | 테스트 더블을 제공하여 위임하거나 스텁 동작을 수행 | ByteBuddy가 생성한 서브클래스 ($MockitoMock$…) |
MockMethodInterceptor
Mockito가 생성한 서브클래스의 모든 메서드는 단일 디스패처인 MockMethodInterceptor를 호출하도록 오버라이드됩니다. 이 인터셉터는 모든 스터빙 지시(예: when(...).thenReturn(...))를 저장하는 레지스트리를 보유합니다.
spy의 메서드를 호출하면 프록시가 다음을 결정합니다:
“실제 코드를 실행할까, 아니면 미리 정의된 답변을 반환할까?”
when() vs. doReturn()
-
다음과 같이 사용하면
when(spy.get(0)).thenReturn("foo");스텁을 설정하기 전에 실제
get(0)메서드가 한 번 호출됩니다. 빈 리스트에서는IndexOutOfBoundsException이 발생할 수 있습니다. -
다음과 같이 사용하면
doReturn("foo").when(spy).get(0);프록시 설정에 직접 접근하므로 실제 메서드는 전혀 호출되지 않습니다.
The Secret Sauce: InvocationContainer
InvocationContainerImpl.java는 프록시로 호출된 모든 메서드 호출을 저장하여, 이후에 다음과 같은 검증을 가능하게 합니다:
verify(spy).add(anyString());
앞으로
우리는 Spring, Hibernate, 그리고 Mockito 프록시 뒤에 숨은 마법을 밝혀냈습니다. 다음 포스트에서는 표준 JDK만을 사용하여 최초의 동적 프록시를 만들 것입니다—외부 라이브러리 없이, Maven 의존성 없이—순수 Java만으로.
파트 3: 네이티브 방식은 InvocationHandler를 작성하는 과정을 살펴볼 것입니다. IDE를 준비하세요!