본문 바로가기
개발 관련 강의 정리/10분 테코톡

[10분 테코톡] 기론, 리버의 JDK Dynamic Proxy와 CGLIB 정리

by 코딩개발 2023. 6. 14.
728x90
반응형

들어가기 앞서


위 코드는 Intellij에서 컴파일 에러(Compile Error)가 나는 것을 확인할 수 있다. 그 이유는 프록시와 관련되어 있는데 자세한 이유는 뒤에 이야기하도록 하겠다.

 

 

Proxy란?

먼저 프록시란 사전적 의미로 '대리'라는 뜻을 가지고 있다.

 

사전적 의미 그대로 클라이언트로부터 타겟을 대신해서 요청을 받는 대리인의 역할을 하고 있고 실제 오브젝트인 타겟은 프록시를 통해 최종 요청을 받아 처리한다. 따라서 타겟은 자신의 기능에만 집중하고 부가기능은 프록시에게 위임하는 형태이다.

 

 

실생활 예제로 살펴 보면 우리를 클라이언트라 생각하고 프록시를 중개인, 타겟을 집주인이라고 생각하면 이해하기 쉽다.
먼저 타겟인 집주인을 대신해서 계약을 요청 받는 중개인을 프록시라고 생각하면 집주인은 프록시인 중개인를 통해서 최종적으로 계약을 한다. 따라서 집주인은 자신의 일에만 집중하고 계약 처리를 하는 부가기능은 중개인이 프록시에 위임하는 형태이다.

 

 

Proxy 사용 목적

프록시는 사용 목적에 따라 두 가지로 구분할 수 있다.

01. (JPA의 지연 로딩(Lazy Loading)같이) 클라이언트가 타깃에 접근하는 방법을 제어하는 것
02. 타깃에 부가적인 기능을 부여해주기 위한 것

- 트랜잭션(스프링의 선언적 방법으로는@Transactional) 혹은 실행 시간을 측정할 때 프록시를 사용하실 수 있다.

 

 

프로시 패턴

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

 

 

프록시 패턴이 필요한 예제코드

public class Client {
	public static void main(String[] args) {
		Hello hello = new Hello();
		System.out.println(hello.sayHello("기론"));
	}
}
public class Hello {
	
	public String sayHello(String name) {
		return "Hello " + name;
	}
	
	public String sayHi(String name) {
		return "Hi " + name;
	}

	public String sayThankYou(String name) {
		return "Thank You " + name;
	}
}

클라이언트라는 굉장히 간단한 객체를 살펴보려고 한다. 클라이언트는 Hello 객체를 생성하고 hello.sayHello("기론")를 호출하면 "Hello 기론"을 호출해준다. 여기서 Hello 객체를 수정하지 않고 클라이언트 객체도 수정하지 않고 "Hello 기론"을 대문자로 반환하고 싶다. 어떻게 해야될까? 이럴 때 프록시 패턴을 사용하면 대문자로 바꾸는 기능을 수행할 수 있다.

 

예시코드 - 인터페이스 구현

package proxypattern;

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

프록시 패턴을 사용하려면 Hello를 인터페이스(Interface)로 변경해서 선언부를 만들어주면 된다. sayHello, sayThankYou, sayHi를 만들어 주고

 

에시코드 - Target과 Proxy

package proxypattern;

public class HelloTarget implements Hello {

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

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

	public String sayThankYou(String name) {
		return "Thank You " + name;
	}
}

Hello 인터페이스를 구현한 HelloTarget 객체를 생성해 주었다.

 

package proxypattern;

public class HelloProxy implements Hello {
	
	private Hello hello;

	public HelloProxy(Hello hello) {
		this.hello = hello;
	}
	
