[Spring & Java] Proxy 를 코드로 이해하기(1)

식빵·2022년 7월 30일
1

Spring Lab

목록 보기
15/33
post-thumbnail

Spring 에서는 AOP 를 위해서 Proxy 를 활용한다.
그런데 이 Proxy 라는 개념은 알겠는데, 코드로 직접 짜보지 않으니 뭔가
와닿지 않았다. 그래서 직접 코드로 짜보고 테스트해봤는데, 그 내용을 기록한다.
참고로 기록하는 내용의 코드들은 모두 Junit5 테스트 코드이다.

Proxy 에 대한 개념은 설명하지 않습니다!
Proxy 가 뭔지에 대한 최소 이해만 갖추고 보시면 되겠습니다.



🥝 개발환경 및 프로젝트 생성


나의 개발환경

  • 운영체제 : Window 10 Home
  • IDE : intellij Ultimate

프로젝트 생성

스프링 부트 프로젝트를 생성하며, 상세 설정은 아래와 같다.

  • JDK : Azul zulu version 17
  • dependencies : Lombok, Spring Web
  • Build Tool : Maven




🥝 Proxy 코드


🥥 테스트용 인터페이스, 클래스 생성

> 인터페이스 생성

package me.dailycode.playground.proxy.domain;

public interface User {
    void whoAmI();
    void say(String word);
}

> 인터페이스 구현 클래스

package me.dailycode.playground.proxy.domain;

import lombok.*;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserImpl implements User {

    private Long id;
    private String name;
    private String nickName;

    @Override
    public void whoAmI() {
        System.out.println("i am " + name);
    }

    @Override
    public void say(String word) {
        System.out.println("word = " + word);
    }

}

> 인터페이스 구현 없는 클래스

package me.dailycode.playground.proxy.domain;

import lombok.*;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserConcrete {

    private Long id;
    private String name;
    private String nickName;

    public void whoAmI() {
        System.out.println("i am " + name);
    }

    public void say(String word) {
        System.out.println("word = " + word);
    }

}




🥥 JDK Dynamic Proxy

자바 자체에서 제공하는 동적 Proxy 생성법이 JDK Dynamic Proxy 이다.
이 방식의 특징은 인터페이스 기반의 프록시 클래스만 생성할 수 있다는 것이다.

이때 인터페이스는 반드시 프록시의 부가기능이 적용될
타겟 객체가 구현하는 인터페이스와 같아야만 한다.

코드를 짜면서 이해해 보자.

// 일부 import 생략
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

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

class JdkProxyTest {

    @Test
    @DisplayName("jdk 동적 프록시 생성 테스트")
    void jdkProxyTest() {
    
    	User testUser = new UserImpl(1L, "devToroko", "dailyCode");
    
		// 워낙 옛날에 나온 것이라서 제네릭이 제공되지 않는다. 
        // Casting 필요.
        User testUserProxy = (User) Proxy.newProxyInstance(
                User.class.getClassLoader(),
                new Class[]{User.class},
                new jdkDynamicHandler(testUser)
        );
        // User 라는 인터페이스를 구현한 testUser1 을 proxy 의 "target" 으로 지정하겠다.

        testUserProxy.say("wow");
        
        
        System.out.println("\nproxyClass : " + testUserProxy.getClass());
    }


    // Jdk Dynamic Proxy 를 생성할 때, target 객체가 아닌 프록시 자체에서
    // 원하는 동작을 지정할 수 있는데, 그 로직을 InvocationHandler 인터페이스를 
    // 구현한 클래스를 통해서 작성이 가능하다.
    static class jdkDynamicHandler implements InvocationHandler {

        private final Object target;

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

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) 
        	throws Throwable {
        
        	// 이 프록시는 타겟 오브젝트의 메소드를 호출하기 전/후로
            // 간단한 로그를 남기는 작업만 한다.
            System.out.println("jdk Dynamic Proxy [START]");

			// 참고) 반환값이 없는 메소드면 null을 반환한다.
            Object returnValue = method.invoke(target, args);

            System.out.println("jdk Dynamic Proxy [END]");

            return returnValue;
        }
    }
}

이렇게하면 User 인터페이스를 구현한 클래스가 동적으로 생성되고,
그 클래스로 프록시 객체를 생성하게 된다. 그 중심에는 Proxy.newProxyInstance가 있다.

Proxy.newProxyInstance 메소드는 클래스 로더 정보, 인터페이스, 그리고 핸들러 로직
을 넣어주면 해당 인터페이스를 기반으로 동적 프록시를 생성하고 그 결과를 반환한다.


참고로 Lambda 를 통해서 더 간단하게 구현할 수도 있다.

