본문 바로가기
JAVA

JDK Dynamic Proxy

by oncerun 2022. 1. 22.
반응형

 

Proxy 패턴과 Decorator 패턴을 공부하면서 예제를 만들다 보면 상당히 불편한 점을 느끼게 된다. 

 

Interface 혹은 상속으로 클라이언트에게 다형성으로 프록시프락시 객체를 주입하려면 해당 인터페이스 혹은 상속받은 프락시 객체를 클라이언트 개수만큼 만들어야 한다는 사실이다. 

 

만약 현재 구동되고 있는 애플리케이션의 유지보수를 하다가 기존로직에는 영향을 주지 않고 프록시 객체를 통해 접근제어나 데코레이터를 추가해야 하는 일이 생긴다면 클라이언트 수만큼 프록시를 생성해 주입해주는 일은 시간 소유가 상당히 커서 비효율적이다.

 

그래서 동적으로 프록시 객체를 런타임 시점에 개발자가 아닌 자바가 만들어 주입해주는 역할이 필요한데, 이는 자바에서 기본적으로 제공해주는 JDK 동적 프록시를 사용해서 가능하다.

이를 이해하기 위해서는 Java의 Reflection의 지식이 필요한데 Reflection의 간단한 개념부터 JDK Dynamic Proxy에 대한 적용을 공부하려고 한다.

 

 

Reflection

 

리플렉션 기술을 사용하면 클래스나 메서드의 메타정보를 동적으로 획득하고, 코드또한 동적으로 호출할 수 있다.

 

프록시 객체는 target인 실제 구현체를 의존하고 있어야 하는데, 생각해보면 모든 프록시가 하는 일은 비슷하다. 

 

@Test
void reflection(){

    Hello target = new Hello();

    // common logic 1 start
    log.info("start");
    String result1 = target.callA();
    log.info("result = {}", result1);
    log.info("end");
    // common logic 1 end

    // common logic 2 start
    log.info("start");
    String result2 = target.callB();
    log.info("result = {}", result2);
    log.info("end");
    // common logic 2 end
}

다음과 같이 target의 메소드를 호출해주는데 실행하는 메서드만 다르다고 가정했을 때 추상화를 할 수 있다는 생각이 떠오른다. 그런데 이 작업이 생각보다 어려운 게 런타임 시점에 어떠한 메서드를 실행할지 선택하여 입력해야 하는데,  조건에 따라 원하는 메서드를 실행하는 부분에서 막힌다. 많은 메서드가 있는데 수많은 if문으로 처리할 수는 없지 않은가?

 

이제 Reflection을 통해 동적으로 Class의 정보를 가져오고, 사용할 target, 그리고 클래스에서 동적으로 메서드를 가져와보자. 

 

@Test
void reflection() throws Exception {

    Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
    Hello target = new Hello();

    Method methodCallA = classHello.getMethod("callA");
    dynamicCall(methodCallA, target);

    Method methodCallB = classHello.getMethod("callB");
    dynamicCall(methodCallB, target);
}

private void dynamicCall(Method method, Object target) throws InvocationTargetException, IllegalAccessException {
    log.info("start");
    Object result = method.invoke(target);
    log.info("result = {}", result);
    log.info("end");
}

@Slf4j
static class Hello {

    public String callA() {
        log.info("callA");
        return "A";
    }

    public String callB() {
        log.info("callB");
        return "B";

    }
}

 

 

Class.forName()을 통해 해당 클래스의 메타정보를 가져와 문자열로 원하는 Method객체를 추출할 수 있다

이 Method객체와 실행시킬 Instance를 통해 추상화시킨다.

 

dynamicCall에서 method.invoke(target)를 통해  실행시킬 instance의 메서드를 실행시킨 후 그 결과를 Object타입으로 받을 수 있다. 이렇게 공통 로직에 대한 추상화를 했으며, 메타정보를 통해 동적으로 클래스의 필요한 메서드를 가져와 실행시킨 후 그 결과를 받아 처리할 수 있게 되었다.

 

리플렉션을 사용하면 클래스와 메서드의 메타정보를 통해 동적으로 변하는 애플리케이션을 유연하게 만들 수 있지만 런타임에 동작하는 리플렉션은 컴파일 시점에 오류를 잡을 수 없다. 따라서 개발자의 실수 혹은 예상하지 못한 부분에서 오류가 터졌을 때는 사용자의 요청에 오류가 발생할 수 있다는 사실이다. 

보통 좋은 오류는 즉시 확인할 수 있는 컴파일 오류라는 말을 많이 들었기 때문에, 이는 매우 무서운 방식이다. 

그래서 리플렉션은 프레임워크 개발이나 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해서 사용한다.

 

 

 

JDK 동적 프록시 

 

JDK 동적 프록시는 인터페이스 기반으로 프록시를 동적으로 만들어 주기 때문에 인터페이스가 필수적이다.

 

동적 프록시를 만들기 전 필요한 요소를 생각해볼 필요가 있다.

 

프록시가 해야 하는 로직을 어디다 작성해야 하는가? 대한 문제인데, 기존 프록시객체를 직접 만들 때는 해당 인터페이스를 구현하여 사용했기 때문에 해당 메서드에 로직을 작성하면 됐지만 이번에는 JDK가 동적으로 프록시를 만들어 주기 때문에 특정한 규약이 필요하다.

 

