Dynamic Proxy vs CGLIB 방식 차이

2026. 2. 21. 19:48·스프링

 들어가며

토비의 스프링을 읽으며 Spring AOP는 프록시 기반으로 동작하며 그 프록시를 생성하는 방식에는 

JDK Dynamic Proxy와 CGLIB가 있다는걸 알게 되었습니다. 


0. Dynamic Proxy vs CGLIB 방식 차이를 알아야 하는 이유

JDK Dynamic Proxy와 CGLIB의 차이를 알아야 하는 이유는  Spring AOP가 어떤 방식으로 동작하는지 이해하기 위해서입니다.

 

Spring의 AOP 기능은 모두 프록시 객체를 통해 동작합니다. 따라서 프록시가 어떤 방식으로 생성되는지에 따라 AOP가 적용되는 범위와 조건이 달라집니다. 이 차이를 모르면 특정 상황에서 AOP가 왜 동작하지 않는지 설명하기 어렵습니다.

 

예를 들어 @Transactional이 같은 클래스 내부 호출에서 적용되지 않는 문제는 프록시 구조를 이해해야만 납득할 수 있습니다. 프록시를 거치지 않고 실제 객체가 직접 호출되면 Advice가 실행되지 않기 때문입니다. 단순히 어노테이션의 문제가 아니라, 프록시 동작 방식과 연결된 문제라는 것을 알게 되었습니다.

 

또한 Spring은 인터페이스가 존재하면 JDK Dynamic Proxy를 사용하고, 그렇지 않으면 CGLIB을 사용합니다. JDK Dynamic Proxy는 인터페이스에 선언된 메서드만 프록시 대상이 되고, CGLIB은 클래스를 상속하는 방식이기 때문에 final 메서드에는 적용할 수 없습니다. 이런 제약을 모르고 있으면 AOP가 적용되지 않는 상황을 제대로 이해하기 어렵습니다.

 

결국 두 방식의 차이를 아는 것은 단순한 구현 기술을 구분하는 문제가 아니라, Spring이 어떤 기준으로 프록시를 생성하고, 왜 특정 상황에서 AOP가 동작하지 않는지를 이해하기 위한 기본 전제라고 생각합니다.


1. 프록시(Proxy)란?

프록시는 실제 객체(Target)를 대신하여 요청을 받아 처리하는 대리 객체입니다. 그대로 대리자입니다.

 

클라이언트가 어떤 대상(서버 객체)을 직접 호출하는 대신 중간에 있는 대리 객체를 통해 간접적으로 요청하는 구조입니다.

현실 세계로 비유해 보면서 알아보겠습니다.

  • 내가 직접 마트에 가서 장을 볼 수도 있다.
  • 하지만 누군가에게 대신 장을 봐달라고 부탁할 수도 있다.

이때 장을 대신 봐주는 사람이 바로 프록시입니다.

중요한 점은 나는 장을 직접 보지 않았지만 원하는 결과는 동일하게 얻는다는 것입니다.


직접 호출과 간접 호출의 차이

직접 호출은 단순합니다.

클라이언트가 서버에게 요청을 보냅니다. 

클라이언트 → 서버

 

 

하지만 프록시가 있으면 구조가 다음과 같이 바뀝니다. 

클라이언트 → 프록시 → 서버

 

여기서 핵심은 클라이언트는 자신이 프록시를 호출하는지, 서버를 호출하는지 몰라야 한다는 것입니다.

그래야 진짜 프록시 패턴이 성립합니다.


프록시의 장점

프록시는 직접 호출을 간접 호출로 바꾸는 구조를 만듭니다 이 간접 호출 구조 덕분에 중간에서 요청을 가로채고(intercept) 제어할 수 있습니다. 

바로 이 중간 개입 이 프록시의 생명입니다.


1. 접근 제어가 가능하다

프록시는 단순히 요청을 전달만 하는 것이 아니라, 상황에 따라 요청을 차단하거나 대체할 수 있습니다.

 

예를 들어 

  • 이미 데이터가 캐시에 있다면 서버까지 가지 않고 바로 반환할 수 있습니다.
  • 권한이 없는 사용자의 요청은 아예 서버로 전달하지 않을 수 있습니다.
  • 필요할 때까지 실제 객체를 생성하지 않는 지연 로딩도 가능합니다.

즉 프록시는 “요청을 전달할지 말지 결정할 수 있는 권한” 을 가집니다.

 


2. 부가기능을 추가할 수 있다

프록시는 원래 서버가 제공하는 기능을 건드리지 않으면서중간에 추가 작업을 수행할 수 있습니다.

예를 들어:

  • 실행 시간을 측정해서 로그를 남긴다.
  • 요청/응답 데이터를 가공한다.
  • 트랜잭션을 시작하고 종료한다.

