본문으로 건너뛰기

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 필드 주입)로 바꾸어 프록시 관문을 무조건 타게 만드는 것입니다.