본문으로 건너뛰기

8.3 CGLIB vs JDK Dynamic Proxy 동작 원리

스프링 AOP는 코드를 컴파일할 때 바이트코드를 직접 뜯어고쳐 조작하는 무거운 방식(AspectJ 네이티브 위버)이 아닌, 런타임 환경에서 프록시(Proxy, 대리자) 객체를 생성하여 타겟을 동적으로 감싸는 프록시 기반 AOP 를 사용합니다.

이 프록시를 메모리에 생성하는 방식에는 기술적으로 두 가지 구현체가 대립합니다. 이번 장에서는 JDK Dynamic Proxy와 CGLIB의 차이점과 동작 원리를 완벽하게 이해해봅시다.


🎭 1. 프록시(Proxy)란 무엇인가?​

프록시(Proxy)는 '대리인'이라는 뜻입니다. 클라이언트가 실제 객체(Target)를 직접 호출하는 대신, 프록시 객체가 중간에 개입하여 호출을 가로챕니다(Intercept). 이를 통해 원래 비즈니스 로직을 전혀 건드리지 않고도 앞뒤로 트랜잭션 시작/종료, 실행 시간 측정, 권한 체크 등의 부가 기능(공통 로직) 을 덧붙일 수 있습니다.

// 실제 객체 (핵심 비즈니스만 담겨 있음)
public class OrderService {
public void order() {
System.out.println("주문 로직 파비박 실행 완료!");
}
}

// 프록시 객체 (스프링이 메모리에 가짜로 만들어 덮어씌움)
public class OrderServiceProxy extends OrderService {
private final OrderService target = new OrderService();

// 오버라이딩하여 가로채기
@Override
public void order() {
System.out.println("[프록시] 트랜잭션 락 온! DB 열었다."); // 부가 로직 전처리
target.order(); // 타겟 객체로 위임 바통 터치
System.out.println("[프록시] 트랜잭션 락 오프! 완전 커밋."); // 부가 로직 후처리
}
}

스프링 프레임워크는 이러한 프록시 클래스를 개발자가 직접 만들지 않도록, 애플리케이션 실행 시점(런타임)에 메모리 상에 동적으로 프록시(가짜 빈)를 찍어냅니다. 이를 생성하는 기술이 바로 JDK Dynamic Proxy와 CGLIB입니다.


☕ 2. JDK Dynamic Proxy​

자바 언어에 기본으로 내장된 리플렉션(Reflection) API의 java.lang.reflect.Proxy를 사용하여 순수하게 프록시 객체를 생성하는 기술입니다.

특징 및 제약 사항​

  • 인터페이스(Interface) 필수: 타겟 객체가 반드시 1개 이상의 인터페이스를 구현(implements) 하고 있을 때만 작동합니다.
  • 단점: 구체 클래스(구현체) 타입으로는 빈 주입(@Autowired) 시 캐스팅(형변환) 에러를 뱉습니다. (BeanNotOfRequiredTypeException 터짐)
// JDK 방식은 이런 껍데기 인터페이스가 반드시 필수
public interface MemberService { void join(); }

// 타겟
@Service
public class MemberServiceImpl implements MemberService {
@Override public void join() { ... }
}

// 실패! JDK 프록시는 인터페이스 타입으로만 주입 가능하므로 에러 터짐
@Autowired MemberServiceImpl memberService;

🧬 3. CGLIB (Code Generation Library)​

인터페이스 스펙이 없는 일반 구체 클래스라도 런타임에 프록시를 생성할 수 있도록 돕는 바이트코드 조작 라이브러리입니다. (스프링 코어에 기본 번들링 되어있음)

특징 및 제약 사항​

  • 상속(Inheritance) 기반: 타겟 클래스를 상속(extends) 하여 메서드를 덮어쓰기(Overriding)하는 방식으로 가짜 자식 클래스를 만듭니다.
  • 장점: 불편하게 인터페이스를 따로 만들 필요 없이 아무 클래스나 즉시 프록시 얍!
  • 치명적 단점 (final 제약): 타겟 클래스가 닫힌 final 클래스이거나, 메서드가 private 또는 final이면 자바 문법상 상속 오버라이딩이 물리적으로 불가능하므로 프록시 AOP가 처참하게 고장납니다.
@Service
public class NoticeService { // 인터페이스 안 만들고 단독 배포! (CGLIB은 가능)
@Transactional
public void notice() { ... }

@Transactional
// 💥 치명적 에러: private 메서드는 자식이 물려받지(오버라이드) 못하므로 프록시가 먹히지 않음.
// 따라서 이 메서드에 트랜잭션 선언은 장식용일 뿐, 적용 자체가 안 됩니다.
private void internalNotice() { ... }
}

🏆 4. Spring Boot의 영리한 기본 전략 방침​

과거에는 "인터페이스 있으면 JDK Proxy 돌리고 없으면 CGLIB 돌린다"가 국룰이었지만, 개발자들이 캐스팅 에러 폭격에 고생했습니다.

이에 Spring Boot 2.0부터는 타겟 객체가 인터페이스를 구현하고 있든 없든 무조건 CGLIB 프록시 생성을 강제(spring.aop.proxy-target-class=true)하는 것이 기본값(Default) 이 되었습니다. 현대 스프링 환경에선 CGLIB이 킹입니다.


🎯 고수 팁 (Pro Tips)​

💡 프록시 내부 호출 (Self-Invocation) 무방비 문제 프록시 AOP의 최대 약점은 "같은 클래스 내부의 메서드끼리 서로 호출할 때는 프록시가 작동하지 않는다" 는 것입니다.

@Service
public class OrderService {

// AOP(트랜잭션) 안 걸려 있는 쌩짜 메서드 외부 호출 허용
public void externalCall() {
// [심각한 문제 발생] 트랜잭션이 하나도 안 걸린 쌩 DB 로직이 흘러가게 됨!
// 왜냐하면 호출하는 internalCall()은 감싸진 프록시 객체가 아니라
// 껍데기를 헤집고 이미 들어온 실제 Target 자아 객체 내부에서의 쌩짜 직접 호출(this.internalCall)이기 때문.
this.internalCall();
}

// 분명 @Transactional 어노테이션이 붙어 있지만 방어막 프록시를 타지 않았으므로 무용지물
@Transactional
public void internalCall() {
System.out.println("주문 저장");
}
}

[해결책] 가장 깔끔하고 실무적인 베스트 프랙티스는 AOP가 적용된 internalCall() 로직을 별도의 @Service를 단 완전히 남남 클래스로 분리(Extract Class) 하여 컴포넌트 간 외부 호출(DI 필드 주입)로 바꾸어 프록시 관문을 무조건 타게 만드는 것입니다.