[스프링 핵심 원리 고급편(4)] : 동적 프록시 기술

Loopy·2023년 1월 6일
0

스프링

목록 보기
7/16
post-thumbnail

☁️ 들어가기

대상 클래스 수만큼 로그 추적을 위한 프록시 클래스를 만들지 않기 위해서는, 프록시를 적용할 코드를 하나만 생성해두고 동적으로 프록시 객체를 만들어내면 된다.

JDK 동적 프록시 기술이나, CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프록시 객체를 동적으로 만들어낼 수 있는데 이를 이해하기 위해서는 먼저 리플렉션에 대해 알아보도록 하자.

☁️ 리플렉션(Reflection)

리플렉션 적용 전

@Slf4j
public class ReflectionTest {
    @Test
    void reflection0() {
        Hello target = new Hello();
        
        // 공통 로직 1 시작
        log.info("start");
        String resultA = target.callA();  // 호출하는 대상이 다름, 동적 처리 필요
        log.info("result={}", resultA);
        // 공통 로직 1 종료

        // 공통 로직 2 시작
        log.info("start");
        String resultB = target.callB();
        log.info("result={}", resultB);
        // 공통 로직 2 종료
    }
}

위와 같이 공통 로직이 반복되는 상황에서, 우리는 하나의 메서드로 뽑아서 중복을 제거하고 싶을 것이다. 물론 람다를 매개변수로 받아서 하나의 메서드로 추출할 수 있지만, 람다를 사용할 수 없는 자바 8 이전 버전이라고 가정을 해보면 쉽지 않은 일이다.

하지만 리플렉션을 사용한다면, 실행 중에 클래스나 메서드의 메타 정보를 활용해서 동적으로 호출하는 메서드를 변경할 수 있다. 즉 클래스나 메서드 정보를 동적으로 변경할 수 있는 것이다.

🔖 기본 문법
Class.forName("Xx$x") : 클래스 메타정보를 획득 ($ : 내부 클래스)
Class.getMethod("xx") : 해당 클래스의 실행시킬 메서드 메타정보를 획득
Method.invoke() : 획득한 메타정보로 실제 인스턴스의 메서드를 호출

리플렉션 적용 후

@Slf4j
public class ReflectionTest {
    @Test
    void reflection2() throws Exception {
        Class<?> classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");

        Hello target = new Hello();
        dynamicCall(classHello.getMethod("callA"), target);
        dynamicCall(classHello.getMethod("callB"), target);
    }
    
    private void dynamicCall(Method method, Object target) throws Exception {
        log.info("start");
        Object result = method.invoke(target);  // 실제 실행할 인스턴스 정보
        log.info("result={}", result);
    }
}

기존의 callA() , callB() 메서드를 직접 호출하는 하드 코딩이 아닌, Method 라는 메타정보 객체로 추상화함으로써 공통화가 가능해진 것을 볼 수 있다.

리플렉션 주의 사항

리플렉션은 런타임에 동작하기 때문에, 컴파일 시점에 오류를 잡을 수 없다.
예를 들어서 클래스에 존재하지 않는 메서드를 불렀을 때, 컴파일은 성공하지만 실행이 된 후에나 예외가 터지게 되는 것이다.

Method methodA = classHello.getMethod("callAAAAA"); // 오타를 낸다면?

따라서 리플렉션은 가급적이면 쓰지 않는 것이 좋으며, 프레임워크 개발이나 또는 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해서 사용해야 한다.

☁️ JDK 동적 프록시

동적 프록시 기술을 사용하면 개발자가 직접 프록시 객체 수백개를 만들지 않아도 되며, 런타임에 동적으로 개발자 대신 생성해준다. 또한 동적 프록시에 원하는 실행 로직을 지정할 수 있다.

🔖 주의 사항
JDK 동적 프록시는 인터페이스 기반으로 만들기 때문에, 인터페이스가 필수이다. 구체 클래스 기반 동적 프록시는 CGLIB, 다음에 소개할 스프링 프록시 팩터리를 사용하자.

먼저 간단한 예제를 통해 알아보자.
AB 인터페이스, 그리고 그 둘을 구현한 구현체를 만들었다.