	@Override
	public String sayHello(String name) {
		return hello.sayHello(name).toUpperCase();
	}

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

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


다음으로 프록시 객체를 생성해주었다. 이때 대문자로 반환하기 위해서 toUpperCase 메서드를 호출한 것을 볼 수 있다. 프록시는 하나의 특징을 가지는데 필드로 인터페이스 선언부를 가지고 있다. 왜냐면 특정 시점에서 타켓 객체의 메서드를 호출 해 주기 위해서 필드로 가지고 있다.

 

 

프록시 패턴이란?

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

초기화 지연, 접근 권한 제어, 부가 기능 등에 사용할 수 있다.

일반적인 경우 클라이언트가 타겟, HelloTarget(우측 HelloProxy 부분)을 직접으로 사용한다. 여기서는 부가적인 대문자 기능을 구현하기 위해 우선 클라이언트가 인터페이스를 가리키게 하고 인터페이스를 구현한 프록시 객체와 타겟 객체 두 가지를 생성해서 클라이언트가 HelloProxy를 사용하도록 구현하면 프록시 패턴을 적용할 수 있다.

 

 

프록시 정리

특정 객체에 대한 접근을 제어하거나 기능을 추가 할 수 있는 객체를 의미

 

▶ 장점
- OCP : 기존 코드를 변경하지 않고 새로운 기능을 추가 할 수 있음
- SRP : 기존 코드가 해야 하는 일만 유지할 수 있음
          기능 추가. 접근 제어 등 다양하게 응용하여 활용 할 수 있음

 

위와 같은 장점이 있지만 이런 프록시를 거의 사용하는 경우가 없는데 이유는 단점이 굉장이 크기 때문이다.

 

▶ 단점
- 인터페이스 구현과 프록시 객체 생성에 의해 코드의 복잡도가 증가한다.
- 위 예제의 toUpperCase는 모든 메서드에 적용된 걸 볼 수 있다. 이런 식으로 부가기능을 구현할 때
모든 메서드에 적용해 주어야 하기 때문에 중복 코드가 많이 발생한다.

 

이와 같은 문제를 해결하는 방법은 동적 프록시를 통해 해결할 수 있다.

먼저 동적 프록시의 한 종류인 JDK Dynamic Proxy에 대해 알아보자

 

 

JDK Dynamic Proxy

▶ 프록시 클래스를 직접 구현하지 않아도 됨

→ 코드 복잡도 문제 해결
▶ InvocationHandler

→ 중복 코드 제거 가능

 

package proxypattern;

public class HelloProxy implements Hello {

	private Hello hello;

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

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

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

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

 

InvocationHandler에 대해 알아보자면 앞서 이야기 했건 것처럼 Hello 인터페이스를 구현하는 HelloProxy객체에서 sayHello, sayHi, sayThankYou 세 개의 메서드를 볼 수 있다. 위 코드와 같이 toUpperCase는 대문자로 변환해주는 같은 메서드인데 세 개에 메서드에 중복 코드가 발생한 것을 볼 수 있다.

 

 

이 부분을 InvocationHandler를 통해 해결할 수 있는데, sayHello, sayHi, sayThankYou 이 것들을 InvocationHandler에서 재정의한 Invoke를 통해 부가기능을 추가하여 타겟에게 다시 반환하는 형태로 해결할 수 있다.

 

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);
    }
}

위에 코드를 보면 InvocationHandler를 구현한 UpperCaseHandler를 볼 수 있는데, if부분에서 메소드의 이름이 say로 시작하면 toUpperCase로 변환하여 반환하는 것을 확인할 수 있다. 여기서 JDK Proxy는 Method라는 리플렉션(Reflection) API를 사용한다.

 

 

Reflection API

구체적인 클래스 타입을 알지못해도 런타임에 클래스 정보에 접근할 수 있게 해주는 자바 API이다. 이를 통해 JDK Proxy가 외부 라이브러리에 의존하지 않고 자바 API를 사용하는 걸 확인할 수 있다. 공식 문서에서 확인할 수 있듯이 리플렉션은 동적일때 해결되는 타입을 포함하므로 JVM(Java Virtual Machine) Optimization(최적화)이 작동하지 않아 성능상 느리다고 한다.

 

 

JDK Dynamic Proxy - 구현

 

JDK Dynamic Proxy - 예시 코드


LoggingHandler라는 로그를 찍어주는 부가기능을 수행하는 Handler가 있고 Order, OrderService 그리고 OrderService의 구현체인 OrderServiceImpl이 있다. JDK Dynamic Proxy의 플로우를 보면 클라이언트가 메소드를 요청하면 JDK Dynamic Proxy는 메소드 처리를 InvocationHandler에게 위임한다. InvocationHandler는 부가기능을 수행하고 다시 타겟에게 위임하는 형태로 진행된다.

 

먼저 타켓 클래스를 확인해 보자

 

 

save를 호출하면 "정상적으로 저장되었습니다."를 출력하고 단건을 조회하면 "주문 한 개를 조회했습니다." 라고 출력하는 형태이다.

 

다음으로 InvocationHandler에 대해 살펴보자

 

 