@Test
@DisplayName("jdk 동적 프록시 생성 테스트(feat.Lambda)")
void jdkProxyLambdaTest() {
	User testUser = new UserImpl();
    User testUserProxy = (User) Proxy.newProxyInstance(
            jdkDynamicHandler.class.getClassLoader(),
            new Class[]{User.class},
            (proxy, method, args) -> {
                System.out.println("jdk Dynamic Proxy [START]");
                Object returnValue = method.invoke(testUser, args);
                System.out.println("jdk Dynamic Proxy [END]");
                return returnValue;
            }
    );
    testUserProxy.say("wow");
}

출력 결과

jdk Dynamic Proxy [START]
wow
jdk Dynamic Proxy [END]

proxyClass : class jdk.proxy2.$Proxy11
  • 프록시 적용으로 인해서 target 메소드 호출 전/후로 문자열이 프린트된다.
  • testUserProxy.getClass() 를 출력해서 보면 jdk.proxy2.$Proxy11 가 보인다.
    이것은 JdkDynamicProxy 기능이 동적으로 생성한 클래스의 이름이다.




🥥 CGlib Proxy

CGlib(Code Generator Library)는 바이트코드를 조작해서 "동적으로 클래스를 생성"하는 라이브러리다. 이때 동적 프록시 클래스 생성 방식은 타깃 객체의 클래스를 상속하는
자식 클래스를 생성하는 방식이다.

이런 특징 때문에 jdkDynamicProxy 처럼 인터페이스가 필수가 아니며,
클래스만 있어도 동적으로 프록시를 생성할 수 있다.

참고:

스프링에서는 외부 라이브러리인 CGLIB 를 자신들의 프레임워크 코드에 포함시켰다.
그렇기 때문에 Spring 관련 라이브러리를 사용하는 곳 어디서라도, CGLIB 라이브러리를 사용할 수 있다.


코드를 짜면서 이해해 보자.

// 일부 import 생략
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

class CglibProxyTest {

    @Test
    void cglibProxyTest() {
        UserConcrete target = new UserConcrete(1L, "devToroko", "dailyCode");

        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(UserConcrete.class);
        enhancer.setCallback(new CglibMethodInterceptor(target));

        UserConcrete proxy = (UserConcrete) enhancer.create();
        
        proxy.say("HELLO WORLD~");
        
        System.out.println("\nproxyClass Name: " + proxy.getClass());

    }

    // MethodInterceptor 인터페이스를 구현한 클래스를 통해서
    // 프록시 로직을 지정한다.
    static class CglibMethodInterceptor implements MethodInterceptor {

        private final Object target;

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

        @Override
        public Object intercept(Object o, Method method, 
        						Object[] args, MethodProxy methodProxy)
                                throws Throwable {

            System.out.println("Cglib Proxy Intercept [START]");
            
            // methodProxy 가 성능상 이점이 있어서 method 대신 쓴다.
            // CGLIB 가 권장하는 사항이다.
            Object resultValue = methodProxy.invoke(target, args);
            
            System.out.println("Cglib Proxy Intercept [END]");

            return resultValue;
        }
    }
}

여기서 눈여겨 볼 것은 enhancer.setSuperclass(UserConcrete.class);이다.
이렇게 설정을 하면 Enhancer 가 UserConcrete 클래스를 상속 받는 클래스를
동적으로 생성
하고, 그 클래스를 기반으로 프록시 객체를 생성하는 것이다.


JDK Dynamic Proxy 는 인터페이스 구현을 통한 프록시 생성이고
CGlib 는 클래스의 상속을 통한 프록시 생성임을 기억하자!


출력 결과

Cglib Proxy Intercept [START]
word = HELLO WORLD~
Cglib Proxy Intercept [END]

proxyClass Name: class {패키지명 생략}.UserConcrete$$EnhancerByCGLIB$$883f38a2
  • Proxy 로직이 제대로 동작하는 것을 확인
  • CGlib 를 통해서 생성된 동적인 클래스 이름은 대상클래스$$EnhancerByCGLIB$${랜덤코드} 같은 형식으로 지정된 것을 확인

인터페이스도 될까? YES!

@Test
void canUseInterfaceTest() {

	// 인터페이스 구현체 User_01 클래스 인스턴스 생성
    UserImpl target = new UserImpl(1L, "devToroko", "dailyCode");

    Enhancer enhancer = new Enhancer();
    
    // User 인터페이스를 superClass 로 지정
    enhancer.setSuperclass(User.class);
    enhancer.setCallback(new CglibMethodInterceptor(target));

	// 프록시 생성, 인터페이스로 Casting...
    User proxy = (User) enhancer.create();

    System.out.println("proxyClass Name: " + proxy.getClass());
    proxy.say("HELLO WORLD~");
}