public interface AInterface {
    String call();
}
@Slf4j
public class AImpl implements AInterface {

    @Override
    public String call() {
        log.info("A 호출");
        return "a";
    }
}

동적 프록시 적용 전

직접 프록시 객체를 인터페이스 개수에 맞추어서 생성해주어야 할 것이다.

동적 프록시 적용 후

JDK에서 프록시 객체들을 자동으로 만들어주기 때문에, 직접 생성할 필요가 없어진다. 그렇다면 어떻게 자동화해주는 것일까?

여기서 가장 중요한 InvocationHandler 에 주목해봐야 하는데, 이것이 바로 동적 프록시 객체에 적용할 공통 로직을 의미한다. 동적 프록시를 여러개 만들더라도 로그 추적기와 같이 공통화 로직은 모두 같기 때문에, InvocationHandler 에 담아두고 하나만 생성해두면 이후에 동적으로 생성된 여러 프록시 객체들이 호출만 해주면 되기 때문이다.

기본 구조는 아래와 같다.

package java.lang.reflect;

public interface InvocationHandler {
       public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}

리플렉션 파트에서 봤던 것처럼,Method 메타 데이터 객체를 활용해 공통화 하고 있는 것을 볼 수 있다.

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

동작 방식은 다음과 같다.

  1. 클라이언트는 JDK 동적 프록시의 call() 을 실행한다.
  2. JDK 동적 프록시는 InvocationHandler.invoke() 를 호출한다.
  3. 구현체인 TimeInvocationHandler 가 내부 로직을 수행하고, method.invoke(target, args) 를 호출해서 target 인 실제 객체(AImpl)를 호출한다.
  4. AImpl 인스턴스의 call() 이 실행된다.

예제 테스트

@Slf4j
public class JDKDynamicProxyTest {

    @Test
    void dynamicA() {
        AInterface target = new AImpl();  // 실제 실행할 로직
        TimeInvocationHandler handler = 
        		new TimeInvocationHandler(target);  // 프록시가 호출할 로직

        AInterface proxy = (AInterface) Proxy.newProxyInstance(
                  AInterface.class.getClassLoader(),
                  new Class[]{AInterface.class},
                  handler
        );
        proxy.call();   // handler에 있는 로직을 수행

        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());
    }
 }

먼저 new TimeInvocationHandler(target) 을 통해 동적 프록시에 적용할 핸들러 로직을 생성하고, 실제 실행할 객체를 주입해준다.

이후 Proxy.newProxyInstance 를 통해 동적 프록시를 생성하면 되는데 인자로 클래스 로더 정보, 프록시를 적용할 인터페이스, 핸들러 로직을 차례대로 넣어주면 된다. 그러면 해당 인터페이스를 기반으로 동적 프록시를 생성하고, 그 결과를 반환한다.

@Slf4j
public class TimeInvocationHandler implements InvocationHandler {

    private final Object target;   // 동적 프록시가 호출할 대상

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

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {  
        log.info("Time Proxy 실행");
        long startTime = System.currentTimeMillis();

        Object result = method.invoke(target, args); // 실제 객체 호출

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("TimeProxy 종료 resultTime={}", resultTime);
        return result;
    }
}

TimeInvocationHandler.invoke() 함수의 method 인자에는 call() 메서드의 정보가 담겨있을 것이고, method.invoke() 에서 리플렉션을 사용해서 실제 target 인스턴스의 메서드인 call() 을 호출할 수 있게 된다.

결과적으로 프록시 클래스를 수 없이 만들어야 하는 문제도 해결하고, 부가 기능 로직도 하나의 클래스에 모아서 단일 책임 원칙(SRP)도 지킬 수 있게 되었다.

로그 추적기에 적용

public class LogTraceBasicHandler implements InvocationHandler {

    private final Object target;
    private final LogTrace logTrace;

    public LogTraceBasicHandler(Object target, LogTrace logTrace) {
        this.target = target;
        this.logTrace = logTrace;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        TraceStatus status = null;
        try {
            String message = method.getClass().getSimpleName() + "." + method.getName() + "()";  // 메시지 구성
            status = logTrace.begin(message);

            Object result = method.invoke(target, args);
            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}
profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글