1. JDK 동적 프록시에 적용할 로직은 InvocationHandler 인터페이스를 구현해서 작성하면 된다.

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

@Slf4j
public class LogInvocationHandler implements InvocationHandler {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        return null;
    }
}

 

* cglib패키지가 아닌 reflection을 사용해야 한다. 왜냐하면 JDK의 동적 프록시를 만드는 과정에서 Reflection의 InvationHandler를 사용하기 때문이다.

 

파라미터 정보

  • Object proxy : 프록시 자신
  • Method method : 호출된 메서드
  • Object [] args : 메서드를 호출할 때 전달한 인수

 

 

LogInvocationHandler를 구현해보자 

@Slf4j
public class LogInvocationHandler implements InvocationHandler {

    private final Object target;

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

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        log.info("로그를 남기는 프록시의 로직 실행");

        Object result = method.invoke(target, args);

        log.info("로그를 남기는 프록시의 로직 종료");

        return result;
    }
}

 

Proxy의 실 구현체의 target을 받는데 범용적으로 사용하기 위해 Object타입을 사용했다.

호출된 메서드 객체의 invoke를 통해 실 target의 메서드를 호출하며 전달된 인자 또한 같이 넘겨주어 실행한 결과를 return 한다.

 

 

2. 프록시를 사용할 클라이언트와 인터페이스를 간단히 정의하여 테스트

 

예상 흐름
클라이언트에서 서버 호출 -> JDK 동적 프록시 객체 -> 프록시 로직 적용 -> 실 구현체인 서버 호출

객체들을 스프링 Bean으로 적용하고 Config에서 Proxy를 사용하도록 설정한다.

 

@Configuration
public class DynamicProxyConfig {

    @Bean
    public ServerInterface server(){
        Server server = new Server();
        ServerInterface serverProxy = (ServerInterface) Proxy.newProxyInstance(ServerInterface.class.getClassLoader(),
                new Class[]{ServerInterface.class},
                new LogInvocationHandler(server));
        return serverProxy;
    }

    @Bean
    public Client client(){
        return new Client(server());
    }

}

 

실제 요청을 하는 Client는 JDK 동적 프록시 객체를 주입받는다. 따라서 클라이언트에서 주입받은 프록시 객체의 필요한 메서드를 실행하면 프록시 객체가 프록시에서 적용할 로직을 적용하고 실 구현체인 target의 로직을 실행하여 리턴한다.

 

Proxy.newProxyInstance()를 통해 동적으로 프록시 객체를 만들 수 있다. 클래스 로더 정보, 인터페이스 정보,  그리고 핸들러의 로직을 넣어주면 된다. 그러면 해당 인터페이스를 기반으로 동적 프록시를 생성하고 그 결과를 반환한다.

 

@SpringBootTest(classes = DynamicProxyConfig.class)
class ProxyApplicationTests {

   @Autowired
   Client client;

   @Test
   void dynamicProxy() {
      client.execute();
   }

}

 

 

실행 순서

  1. 클라이언트는 JDK 동적 프록시 객체인 Server의 call() 메서드를 실행한다.
    @Slf4j
    public class Client {
    
        private final ServerInterface server;
    
        public Client(ServerInterface serverInterface) {
            this.server = serverInterface;
        }
    
        public void execute() {
            log.info("클라이언트 execute() 실행");
            server.call();
            log.info("클라이언트 execute() 종료");
        }
    
    }​


  2. JDK 동적 프록시는 LogInvocationHandler.invoke()를 호출한다.
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    
        log.info("로그를 남기는 프록시의 로직 실행");
    
        Object result = method.invoke(target, args);
    
        log.info("로그를 남기는 프록시의 로직 종료");
    
        return result;
    }​
    LogInvationHandler의 내부 로직을 수행하고 method.invoke(target, args)를 호출해서 실제 객체인 Server의 call()을 호출한다.
  3. 실 구현체인 Server의 call()이 실행되고 리턴된다.
    @Slf4j
    public class Server implements ServerInterface{
        @Override
        public String call() {
            log.info("Server 로직 실행");
            log.info("Server 로직 종료");
            return "call()";
        }
    }​

 

 

JDK 동적 프록시 기술을 통해 적용대상만큼 프록시 객체를 만드는 일을 하지 않아도 되며, 부가 로직, 접근 제어등의 로직을 한번만 개발하여 공통으로 적용할 수 있다. 이는 단일 책임 원칙을 지킬 수 있으며, 반복적인 프록시 객체 생성 작업을 하지 않아도 된다. 

 

 

한계

JDK 동적 프록시는 인터페이스가 필수이기 때문에 Interface가 없는 경우에는 적용하기가 매우 어렵다. 따라서 CGLIB이라는 바이트코드를 조작하는 특별한 라이브러리를 사용해야 한다.

 

 

반응형

'JAVA' 카테고리의 다른 글

keytool  (0) 2022.11.14
가변인수  (0) 2022.11.05
Server Networking Proxy  (0) 2021.11.14
메일 발송  (0) 2021.11.06
JavaMail API  (0) 2021.10.24

댓글