출력 결과

proxyClass Name: class {패키지명 생략}.User$$EnhancerByCGLIB$$75ca306d
Cglib Proxy Intercept [START]
word = HELLO WORLD~
Cglib Proxy Intercept [END]




🥥 CGLIB 의 제약사항

중요한 내용이여서 따로 목차를 만들고 설명한다.

CGLIB 에는 몇가지 제약이 있다.

다시 말하지만 CGLIB 동작 방식은 타겟으로 지정할 클래스를 "상속"받은
클래스를 동적으로 생성하는 방식이다. 즉 동적으로 생성된 클래스는 자식 클래스를 의미한다.

그리고 이 자식 클래스를 생성할 때 자바의 기본적인 제약사항들이,
CGLIB 의 프록시 생성에도 영향을 준다. 제약사항은 아래와 같다.

  • 부모 클래스의 "기본 생성자"가 필요하다.
  • final 클래스를 사용할 수 없다.
  • final method 에 대해서는 프록시가 동작하지 않는다.

Java 에 대한 기본기가 있다면 위의 제약사항이 왜 생기는지는 알 것이다.
하지만 혹시나 모르니 테스트 코드를 작성해둔다.


// import org.assertj.core.api.Assertions;

static class NoDefaultConstructorClass {
    private Long id;

    // 기본 생성자가 없다!
    public NoDefaultConstructorClass(Long id) {
        this.id = id;
    }
}

@Test
@DisplayName("기본 생성자가 없다면 CGLIB 동적 프록시 생성 불가")
void cglibLimitTest() {

    NoDefaultConstructorClass target = new NoDefaultConstructorClass(1L);

    Assertions.assertThatThrownBy(() -> {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(NoDefaultConstructorClass.class);
        enhancer.setCallback(new CglibMethodInterceptor(target));
        enhancer.create();
    })
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessageContaining("Superclass has no null constructors but no arguments were given");

}



static final class FinalClass {
    private Long id;
}

@Test
@DisplayName("final class 는 CGLIB 동적 프록시 생성 불가")
void cglibLimit2Test() {

    FinalClass target = new FinalClass();

    Assertions.assertThatThrownBy(() -> {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(FinalClass.class);
        enhancer.setCallback(new CglibMethodInterceptor(target));
        enhancer.create();
    })
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessageContaining("Cannot subclass final");

}



static class FinalMethodClass {
    private Long id;

    public void proxyIntercept() {
        System.out.println("proxy intercepted");
    }
    public final void noProxyIntercept() {
        System.out.println("no proxy intercepted");
    }
}


@Test
@DisplayName("final method 는 프록시 기능 사용 불가")
void FinalMethodTest() {

    FinalMethodClass target = new FinalMethodClass();

    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(FinalMethodClass.class);
    enhancer.setCallback(new CglibMethodInterceptor(target));
    FinalMethodClass proxy = (FinalMethodClass) enhancer.create();

    proxy.proxyIntercept();
    System.out.println();
    proxy.noProxyIntercept();
    /*
    출력 결과:
    Cglib Proxy Intercept [START]
    proxy intercepted
    Cglib Proxy Intercept [END]

    no proxy intercepted
     */
    // 프록시 적용이 안된 것을 확인.
}




🥥 ProxyFactory

잘 생각해보면 우리는 어떤 타겟 객체를 만들고,
타겟 객체에 부가적으로 어떤 동작이 일어나는 코드를 JDK 동적 프록시에서는
"InvocationHander" 를 사용했고, CGLIB 에서는 "MethodInterceptor" 를 사용했을 뿐이지, 사실상 비슷한 기능과 동작 방식이다.

그래서 Spring 에서는 JDK Dynamic Proxy, CGlib Proxy 모두 같은 방식으로 사용할 수 있도록 추상화를 해놨고, 그 방식이 바로 ProxyFactory 이다.

그리고 앞서 말한 InvocationHander, MethodInterceptorAdvice라는 개념으로 추상화를 했다. 이 Advice 만 잘 만들면 내부적으로 알아서 ProxyFactoryInvocationHander, MethodInterceptor 둘 중 하나에게 요청을 위임하게 된다.

코드를 보면서 이해해 보자.

import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;

class ProxyFactoryTest {