이것이 바로 Spring AOP의 핵심입니다.@Transactional, @Log, @Cacheable 이 모든 것은 프록시가 중간에서 동작하기 때문에 가능한 기능입니다.


프록시가 되기 위한 조건

그렇다면 아무 객체나 프록시가 될 수 있을까요?

그렇지 않습니다.

프록시가 되려면 반드시 이 조건을 만족해야 합니다.

서버와 프록시는 같은 인터페이스를 구현해야 한다.

왜냐하면 클라이언트는 자신이 진짜 서버를 쓰는지 프록시를 쓰는지 몰라야 하기 때문입니다.

 

예를 들어:

public interface OrderService {
    void order();
}
public class OrderServiceImpl implements OrderService {
    public void order() { ... }
}
public class OrderServiceProxy implements OrderService {
    private OrderService target;

    public void order() {
        // 부가기능
        target.order();
    }
}

 

 

클라이언트는 이렇게 사용합니다.

OrderService service = new OrderServiceProxy(...);
service.order();

여기서 중요한 점은 클라이언트 코드는 단 한 줄도 바뀌지 않았다는 것입니다.

이게 바로 대체 가능성(Replaceability) 입니다.


DI와 프록시의 관계

이 대체 가능성을 가능하게 해주는 것이 바로 DI(Dependency Injection) 입니다.

DI를 사용하면:

  • 실제 객체를 주입할 수도 있고
  • 프록시 객체를 주입할 수도 있습니다.

클라이언트 코드는 변경하지 않습니다.

즉

프록시는 DI 환경에서 가장 강력해진다.

Spring이 AOP를 프록시 기반으로 설계한 이유도 바로 이것입니다.


프록시 체인(Proxy Chain)

프록시는 또 다른 프록시를 호출할 수도 있습니다.

클라이언트 → 프록시1 → 프록시2 → 서버

 

이 부분은 실제 동작 흐름을 한 번 그려보면 훨씬 이해가 잘 됩니다.

 

예를 들어 이런 코드가 있다고 가정해보겠습니다.

@Service
public class OrderService {

    @Transactional
    @Secured("ROLE_USER")
    public void createOrder(String item) {
        System.out.println("주문 생성 로직 실행");
    }
}

 

여기에는 최소 2개의 프록시 개념이 적용됩니다.

  • 트랜잭션 처리
  • 보안 검사

여기에 로깅 AOP까지 추가되었다고 가정해보겠습니다. 이제 내부적으로 어떤 일이 벌어질까요?

 

 

클라이언트는 단순히 이렇게 호출합니다.

orderService.createOrder("노트북");

 

하지만 실제 호출 흐름은 다음과 같습니다.

클라이언트
   ↓
보안 프록시
   ↓
트랜잭션 프록시
   ↓
로깅 프록시
   ↓
실제 OrderService

 

단계별로 실제 동작을 살펴보겠습니다

 

1.보안 프록시

가장 먼저 실행됩니다.

  • 현재 사용자의 권한을 확인합니다.
  • ROLE_USER가 아니면 예외를 던집니다.
  • 권한이 통과되면 다음 프록시로 호출을 넘깁니다.
"권한 검사 통과"

2.트랜잭션 프록시

다음으로 트랜잭션 프록시가 개입합니다.

  • 트랜잭션을 시작합니다.
  • 실제 메서드를 실행합니다.
  • 예외가 발생하면 rollback
  • 정상 종료되면 commit
"트랜잭션 시작"

3.로깅 프록시

 

  • 실행 시간을 측정합니다.
  • 요청값을 기록합니다.
  • 응답을 로그로 남깁니다.
"메서드 실행 시간: 15ms"

4.실제 비즈니스 로직 실행

"주문 생성 로직 실행"

그런데 클라이언트는?

클라이언트는 이 모든 것을 전혀 모릅니다.
이 한 줄만 알고 있습니다.

orderService.createOrder("노트북");

 

  • 트랜잭션이 있는지
  • 보안이 있는지
  • 로깅이 있는지
  • 프록시가 몇 개 붙어 있는지

전혀 모릅니다.

그저 결과만 받으면 됩니다.


객체 세계에서의 프록시 의미

객체 지향 관점에서 프록시는 단순한 대리자가 아닙니다.

프록시는 다음을 가능하게 합니다:

  • 개방-폐쇄 원칙(OCP) 준수
  • 기존 코드 수정 없이 기능 확장
  • 관심사의 분리 (핵심 로직 vs 부가기능)

즉,

프록시는 객체지향 설계를 유연하게 만드는 핵심 도구입니다.


정리
프록시는

  • 직접 호출 대신 간접 호출 구조를 만들고
  • 중간에서 접근 제어를 수행할 수 있으며
  • 부가기능을 추가할 수 있고
  • DI를 통해 유연하게 교체 가능하며
  • AOP의 기반이 되는 핵심 구조입니다.

