개요

프록시를 이해하려고 작성한다.

  • jdk dynamic proxy
  • cglib proxy

jdk dynamic proxy

자바 1.3 버전 이후부터 사용이 가능하다. proxy class 는 proxy 클래스 및 proxy 인스턴스를 만들기 위해서 스태틱 메소드를 제공하고 있다. 그리고 스태틱 메소드에 의해 생성된 모든 dynamic proxy 에 대한 슈퍼클래스이다.

 

다이내믹 프록시 클래스는 (이하 프록시 클래스라 칭한다.) 생성될 때, 런타임에 동적으로 인터페이스를 구현한다. 프록시 인터페이스는 프록시 클래스에 의해 구현되는 인터페이스이다. 프록시 인스턴스는 프록시 클래스에 대한 인스턴스이다. 

 

각각의 프록시 인스턴스에는 invocation handler object 가 존재한다. 프록시를 통한 메소드가 호출되면 프록시는 invocationHandler 인터페이스로 구현된 invoke() 함수를 호출한다. (다이내믹 프록시를 만들기 위해선 invocationHandler 인터페이스에 대한 별도의 구현이 필요하다.)

 

InvocationHandler 는 인코딩되어있는 메소드 호출에 대해서 처리하고, 프록시 인스턴스의 메소드 호출 결과를 반환한다. 프록시는 InvocationHandler 와 긴밀하게 연결되어 있다.

 

  • proxy class 는 따로 지정된 이름이 존재하지 않는다. 다만 "$Proxy" 라는 이름으로 프록시 클래스는 예약어가 잡혀있다.
  • proxy class 는 java.lang.reflect.Proxy 클래스를 extends 한다.
  • proxy class 를 구현할 인터페이스가 필요하다.

 

구조 및 코드

/** interface **/
public interface Human {
    void walk();
    void talk();
}

public class Person implements Human{
    @Override
    public void walk() {
        System.out.println("i am walking");
    }

    @Override
    public void talk() {
        System.out.println("i am talking");
    }
}

/** InvocationHandler 인터페이스를 구현하는 LoggingHandler **/
public class LoggingHandler implements InvocationHandler {

    private final Object target;
    private final Map<String, Integer> calls = new HashMap<>();

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

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        final String name = method.getName();

        /** 필요 기능 구현 가능 **/
        if(name.contains("toString")){
            return calls.toString();
        }

        calls.merge(name, 1, Integer::sum);

        /** 타겟에 대한 메소드가 호출된다. **/
        return method.invoke(target, args);
    }
}

public class Demo {
	// dynamic proxy 를 만드는 방법 1
    @SuppressWarnings("unchecked")
    public static <T> T withLogging(T target, Class<T> clazz) throws Exception {
        Class proxyClass = Proxy.getProxyClass(target.getClass().getClassLoader(), clazz);
        return (T) proxyClass.getConstructor(new Class[]{InvocationHandler.class}).newInstance(new Object[] {new LoggingHandler(target)});
    }

	// dynamic proxy 를 만드는 방법 2
    @SuppressWarnings("unchecked")
    public static <T> T withLoggingBySimply(T target, Class<T> clazz) {
        return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class<?>[] {clazz}, new LoggingHandler(target));
    }

    public static void main(String[]args) throws Exception {
        Person person = new Person();
        Human logged = withLoggingBySimply(person, Human.class);
        // Human logged = withLogging(person, Human.class);
        logged.talk();
        logged.walk();
        logged.walk();
        logged.walk();
        System.out.println(logged);
    }
}

logged 객체에 디버깅을 해보면 해당 객체는 $Proxy0@XXX 로 찍혀있다.

 

문득 왜 다이내믹 프록시를 사용할까? 라는 생각이 든다.

  • 메소드 실행에 대한 로깅처리
  • 메소드로 들어온 인자값에 대한 추가적인 유효성 검사처리
  • 리소스가 큰 객체를 접근할 시에 lazy access 가능

그럼 단점은 뭐가 있을까?

  • 인터페이스를 항상 구현해주어야 한다. newProxyInstance 를 수행하는 시점에 target class 는 인터페이스를 구현하고 있어야 한다.

참고자료

 

 

cglib proxy

기본적으로 스프링 컨테이너는 <aop: scoped-proxy> 요소로 마크업된 빈에 대한 프록시를 생성하면 cglib 기반의 프록시를 생성한다. cglib 프록시는 public 접근지정자에 대한 메소드만 인터셉트할 수 있다. 그 외에 접근지정자로써는 쓰더라도 실제 target object 로 delegated 하지 못한다. (target object 의 메소드를 수행시키지 못한다는 의미)

 