InvocationHandler의 메서드를 재정의한 invoke를 보면 메서드 이름이 get으로 시작하면(startsWith("get")) "조회 메서드 호출"이라는 로그를 찍어주는 것을 확인할 수 있다. 타겟에게 위임하는 상태로 볼 수 있다.

 

 

다음으로 JDK Dynamic Proxy를 구현한 것을 살펴 보자

 

위와 같이 Proxy.newProxyInstance를 사용해서 JDK Proxy를 사용할 수 있는데 처음에는 프록시 로딩에 사용할 클래스 로더(ClassLoader)를 적어주고 타겟의 인터페이스를 적어준 후, 부가기능과 위임할 타겟을 적어주면 된다. 이제 OrderService의 getOne을 조회하면 "조회 메서드 호출"이라는 방금 로그를 찍었던 부가기능이 추가되는 것을 확인할 수 있다.

 

OrderService의 클래스를 출력하면 위와 같이 sun.proxy라는 JDK Proxy를 사용했음을 확인할 수 있다.

 

JDK Dynamic Proxy - 실행

 

조회 하지 않고 save만 했을 때를 보면, save하면 로그를 찍지 않고 저장되었다고 출력하는 것을 예상할 수 있다.

 

 

예상대로 저장만 되었다고 출력 하고 JDK Proxy를 사용했다는 것을 확인할 수 있다.

 

 

만약 인터페이스가 아닌 인터페이스의 구현체(Implements)를 실수로 넣었을 때는 어떻게 될까?

 

 

Runtime 시점에 OrderServiceImpl이 인터페이스가 아니라는 Exception(예외)을 발생하는 것을 확인할 수 있다.

 

 

JDK Dynamic Proxy 특징

01. JDK에서 지원하는 프록시 생성 방법

- 외부 라이브러리에 의존X
02. Reflection APl를 사용한다.

- 조금 느리다
03. 인터페이스가 반드시 있어야 한다.
04. Invocation Handler를 재정의한 invoke를 구현해줘야 부가기능이 추가된다.

 

 

동적 프록시

동적 프록시 중 하나인 CGLIB

 

CGLIB

 


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

 

 

CGLIB(Code Generation Library) 특징

01. 바이트 코드를 조작해서 프록시 생성
02. MethodInterceptor를 재정의한 intercept를 구현해야 부가기능이 추가된다.

 

 

CGLIB - 예시 코드

LoggingMethodInterceptor라는 로그를 찍는 부가기능을 해주는 클래스가 있고 Product, ProductService가 있다. 흐름은 JDK Proxy와 비슷하다. 클라이언트가 메소드를 요청하면 CGLIB에서 InvocationHandler가 아닌 MethodInterceptor에 메서드 처리를 요청한다. 그럼 MethodInterceptor에서 부가 기능을 수행후 타겟에 위임하는 형태이다.

 

 

먼저 타겟 클래스부터 확인해 보자

 

 

앞선 JDK와 같이 save를 호출하면 "정상적으로 저장되었습니다."를 출력하고 단건을 조회하면 "상품 한 개를 조회했습니다."라고 출력하는 형태이다.

 


LoggingMethodInterceptor를 보면 MethodInterceptor의 interccept라는 메서드를 재정의하여 사용하는데. 메서드 이름이 get으로 시작하면(startsWith("get")) "조회 메서드 호출!!!"이라고 나오고 그렇지 않으면 "조회가 아닌 메서드 호출~~~"이라고 로그가 찍힌다.

 


CGLIB 구현을 보면 Enhancer라는 외부 의존성을 사용하여 CGLIB을 사용할 수 있는데 ProductService에서 getOne으로 조회하고 ProductService 클래스를 찍어보면 조회했다는 로그가 찍히고 상품을 한 개 조회했다는 기능(메서드 실행 결과)이 나오고 CGLIB을 사용했다는 것을 확인할 수 있다.

 


save 하면 "조회가 아닌 메서드 호출~~~"이라는 로그가 찍히고 "정상적으로 저장되었습니다."가 출력이 된 후 
마찬가지로 CGLIB이 작동한 것을 확인할 수 있다.

 


CGLIB 특징

01. 인터페이스에도 강제로 적용할 수 있습니다. 이때는 클래스에도 프록시를 적용시켜야 한다.

 

02. 메서드에 final을 붙이면 오버라이딩이 불가능하다.