Spring을 제대로 이해하고 싶다면
프록시는 반드시 이해해야 할 개념입니다.


 

2. CGLIB 프록시란 무엇인가?

앞에서 설명한 것처럼 프록시는 “대리자”입니다. 그리고 Spring은 이 프록시를 런타임에 동적으로 생성합니다.

 

그런데 한 가지 문제가 있습니다.

만약 인터페이스가 없다면 어떻게 프록시를 만들 수 있을까요?

여기서 등장하는 것이 바로 CGLIB(Code Generator Library) 입니다.

CGLIB은 상속을 이용해 프록시 객체를 생성하는 방식입니다.


즉, 기존 클래스를 상속한 새로운 클래스를 런타임에 만들어서 그 안에서 메서드를 가로채는(intercept) 구조입니다.


왜 CGLIB이 필요할까?

JDK Dynamic Proxy는 반드시 “인터페이스 기반”이어야 합니다.

 

public interface OrderService {
    void order();
}

이런 인터페이스가 있어야 프록시를 만들 수 있습니다.

 

 

하지만 다음과 같은 경우는 어떻게 할까요?

@Service
public class OrderService {
    public void order() {
        System.out.println("주문 생성");
    }
}

이 경우 JDK 동적 프록시는 사용할 수 없습니다.

그래서 Spring은 클래스 자체를 상속해서 프록시를 만드는 CGLIB 방식을 사용합니다.


CGLIB의 동작 원리

CGLIB은 내부적으로 다음과 같은 구조를 만듭니다.

OrderService
      ↑
OrderService$$EnhancerByCGLIB

런타임에 원본 클래스를 상속한 새로운 클래스를 생성하고, 메서드를 오버라이딩해서 중간에 개입합니다.

 

예를 들어 다음과 같은 코드가 있다고 가정해보겠습니다.

@Service
public class OrderService {

    @Transactional
    public void createOrder() {
        System.out.println("주문 생성 로직 실행");
    }
}

 

 

Spring은 실제로 이런 구조를 만듭니다.

public class OrderService$$EnhancerByCGLIB extends OrderService {

    @Override
    public void createOrder() {
        // 트랜잭션 시작
        super.createOrder();
        // 트랜잭션 커밋
    }
}

 

이렇게 부모 메서드를 오버라이딩하여 중간에 부가기능을 삽입합니다.


CGLIB 방식에서의 프록시 체인

CGLIB이라고 해서 동작 방식이 다르지는 않습니다.

실제 실행 흐름은 여전히 다음과 같습니다.

보안 → 트랜잭션 → 로깅 → 실제 로직

 

다만 차이점은 인터페이스 기반이 아니라 상속 기반이라는 점입니다. 


CGLIB의 특징

1. 인터페이스가 없어도 동작합니다

클래스만 있으면 프록시를 만들 수 있습니다.
Spring Boot 2.x 이후 기본값은 CGLIB 방식입니다.

2. 상속 기반이기 때문에 제약이 있습니다

CGLIB은 상속을 사용하기 때문에 다음 제약이 존재합니다.

  • final 클래스는 프록시 생성 불가
  • final 메서드는 오버라이딩 불가
  • private 메서드는 가로챌 수 없음

왜냐하면 상속을 통한 오버라이딩이 불가능하기 때문입니다.


3.생성자가 두 번 호출될 수 있습니다 (과거 이슈)

CGLIB은 객체 생성 과정에서 부모 생성자를 한 번 더 호출하는 문제가 있었습니다.
최근 Spring에서는 Objenesis를 사용해 이 문제를 해결했습니다

3. 그래서 JDK Dynamic Proxy vs CGLIB의 차이가 뭔데?

Spring에서 프록시를 생성하는 방식은 크게 두 가지입니다.

 

하나는 JDK Dynamic Proxy,다른 하나는 CGLIB 방식입니다.

두 방식 모두 “중간에서 메서드를 가로채는(intercept)” 구조라는 점에서는 동일합니다.

하지만 프록시를 생성하는 기반 구조가 완전히 다릅니다.


JDK Dynamic Proxy – 인터페이스 기반 프록시

DK Dynamic Proxy는 인터페이스를 기반으로 프록시를 생성하는 방식입니다.

 

즉, 반드시 다음과 같은 구조가 있어야 합니다.

public interface OrderService {
    void order();
}

 

 

그리고 구현체가 존재해야 합니다.

public class OrderServiceImpl implements OrderService {
    public void order() {
        System.out.println("주문 생성");
    }
}

 

 

JDK 동적 프록시는 이 인터페이스를 구현한 새로운 객체를 런타임에 생성합니다.