cglib 는 bytecode generation library 이다. 프록시 생성 기능을 제공하고 있으며, 인터페이스 및 클래스에 대한 프록시를 생성할 수 있다. 자바 리플렉션 api Proxy 클래스는 인터페이스에 대한 프록시 생성만 가능했다면 cglib 는 클래스만 가지고도 프록시를 생성할 수 있다. cglib 를 이용하는 오픈소스 프로젝트는 spring, hibernate, iBatis, modelMapper 등이 있다.

 

긴 설명보단 바로 코드로 들어가자.

 

class 로 프록시 생성

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.NoOp;

public class Main {
    public static void main(String[] args) {

        Enhancer enhancer = new Enhancer();

        // 프록시 대상을 지정
        enhancer.setSuperclass(Dog.class);

        // 프록시에서 호출할 callback 지정
        // NoOp 는 아무것도 처리하지 않음을 뜻한다.
        enhancer.setCallback(new NoOp(){});
    
        // proxy 생성
        Object proxy = enhancer.create();
        
        Dog dog = (Dog) proxy;
        dog.bark();
    }
}

// Dog 클래스
public class Dog {
    public void bark() {
        System.out.println("bark bark bark!!!");
    }
}
  • setCallback() 메소드에 아무것도 작성되어있지 않다면 NPE 가 떨어진다. 그래서 callback 은 필히 작성이 필요하다.
  • Dog dog = (Dog) proxy 부분에서 타입 캐스팅을 수행하는데, 디버깅으로 찍어보면, 해당 클래스는 $$EnhancerByCGLIB 라고 CGLIB 기반의 프록시 클래스임을 알 수 있다.

 

cglib 프록시 대상을 지정

cglib 는 dynamic proxy 와는 다르게, 클래스와 인터페이스 둘 다 프록시를 생성할 수 있다.

 

interface 로 프록시 생성

public class Main {
    public static void main(String[] args) {

        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(Cat.class);
        
        // 인터페이스 지정
        enhancer.setInterfaces(new Class<?>[] {Animal.class});
        enhancer.setCallback(NoOp.INSTANCE);
    
        // proxy 생성
        Object proxy = enhancer.create();

        Animal cat = (Animal) proxy;
        cat.cry();
    }
}

public interface Animal {
    void cry();
}

public class Cat implements Animal{
    @Override
    public void cry() {
        System.out.println("meow meow meow..");
    }
}
  • setInterfaces 코드를 통해서 상위 인터페이스인 Animal 을 설정해두었다. 이후에는 클래스로 프록시를 만들때와 동일하다.

여기서 잠깐, 프록시를 왜 쓰는지 앞서서 생각했지만 다시 한번 상기시키자.

  • 로깅의 측면.
  • 비용이 많이 드는 리소스에 대한 lazy access
  • 들어오는 인자값에 대한 추가적인 작업처리

 

cglib 는 콜백메소드를 여러 개 제공하는데, 제공해주는 콜백 메소드를 이용하면 바로 위에서 언급한 내용들에 대해 작성이 가능하다. 현재 나는 cglib 3.3.0 버전을 임포트해서 테스트하고 있고 사용 가능한 콜백은 아래와 같다. 메이븐 저장소 링크

  • MethodInterceptor
    • 대상 객체 호출 이전/이후에 필요한 기능삽입이 가능한 콜백이다. (중간에 메소드를 말 그대로 가로챈다.)
    • General-purpose callback which provides for "around advice".
  • InvocationHandler
    • jdk dynamic proxy 에서 본 invocationHandler 와 동일한 역할이다.
    • Proxy 클래스에서도 사용가능하고 추가적으로 Enhancer 클래스에서도 작동한다.
  • LazyLoader
    • 최초 요청 시, proxy instance 에서 실제 객체를 반환하는 콜백이다.
    • 프록시 인스턴스에 의해 호출된 객체는 실제 객체를 계속 이용하게 된다.
    • null 리턴 시 NPE 발생하고, 다른 타입의 클래스 리턴 시 타입캐스팅 문제가 발생한다.
    • Return the object which the original method invocation should be dispatched.
  • FixedValue
    • 단순 특정 값을 반환하는 콜백이다.
  • 그외에도 나머지 것들이 존재한다.

callback method example

