야생 속의 마법: Spring, Hibernate, Mockito와 같은 Java 거인들이 Dynamic Proxies를 사용하는 방법

발행: (2026년 1월 1일 오후 11:59 GMT+9)
9 min read
원문: Dev.to

Source: Dev.to

시리즈 Java Proxies Unmasked의 두 번째 편에 오신 것을 환영합니다.
이전 글에서는 고전적인 Proxy 패턴에 대해 배우고, static 프록시를 구현했으며, 그것이 왜 확장성이 없는지 살펴보았습니다. 그 후 dynamic proxies를 소개했는데, 이는 런타임에 프록시를 생성하고 static 프록시의 N + 1 문제를 해결합니다.

@Transactional Onion (Spring)

Spring Framework에서 프록시를 가장 흔히 마주치는 곳은 @Transactional 어노테이션입니다.

  • 우리에게는 한 줄의 코드일 뿐입니다.
  • Spring에게는 빈을 복잡한 “프록시 어니언” 으로 감싸라는 신호입니다.

@Transactional이 붙은 메서드를 호출하면, 실제 코드를 직접 호출하는 것이 아니라 데이터베이스 연결의 라이프사이클을 관리하는 프록시를 호출하게 됩니다.

프록시가 생성되는 과정

  1. Bean Post‑ProcessorContext Refresh 단계에서 Spring의 BeanPostProcessor가 여러분의 클래스를 여러 단계에 걸쳐 변환합니다.

  2. 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를 준비하세요!

Back to Blog

관련 글

더 보기 »

Spring Cloud Gateway: 기본 예제

Spring Cloud Gateway: Basic Example에 대한 표지 이미지 https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fd...