초이로그

JDK Dynamic Proxy vs CGLIB Proxy 본문

우아한테크코스/테코톡 정리

JDK Dynamic Proxy vs CGLIB Proxy

수연초이 2022. 10. 18. 23:58
[10분 테코톡] 기론, 리버의 JDK Dynamic Proxy와 CGLIB
[10분 테코톡] 🔥미르의 JDK Dynamic Proxy vs CGLIB Proxy
를 정리한 글

 

Proxy

Proxy란

사전적 의미 = 대리

  • 클라이언트로부터 타겟을 대신해서 요청을 받는 대리인
  • 클라이언트가 사용하려고 하는 대상을 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것
  • 프록시는 지정한 요청에 대해 부가기능을 수행
  • 실제 오브젝트인 타겟은 프록시를 통해 최종적으로 요청받아 처리. 따라서 타겟은 자신의 기능에만 집중하고 부가기능은 프록시에게 위임

사용목적

  1. 클라이언트가 타깃에 접근하는 방법을 제어하는 것(ex. JPA 지연로딩)
  2. 타깃에 부가적인 기능을 부여하기 위한 것(ex. @Transactional, 시간 측정 등)

프록시 구현하기

프록시 패턴을 통해 Proxy를 직접 구현할 수 있다.

클라이언트 객체

public class Client {
    public static void main(String[] args) {
        Hello hello = new Hello();
        System.out.println(hello.sayHello("pepper"));
    }
}

Hello 클래스

public class Hello {
    public String sayHello(String name) {
        return "Hello" + name;
    }
    public String sayHi(String name) {
        return "Hi" + name;
    }
}

 

만약 Hello와 Client 객체를 수정하지 않고 “Hello pepper”를 대문자로 출력하고 싶다면?

프록시 패턴을 활용하여 부가적인 기능인 대문자로 바꾸는 기능 수행

프록시 패턴: 특정 객체에 대한 접근을 제어하거나 부가기능을 구현하는데 사용하는 패턴

public interface Hello {
    String sayHello(String name);
    String sayHi(String name);
}

프록시 패턴을 사용하기 위해서는 인터페이스를 생성해야한다

public class HelloTarget implements Hello {
    public String sayHello(String name) {
        return "Hello" + name;
    }
    public String sayHi(String name) {
        return "Hi" + name;
    }
}

Hello 인터페이스를 구현한 HelloTarget 객체 생성

public class HelloProxy implements Hello{
    private Hello hello;

    public HelloProxy(final Hello hello) {
        this.hello = hello;
    }

    @Override
    public String sayHello(final String name) {
				// 대문자로 반환하기 위해서 toUpperCase 메서드 호출
        return hello.sayHello(name).toUpperCase();
    }

    @Override
    public String sayHi(final String name) {
        return hello.sayHi(name).toUpperCase();
    }
}

프록시 객체 생성

  • 프록시는 특정 시점에서 타겟 객체의 메서드를 호출하기 위해 필드로 인터페이스 선언부를 가지고 있다.

프록시 피턴의 장점

  • OCP(개방 폐쇠 원칙)을 지킬 수 있다
    • 기존 코드를 변경하지 않고 새로운 기능 추가 가능
    • HelloTarget을 변경하지 않고 부가기능(대문자로 만들기)을 추가
  • SRP(단일 책임 원칙)을 지킬 수 있다
    • 기존 코드가 해야 하는 일만 유지 가능
    • HelloTarget이 Hello를 붙여 반환하는 기존 코드가 해야 하는 일만 유지 가능
  • 기능 추가, 접근 제어 등 다양하게 응용하여 활용 가능

단점

매우 큰 이유 두가지라 원래는 사용하는 경우가 거의 없었다.

  • 코드의 복잡도가 증가
    • 인터페이스 구현, 프록시 객체 생성 등등 복잡하다
  • 중복 코드 발생
    • toUpperCase()라는 부가 기능을 구현할 때, 모든 모든 메서드에 적용해야한다.

 

동적 프록시

  • 프록시의 단점을 해결
  • 스프링에서는 클라이언트가 메서드를 요청하면 ProxyFactoryBean에서 인터페이스 유무를 확인하여 인터페이스가 있으면 JDK Dynamic Proxy를 호출하고 없으면 CGLIB 방식으로 프록시를 생성

종류

  • JDK Dynamic Proxy
  • CGLIB Proxy

 