그래서 앞선 예시에서 final 클래스 때문에 정상 동작 하지 않는 것을 확인할 수 있었다.

 

03. net.sf.cglib.proxy.Enhancer 의존성을 추가해야 한다.

 

04. 기본 생성자(Default Constructor)가 필요하다.

만약 기본 생성자가 없으면 기본 생성자가 없다고 Exception을 발생한다.

 

05. 이후 타겟의 생성자를 두 번 호출하는데 기본 생성자를 두 번 호출하는 것을 확인할 수 있다.

 

 

JDK Dynamic Proxy vs CGLIB

JDK Dynamic Proxy는 리플렉션 API를 사용해서 성능상 느리다는 건 확인하였으니 CGLIB의 성능에 대해서 알아보자

 

CGLIB 성능

01. 메소드가 처음 호출되었을 때는 동적으로 타겟 클래스의 바이트코드(Bytecode)를 조작
02. 이후 호출 시에는 조작된 바이트코드를 재사용

 

 

최초 메서드 호출


최초로 메서드 getOne을 호출했을 때를 보면

 

 

디버그를 찍었을 때 위에서 봤던 LoggingMethodInterceptor에 들어가서 methodProxy.invoke를 호출한다.

 

 

내부에서 호출하는 init이라는 메서드를 보면

 

위와같이 FastClass를 통해 바이트코드를 생성하여 해당 로직을 타는 것을 볼 수 있다.

 

 

이후 메서드 호출

 

두 번째로 getOne을 다시 한번 호출하면

 


다시 init으로 들어가지만

 


lock이 걸리면서 내부 메소드 로직을 타지 않고 바로 나오는 것을 확인할 수 있다.

 

CGLIB의 공식 문서를 보면 CGLIB이 Dynamic Proxy보다 3배 가까이 빠르다는 것을 확인할 수 있다.

 

 

중간 요약

JDK Dynamic Proxy

- 리플렉션을 사용해서 느리다.

- 인터페이스가 있으면 작동한다.


CGLIB

- 바이트코드를 조작해서 빠르다.

- 클래스만 있어도 작동한다.

 

 

Spring Boot와 CGLIB


스프링부트(SpringBoot)에서 인터페이스와 클래스를 구별하여 동작을 하면 CGLIB이 작동하는 것을 확인할 수 있다.

 

 

그 이유는 스프링에서 기본으로 proxy-target-class: true로 되어있기 때문에 CGLIB이 기본으로 작동하기 때문이다. 해당 설정을 false로 하면 정상적으로 JDK Proxy가 작동하는 것을 확인할 수 있다.

 

스프링에서 인터페이스를 활용하여 DI(Dependency Injection) 적극 사용하는 방식을 두고 왜 CGLIB을 사용했을까?
이 부분에 대해서 Phil Webb이라는 스프링부트를 개발한 총 책임자가 세가지 이유를 들었다. 그중 한가지로는 인터페이스 기반 프록시는 때때로 ClassCast Exceptions를 추적하기 어렵게한다고 한다. 이러한 이유 때문에 스프링부트에서는 CGLIB을 기본으로 사용하게 되었다.

 


Enhancer 의존성(Dependency)을 추가해야 된다는 부분을 3.2 version Spring Core 패키지에 포함해서 해결했고 기본 생성자가 필요했던 부분은 4.0 version부터 Objensis 라이브러리를 사용해서 해결했다. 마찬가지로 타겟의 생성자 두 번 호출 또한 4.0 version부터 Objensis 라이브러리를 사용해서 해결했고 Spring 4.3과 Spring boot 1.4부터 기본으로 CGLIB 프록시를 사용하게 되었다.

 

 

정리

JDK Dynamic Proxy

01. 리플렉션 API를 사용한다.

02. 인터페이스가 반드시 필요하다.

 


CGLIB

01. 바이트코드를 조작해서 빠르다.

02. 상속을 이용해서 프록시를 만든다.

03. 메서드에 final을 붙이면 안된다.

 

 

Spring의 프록시 구현

 

스프링에서 프록시를 빈(Bean)으로 직접 사용하겠다고 하면 ProxyFactoryBean을 사용해서 구현할 수 있다.

 

ProxyFactoryBean

- Spring에서는 프록시를 Bean으로 만들어주는 ProxyFactoryBean을 제공함
- ProxyFactoryBean을 통해서 Proxy를 생성 할 수 있음

 

ProxyFactoryBean의 주요특징

1. 타겟의 인터페이스 정보가 필요없다.