구조를 단순화하면 다음과 같습니다.

OrderService (interface)
       ↑
Proxy (implements OrderService)

 

정리하면 이 방식의 핵심 특징은 다음과 같습니다.

  • 인터페이스에 선언된 메서드만 프록시 대상이 됩니다.
  • 클래스 자체가 아니라 “인터페이스 타입” 기준으로 동작합니다.
  • 상속이 아니라 구현(implements) 기반입니다.

따라서 인터페이스가 없다면 사용할 수 없습니다.


CGLIB – 클래스 상속 기반 프록시

 

반면 CGLIB은 클래스를 직접 상속해서 프록시를 생성합니다.

 

예를 들어 인터페이스 없이 다음과 같은 클래스가 있다고 가정해보겠습니다.

@Service
public class OrderService {
    public void order() {
        System.out.println("주문 생성");
    }
}

이 경우 JDK Dynamic Proxy는 사용할 수 없습니다.
그래서 Spring은 CGLIB을 사용해 다음과 같은 구조를 런타임에 만듭니다.

 

 

OrderService
      ↑
OrderService$$EnhancerByCGLIB

 

즉, 기존 클래스를 상속한 새로운 클래스를 생성하고,
메서드를 오버라이딩해서 중간에 부가기능을 삽입합니다.

이 방식의 특징은 다음과 같습니다.

  • 인터페이스가 없어도 동작합니다.
  • 클래스 자체를 기준으로 프록시가 생성됩니다.
  • 상속 기반이므로 final 클래스, final 메서드는 프록시 적용이 불가능합니다.

정리

구분                                                   JDK Dynamic Proxy                                       CGLIB
기반 인터페이스 구현 클래스 상속
인터페이스 필요 여부 반드시 필요 필요 없음
적용 대상 인터페이스 메서드만 오버라이딩 가능한 메서드
제약 인터페이스 없으면 사용 불가 final 클래스/메서드 불가
기본 사용 전략 인터페이스가 있으면 사용 인터페이스가 없으면 사용

Spring은 기본적으로

  • 인터페이스가 존재하면 JDK Dynamic Proxy
  • 인터페이스가 없으면 CGLIB

방식을 선택합니다.

(Spring Boot 2.x 이후에는 CGLIB을 기본으로 사용하는 설정이 일반적입니다.)


마치며

 

 

 

저작자표시 비영리 (새창열림)

'스프링' 카테고리의 다른 글

Mockito 핵심 개념 Mock, Stub, Spy(feat : JUnit5)  (0) 2026.02.16
스프링 트랜잭션(@Transactional) 전파레벨 7가지  (0) 2026.02.13
FIRST 원칙으로 바라본 테스트 코드 작성법  (0) 2026.01.30
Spring Validation 완전 정복 - 올바른 유효성 검증 전략  (0) 2026.01.29
스프링에서 스코프(Scope)란 무엇인가?  (0) 2026.01.19
'스프링' 카테고리의 다른 글
  • Mockito 핵심 개념 Mock, Stub, Spy(feat : JUnit5)
  • 스프링 트랜잭션(@Transactional) 전파레벨 7가지
  • FIRST 원칙으로 바라본 테스트 코드 작성법
  • Spring Validation 완전 정복 - 올바른 유효성 검증 전략
깊은바다속꼬북이
깊은바다속꼬북이
  • 깊은바다속꼬북이
    CodeBlossom
    깊은바다속꼬북이
  • 전체
    오늘
    어제
    • 분류 전체보기 (53) N
      • 라이징 캠프 (4)
      • 객채지향 개발론 (3)
      • 스프링 (10) N
      • 네트워크 (2)
      • 자바 (16)
      • 자료구조 (3)
      • 운영체제 (0)
      • 데이터베이스 (4)
      • 디자인패턴 (7)
      • JSP (1)
      • 개발 알쓸신잡 (2)
      • 일반 교양 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    자료구조
    개발 교훈
    백엔드
    jit-compiler
    싱글턴 패턴(Singleton Pattern)
    jvm 클래스 로더
    java data area
    MySQL 파서
    자바 Socket 클래스
    템플릿 메서드 패턴(Template Method Pattern)
    MySQL 옵티마이저
    한국어 검색
    java 버전별 특징
    어댑터 패턴(Adapter Pattern)
    JUnnit5
    디자인패턴
    스프링
    디자인 패턴
    개발자 철학
    프로그램밍 언어
    mockito라이브러리
    JVM
    트랜잭션 전파레벨
    spring
    객체지향
    전략 패턴(Strategy Pattern)
    GC
    junnit5프레임워크
    java
    개발 철학
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
깊은바다속꼬북이
Dynamic Proxy vs CGLIB 방식 차이
상단으로

티스토리툴바