public class Main {
    public static void main(String[] args) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(Cat.class);
        enhancer.setInterfaces(new Class<?>[] {Animal.class});

        // 콜백 다양하게 적용하기
        enhancer.setCallback(new CustomMethodInterceptor());
        enhancer.setCallback(new CustomLazyLoader(Cat.class));
        enhancer.setCallback(new CustomInvocationHandler(Cat.class));
        enhancer.setCallback(new CustomFixedValue());

        Object proxy = enhancer.create();
        Animal cat = (Animal) proxy;
        cat.cry();
    }
}

// LazyLoader
class CustomLazyLoader implements LazyLoader {
    private final Class<?> target;
    
    public CustomLazyLoader(Class<?> target) {
        this.target = target;
    }

    @Override
    public Object loadObject() throws Exception {
        return target.newInstance();
    }
}

// InvocationHandler
class CustomInvocationHandler implements InvocationHandler {

    private final Class<?> target;

    public CustomInvocationHandler(Class<?> target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("[callback-InvocationHandler] Meow! Meow!");
        return method.invoke(target.newInstance(), args);
    }
}

// MethodInterceptor
class CustomMethodInterceptor implements MethodInterceptor {
    /**
     * 
     * @param obj       프록시 객체
     * @param method    호출 메소드
     * @param args      호출 시 전달받은 인자
     * @param proxy     대상 객체 메소드를 호출할 때 사용
     */
    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {

        System.out.println("before method calling");
        Object result = proxy.invokeSuper(obj, args);   // setSuperclass() 로 지정한 클래스의 메소드 호출
        System.out.println("after method called");
        return result;

        // Object result = proxy.invoke(obj, args);        // obj 를 사용해서 값을 넣으면 재귀가 발생함
    }
}

// FixedValue
class CustomFixedValue implements FixedValue {
    @Override
    public Object loadObject() throws Exception {
        System.out.println("[callback-FixedValue] Meow! Meow!");
        return null;
    }
}
  • cglib 프록시를 만들때, final 로 클래스를 선언하면 프록시 클래스를 생성할 수 없다. 강제로 진행한다고 하더라도 Cannot subclass final class {class-name} 이라는 문구를 만나게 된다.

Enhancer 클래스에서 프록시 클래스를 generate 하는 경우에 final 예약어는 에러를 발생시킨다.

  • 근데 특이한건 다른 글들을 살펴보았을 때 final 예약어가 클래스 뿐만 아니라 메소드에 붙어도 프록시를 생성하지 못한다고 되어있는데 직접 해본 결과 final 메소드로 하면 cglib 프록시는 생성된다.

    하지만 스프링단으로 넘어가서 aop 를 적용하기 위한 target object 에 대한 프록시생성시 메소드에 final 예약어를 붙였을때는 컴파일 에러가 나고 프록시 생성 및 적용이 되지 않는다.

 

kotlin 에서는 default 가 final 인데 어떻게 @Transactional 애노테이션을 붙일 수 있을까?

해당 내용에 대해서는 https://kotlinlang.org/docs/all-open-plugin.html#spring-support 에 링크에 들어가면 확인할 수 있다.

내용을 요약하면,

 

kotlin-spring 컴파일러를 적용하여 해결할 수 있다. kotlin-spring 은 all-open 의 최상단 래퍼이며 플러그인을 gradle dsl plugins 에 적용하면 별도로 open 을 붙이지 않고 non final class 형태로 적용시킬 수 있게 된다. 결국 프록시 기반에 aop 로 적용한 클래스들을 설정할 수 있다.

 

 

참고자료

 

프록시 이점

  • 기존코드를 건드리지 않고 추가적인 작업을 만들거나 변경이 가능하다는 점이다. 이는 결국 스프링 aop 를 가능토록 해준다. 관심사를 분리하는 것이다. (로깅처리, 트랜잭션처리, 보안처리 등등)

프론시 단점

  • 디버깅이 어렵다.
  • 프록시 코드 내에 before/after 구간에 IO 사용 시 성능상의 이슈가 발생할 수 있다.
  • 비즈니스 코드에 존재하지 않는 예외에 대해서도 고려가 필요하다. 프록시 내에서 에러가 발생할 수 있기 때문

 

 

Spring 에서 살펴보는 프록시의 존재

 

위의 다이어그램같은 구조가 있다고 생각하자. 이 때 각각의 메소드에는 @Transactional 애노테이션이 붙여져 있는 상태이다. 그리고 각각의 클래스에 대해서 public method 호출 시에, getClass() 메소드를 통해서 현재 클래스 리터럴을 반환하도록 하였다.

 