JDK Dynamic Proxy

  • JDK에서 지원하는 프록시 생성 방법
    • 외부 라이브러리에 의존하지 않는다
  • 프록시 팩토리에 의해 런타임 시 다이나믹하게 만들어지는 오브젝트
  • 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스 오브젝트를 자동으로 생성
  • Reflection API를 사용한다. (느리다)
  • 인터페이스가 반드시 존재해야한다
  • Invocation Hanlder를 재정의한 invoke 코드를 직접 구현해줘야 부가기능이 추가된다

프록시 단점 해소

  • 프록시 클래스를 직접 구현하지 않아도 된다
    • 코드 복잡도 해소
  • InvocationHandler
    • 중복 코드 제거

InvocationHandler

sayHello(), sayHi()를 InvocationHandler에서 재정의한 invoke를 통해 부가 기능을 추가하여 타겟에게 반환

 

InvocationHandler를 구현한 UpperCaseHandler

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class UpperCaseHandler implements InvocationHandler {

    private final Object target;

    public UpperCaseHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (method.getName().startsWith("say")) {
            return ((String) method.invoke(target, args)).toUpperCase();
        }
        return method.invoke(target, args);
    }
}

메서드의 이름이 say로 시작하는 경우, toUpperCase로 변환하여 반환한다