    @Test
    @DisplayName("인터페이스 구현 클래스에 대한 ProxyFactory 동작 테스트")
    void interfaceImplementClassTest() {

		// User 인터페이스를 구현하는 클래스 User_01 사용
        User target = new UserImpl();

		// ProxyFactory 생성. 여기서 타깃 인스턴스 정보를 넘긴다.
        ProxyFactory proxyFactory = new ProxyFactory(target);
        
        // 타겟 정보가 ProxyFactory 에 있으므로 이전처럼 
        // 프록시의 핸들러 클래스 생성자에 target 정보를 넣어줄 필요가 없다.
        proxyFactory.addAdvice(new MyAdvice());
        
        // 프록시 생성
        User proxy = (User) proxyFactory.getProxy();

        // 프록시 적용 확인
        proxy.say("Hi There!");

        // 프록시는 어떤 클래스인지 확인
        System.out.println("\nproxyClass : " + proxy.getClass());
    }

    @Test
    @DisplayName("그냥 클래스에 대한 ProxyFactory 동작 테스트")
    void concreteClassTest() {

        UserConcrete concreteTarget = new UserConcrete();
        ProxyFactory proxyFactory = new ProxyFactory(concreteTarget);
        proxyFactory.addAdvice(new MyAdvice());

        UserConcrete proxy = (UserConcrete) proxyFactory.getProxy();

        proxy.say("Hi There!!!");

        System.out.println("\nproxyClass: " + proxy.getClass());

    }

    // org.aopalliance.intercept.MethodInterceptor; 를 사용할 것!
    // 참고로 MethodInterceptor 인터페이스는 는 Advice 인터페이스를 상속한다.
    static class MyAdvice implements MethodInterceptor {

		// 이전처럼 내부에 Target Object 를 저장하지 않아도 된다.
        // ProxyFactory 내부에서 이미 그 정보를 내재하기 때문이다.
        @Override
        public Object invoke(MethodInvocation invocation) throws 
        Throwable {
            System.out.println("MyAdvice intercept [START]");
            Object result = invocation.proceed();
            System.out.println("MyAdvice intercept [END]");
            return result;
        }
    }
}

중요한 특징 위주로 설명해보겠다.

ProxyFactory 생성자에서 타겟 인스턴스를 넘긴다.
이러면 ProxyFactory 는 해당 인스턴스 정보를 기반으로 프록시를 생성한다.
만약에 인스턴스가 어떤 인터페이스를 구현하고 있다면 JDK 동적 프록시를 사용하고,
인터페이스 구현이 없는 클래스면 CGLIB를 통해서 동적 프록시를 생성한다.

이렇게 함으로써 Adivce 로 프록시가 동작을 수행할 때, 내부적으로
InvocationHander, MethodInterceptor 를 사용할 지를 결정할 수 있게 되는 것이다.

이제 출력 결과를 통해서 위 내용을 확인해보자.



출력 결과(interfaceImplementClassTest)

MyAdvice intercept [START]
word = Hi There!
MyAdvice intercept [END]

proxyClass : class jdk.proxy2.$Proxy10
  • jdk 동적 프록시를 사용하는 것을 확인

출력 결과(concreteClassTest)

MyAdvice intercept [START]
word = Hi There!!!
MyAdvice intercept [END]

proxyClass: class {패티지명 생략}.UserConcrete$$EnhancerBySpringCGLIB$$b5e62a9e
  • CGLIB 를 사용하는 것을 확인

참고: org.springframework.aop.support.AopUtils 를 사용하면
ProxyFactory 에 의해서 생성된 프록시 객체가 Jdk 동적 프록시로 생성되었는지,
CGLIB 프록시로 생성되었는지를 알 수 있다.

AopUtils.isAopProxy(proxy);
AopUtils.isJdkDynamicProxy(proxy);
AopUtils.isCglibProxy(proxy);




🥥 proxyTargetClass=true

그런데 ProxyFactory 는 어떤 target 을 만나든 무조건 CGLIB 를 통한
프록시가 생성되도록 하는 방법을 제공한다.

그 방법은 아래와 같다.

@Test
@DisplayName("proxyTargetClass=true 테스트")
void proxyTargetClassTest() {
    User target = new UserImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.addAdvice(new MyAdvice());
    proxyFactory.setProxyTargetClass(true); // *** 
    User proxy = (User) proxyFactory.getProxy();
    Assertions.assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}

위에서 보이는 proxyFactory.setProxyTargetClass(true); 설정을 하면 된다.
이러면 타겟 객체가 인터페이스를 구현하든 말든 상관없이 클래스 기반의 프록시를 생성한다.




👏 한번 끊고 갑시다

내용이 너무 길어서 한번 끊고 가겠다.
다음 편이 언제 작성될지는 모르겠지만...
나도 주말에는 좀 쉬어야 하니 여기까지만!

다음글 링크: https://velog.io/@dailylifecoding/spring-and-java-proxy-usage-2

profile
백엔드를 계속 배우고 있는 개발자입니다 😊

0개의 댓글