수행환경

  • sprinboot 2.3.0 RELEASE
  • java 1.8
  • windows 10

 

AccountService.class

@Slf4j
@Service
@RequiredArgsConstructor
public class AccountService {

    private final EntityManager entityManager;

    @Transactional
    public Account createTransaction(Account account) {
        log.info("current account service class : {}", getClass());
        log.info("current entityManager : {}", entityManager.getClass());
        entityManager.persist(account);
        return account;
    }
}

// 로그 출력값
current account service class : class edu.pasudo123.study.demo.springio.service.AccountService 가 출력
current entityManager : class com.sun.proxy.$Proxy84

 

 

MemberServiceImpl.class

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

    private final EntityManager entityManager;

    @Override
    @Transactional
    public Member createTransaction(Member member) {
        log.info("current member service class : {}", getClass());
        entityManager.persist(member);
        return member;
    }
}

// 로그 출력값
current member service class : class edu.pasudo123.study.demo.springio.service.MemberServiceImpl 출력

 

위의 AccountService.class 와 MemberServiceImpl.class 는 프록시 내의 타겟 클래스까지 진입한 상태에서 getClass() 메소드를 호출하여 클래스 네임 리터럴이 프록시 이름으로 표현되지 않았다. 그래서 프록시 이름을 확인하고자 한다면 외부에서 해당 빈에 대한 프록시를 확인할 수 밖에 없다. 확인할 수 있는 경우는 두 가지로 테스트해보았다.

  • springboot 컨테이너 올라갈 시, ApplicationContext 를 통해서 빈의 클래스 네임을 확인하기
  • 컨트롤러 레이어에서 has-a 관계를 맺는 서비스 빈의 클래스 네임 확인하기.

 

프록시 네임 확인은 전자로 수행하였다.

@Slf4j
@SpringBootApplication
@RequiredArgsConstructor
public class DemoApplication implements CommandLineRunner {

    private final ApplicationContext context;

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        log.info("=== proxy check run ===");
        AccountService accountService = context.getBean(AccountService.class);
        log.info("account service bean");
        log.info("current class : {}", accountService.getClass());
	
        MemberService memberService = context.getBean(MemberService.class);
        log.info("");
        log.info("member service bean");
        log.info("current class : {}", memberService.getClass());
    }
}

// 로그 출력값
=== proxy check run ===
account service bean
current class : class edu.pasudo123.study.demo.springio.service.AccountService$$EnhancerBySpringCGLIB$$adf7a3eb

member service bean
current class : class edu.pasudo123.study.demo.springio.service.MemberServiceImpl$$EnhancerBySpringCGLIB$$854de124
  • 보다시피 $$EnhancerBySpringCGLIB 프록시 클래스로 확인되는 것을 파악할 수 있다. 앞선 코드에서는 프록시 클래스의 타겟 클래스까지 들어간 상태에서 getClass() 를 통해 확인해서 프록시 클래스가 표시 되지 않았다. 만약 컨트롤러단에서 해당 빈의 getClass() 를 통해서 출력한다면 동일하게 CGLIB 클래스가 잡히는 것을 확인할 수 있다. 프록시 클래스로 잡힌다는 것은 @Transactional 어노테이션이 붙어서 프록시 클래스로 생성되고 aop 가 가능해진 것이다.
  • 추가로 앞선 service layer 에서 entityManager 를 통해 getClass() 메소드를 호출한 결과는 dynamic proxy 가 출력되는 것을 확인할 수 있었다. class.com.sun.proxy.$ProxyXX 결국 entityManager 또한 프록시 클래스인 것이다.

spring.aop.proxy-target-class : true/false

spring:
  profiles:
    active: test
  aop:
    proxy-target-class: false


// 로그 출력값
=== proxy check run ===
account service bean
current class : class edu.pasudo123.study.demo.springio.service.AccountService$$EnhancerBySpringCGLIB$$ee258543

member service bean
current class : class com.sun.proxy.$Proxy89
  • spring.aop.proxy-target-class 는 기본적으로 true 값이다. 만일 해당 값을 false 로 변경한다면 인터페이스를 상속한 MemberServiceImpl 은 $Proxy89 로 dynamic proxy 로 생성된다. 하지만 현재 스프링은 cglib 기반의 프록시 생성을 기본으로 하고 있다.
Posted by doubler
,