Method라는 Reflection API를 사용한다(외부 라이브러리 의존하지 않는다

리플렉션 API
구체적인 클래스 타입을 알지 못해도 런타임에 클래스의 정보에 접근할 수 있게 하는 자바 API 리플렉션은 동적일 때, 해결되는 타입을 포함하므로 JVM의 optimization이 작동하지 않아 성능상 느리다.

구현부

  • 클라이언트가 메서드를 요청하면 JDK Dynamic Proxy는 메서드 처리를 InvocationHandler에게 위임 ➡️ InvocationHandler가 부가 기능 수행 ➡️ Target에게 기능 위임

InvocationHandler

  • 메서드 이름이 get으로 시작하면 “조회 메서드 호출”라는 로그를 출력
  • 이후 타겟에게 기능 위임

JDK Dynamic Proxy

  • Proxy.newProxyInstance를 사용해서 프록시 로딩에 사용할 클래스 로더(ClassLoader), 타겟의 인터페이스, 부가 기능과 위임할 타겟을 인자로 생성하여 JDK Proxy 사용 가능
  • 조회를 하는 경우, 로그도 출력된다. 조회를 하지 않는 경우, 로그가 출력되지 않는다.

만약 인터페이스가 아닌 인터페이스의 구현체를 넣으면 어떻게 될까?

  • 런타임 예외 발생

 

CGLIB Proxy

  • 프록시 팩토리에 의해 런타임 시 다이나믹하게 만들어지는 오브젝트
  • 클래스 상속을 이용하여 프록시 구현. 인터페이스가 존재하지 않아도 가능
    • 바이트 코드를 조작해서 프록시 생성함
    • 인터페이스에도 강제로 적용 가능. 이 경우 클래스에도 프록시를 적용해야 한다
  • Dynamic Proxy 보다 약 3배 가까이 빠르다
    • 메서드가 처음 호출되었을 때 동적으로 타깃 클래스의 바이트 코드를 조작
    • 이후 호출 시엔 조작된 바이트 코드를 재사용
  • MethodInterceptor를 재정의한 intercept를 구현해야 부가 기능이 추가된다
  • 메서드에 final을 붙이면 오버라이딩이 불가능

단점

  • net.sf.cglib.proxy.Enhancer 의존성을 추가해야한다
  • Default 생성자 필요
    • 없는 경우 예외 발생
  • 타겟의 생성자 두번 호출

구현부

클라이언트가 메서드를 요청하면 CGLIB은 메서드 처리를 MethodInterceptor에게 위임 ➡️ MethodInterceptor가 부가 기능 수행 ➡️ Target에게 기능 위임

 

MethodInterceptor

  • 메서드 이름이 get으로 시작하면 “조회 메서드 호출”이라는 로그를 출력
  • 메서드 이름이 get으로 시작하지 않으면 “조회가 아닌 메서드 호출”이라는 로그를 출력
  • 이후 타겟에게 기능 위임

CGLIB

  • 외부 라이브러리인 Enhancer를 사용해서 CGLIB 생성
  • 조회와 조회가 아닌 메서드에 대해 각각 로그가 출력된다

 

Spring Boot와 CGLIB

실제 스프링부트에서 인터페이스와 클래스를 구별하여 동작하는 경우 CGLIB이 작동한다. 스프링에서 proxy-target-class: true가 default라서 CGLIB이 기본으로 작동하기 때문이다. 해당 설정을 false로 두면 JDK Proxy가 작동하게 된다.

왜 스프링에서 인터페이스를 활용하여 DI를 적극적으로 사용하는 방식을 두고 CGLIB을 사용했을까?

스프링부터 개발 총 책임자 Phil Webb은 세가지 이유를 들었다. 그 중 하나는 인터페이스 기반 프록시는 때때로 ClassCastException을 추적하기 어렵게하기 때문에 CGLIB을 기본으로 사용한다는 것이다.

 

단점 해결

  • net.sf.cglib.proxy.Enhancer 의존성을 추가 ➡️ 3.2 ver. Spring Core 패키지에 포함
  • Default 생성자 필요 ➡️ 4.0 ver.부터 Objensis 라이브러리
  • 타겟의 생성자 두번 호출 ➡️ 4.0 ver.부터 Objensis 라이브러리
  • Spring 4.3과 Spring boot 1.4부터 default로 CGLIB 프록시 사용

 

ProxyFactoryBean - Spring의 프록시 구현

ProxyFactoryBean

  • Spring에서 프록시를 Bean으로 만들어주는 하나의 객체
  • ProxyFactoryBean을 통해 Proxy 생성 가능
  • 타깃의 인터페이스 정보가 필요 없다
    • JDK Dynamic Proxy는 인터페이스 정보가 반드시 프록시를 생성할 때 파라미터로 필요하지만, CGLIB은 프록시를 생성할 때 인터페이스가 필요 없다.
  • 프록시 빈을 생성한다
  • MethodInterceptor(CGLIB의 MethodInterceptor와 다른 개념)를 재정의한 invoke를 구현해서 부가 기능을 추가한다

JDK 다이나믹 프록시의 메커니즘

  • JDK Dynamic Proxy는 InvocationHandler를 통해서 프록시의 부가기능을 구현하고 실행한다.
  • InvocationHandler는 실제 객체인 타겟을 반드시 필드로 가지고 있어야 부가 기능을 수행할 수 있다. 이는 타겟에 의존적이다. 타겟이 늘어난다면, 그 개수만큼 InvocationHandler를 빈으로 매번 등록하고 객체로 생성해야한다.

ProxyFactoryBean의 프록시 생성 메커니즘

  • ProxyFactoryBean은 MethodInterceptor라는 인터페이스를 통해 부가 기능을 구현하고 사용한다.
  • ProxyFactoryBean이 프록시를 생성하면 부가기능을 MethodInterceptor가 처리한다. 이때 타겟(실제 원하는 기능을 가지고 있는 객체)을 프록시가 가지고 있다. 부가 기능을 MethodInterceptor가 독립적으로 유지하기 위해 타겟을 갖지 않는 것이다. 타깃이 Proxy에 있으므로 이를 공유해서 사용할 수 있고 부가 기능을 Singleton으로 유지해서 사용 가능하다.

단점

ProxyFactoryBean도 매번 생성해주어야한다.

 

그런데 우리는 스프링에서 트랜잭션을 사용할 때, 인터페이스를 구현하거나 프록시를 생성하는 코드를 입력하지 않아도 된다. 왜 그럴까?

  1. Advice
  2. PointCut
  3. 자동 프록시 생성기

에 대해 알아보자.

(Spring AOP:  https://suyeonchoi.tistory.com/m/80)

 

정리

JDK Dynamic Proxy

  • Relfection API을 사용해 느리다
  • 인터페이스가 반드시 필요하다

CGLIB

  • 바이트 코드를 조작해서 빠르다
  • 클래스만 있어도 작동한다
  • 상속을 이용해서 프록시를 생성한다
  • 메서드에 final을 붙이면 안된다

'우아한테크코스 > 테코톡 정리' 카테고리의 다른 글

서블릿 필터 & 스프링 인터셉터  (0) 2022.11.15
데이터베이스 락  (2) 2022.11.09
Spring AOP  (0) 2022.10.14
인덱스  (0) 2022.10.14
CI/CD와 무중단 배포  (0) 2022.10.09