타겟의 인터페이스 정보를 파라미터로 줄 필요가 없다. 앞서 JDK Dynamic Proxy는 인터페이스 정보가 반드시 파라미터로 들어가야 프록시 생성이 되었다. CGLIB은 프록시를 생성할 때 인터페이스가 없었다. ProxyFactoryBean은 CGLIB을 만들기 위해서 인터페이스 정보를 굳이 받지 않는다. 만약에 인터페이스 정보가 없다면 CGLIB으로 프록시를 생성한다.

 

2. 프록시 Bean을 생성해준다.

프록시 Bean을 생성해주는 하나의 객체라는 점이다.
3. 부가기능을 MethodInterceptor로 구현

여기서의 MethodInterceptor는 CGLIB의 MethodInterceptor와는 다른 개념이다. JDK Dynamic Proxy와 비교하면 JDK Dynamic Proxy는 InvocationHandler를 통해서 프록시 부가기능을 구현하고 실행주었다. 그런데 ProxyFactoryBean은 MethodInterceptor 라는 인터페이스를 통해서 부가기능을 구현하고 사용한다.

 

왜 굳이 MethodInterceptor를 썼는지 JDK Dynamic Proxy의 특징을 다시 한번 살펴 보자.

 

JDK Dynamic Proxy

JDK Dynamic Proxy는 프록시를 생성하고 부가기능을 InvocationHandler가 처리해준다. InvacationHandler는 중요한 특징을 하나 가지고 있다. 우리가 타겟(실제 객체)를 필드로 반드시 가지고 있어야 한다는 점이다. 그래야지만 InvocationHandler는 부가 기능을 수행할 수 있다. 이것은 굉장히 치명적인 단점이 되기도 한다.

 

 


우리가 타겟 A의 정보를 ProxyFactoryBean에게 주고 프록시를 생성할 때 InvocationHandler를 쓴다고 한다면 타켓 A의 정보를 InvocationHanderA가 가지고 있어한다. 마찬가지로 타켓 B를 가지고 InvocationHandlerA의 부가 기능을 수행하려면 InvocationHandlerA가 타켓 B의 정보를 가지고 있어야한다. 마찬가지로 C도, D도 같다. 보다시피 InvocationHandlerA라는 같은 기능을 쓰는데 빈으로 InvocationHandlerA를 매번 만들어준다. 같은 기능을 매번 Bean으로 등록하고 객체 생성을 해 줘야 한다. 불필요한 작업이다.

 

ProxyFactoryBean
ProxyFactoryBean은 MethodInterceptor를 통해서 이러한 단점을 극복했다.


위 그림은 ProxyFactoryBean의 프록시 생성 메커니즘이다. ProxyFactoryBean이 프록시를 생성하면 부가기능을 MethodInterceptor가 처리해준다. 그런데 이때 타겟, 실제 원하는 기능을 가지고 있는 객체는 프록시가 가지고있다. InvocationHandler는 직접 가지고 있었다. 이 차이가 어떤 변화를 가져올까?

 

MethodInterceptor
MethodInterceptor는 타켓을 가지지 않는데 이유는 부가기능을 독립적으로 유지하기 위해서이다.

 


위 그림을 다시 살펴보자. InvocationHandlerA를 썼다면 Bean을 매번 생성 해줬어야 했다. D만 봤을 때, InvocationHandlerA이것이 MethodInterceptor였다면 타겟 뒤의 정보가 ProxyFactoryBean에 들어 있다. 그러면 타겟 C, B, A의 정보도 하나로 공유해서 사용할 수 있게 된다.

 

 

이런 식으로 부가기능을 싱글톤(Singleton)으로 유지해서 사용 가능하다. 그렇기 때문에 ProxyFactoryBean은 MethodInterceptor를 사용한다.

 


ProxyFactoryBean 특징

01. Spring에서 지원하는 프록시 생성 방법이라는 점

02. MethodInterceptor를 재정의한 invoke를 구현해줘야 부가기능이 추가된다.
03. 인터페이스 반드시 필요 하지는 않다.

 

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

 

 

추가로 공부필요한 부분

Advice, PointCut, 자동 프록시 생성기

 

[10분 테코톡] 🔥미르의 JDK Dynamic Proxy vs CGLIB Proxy 정리


참고

https://www.youtube.com/watch?v=MFckVKrJLRQ&ab_channel=%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC 

728x90
반응형

댓글