동적 프록시(Dynamic Proxy)

박병욱·2026년 3월 4일

Spring

목록 보기
12/13
post-thumbnail

🔦 리플렉션

리플렉션(Reflection)은 런타임에 클래스, 메서드, 필드의 메타 정보에 접근해서 객체를 만들거나 메서드를 호출하거나 값을 읽고, 바꾸는 기능이다. 쉽게 말해 실행 중인 프로그램이 자기 자신(또는 다른 객체)의 내부 구조를 들여다보고, 뜯어고칠 수 있게 해주는 기술이라고 생각하면 된다. 보통 자바 코드를 짤 때는 어떤 클래스를 쓸지, 어떤 메서드를 호출할지 컴파일 시점에 이미 다 알고 사용할 수 있다. 하지만 리플렉션을 사용하면 런타임에, 이름만 알고 있는 클래스의 내부 정보를 동적으로 알아내고 조작할 수 있다.

자바 API 문서를 살펴보면, 리플렉션의 주요 특징을 4가지로 요약할 수 있다.

  1. 클래스 내부 정보 알아내기: 어떤 객체가 주어졌을 때, 그 객체를 만든 클래스가 무엇인지 모르더라도 리플렉션을 쓰면 그 객체 안에 어떤 변수가 있고, 어떤 함수가 있으며, 어떻게 객체를 생성하는지 탈탈 털어볼 수 있다.

  2. 알아낸 정보로 직접 조작하기: 단순히 "이런 메서드가 있네?" 하고 끝나는 것이 아니라 알아낸 정보를 바탕으로 직접 메서드를 실행시키거나, 변수의 값을 읽고 덮어쓰는 것도 가능하다. 코드에 하드코딩되어 있지 않은 동작을 런타임에 마음대로 끄집어내어 실행할 수 있는 것이다.

  3. 접근 제어자 무력화: 리플렉션의 가장 강력한 기능이다. 원래 자바에서는 private으로 선언된 변수나 메서드는 외부 클래스에서 절대 접근할 수 없지만, 리플렉션의 AccessibleObject를 사용하면 이 접근 검사를 무시하고 private 변수의 값을 강제로 읽거나 바꿀 수 있다. 일반적으로는 private으로 선언된 이유가 있을 것이기 때문에 사용에 유의하도록 하자.

  4. 배열의 동적 생성: 보통 배열은 크기와 타입을 미리 정해두지만, Array 클래스를 사용하면 실행 중에 원하는 타입과 크기를 동적으로 결정해서 배열을 만들어낼 수 있다.

 

정리하자면, 리플렉션은 개발자가 짠 코드를 프레임워크나 시스템이 실행 중에 유연하게 분석하고 조립하기 위해 만들어둔 만능 열쇠 정도로 생각하면 좋을 것 같다.

 

이제 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스를 이해하기 위한 최소한의 리플렉션 기술에 대해 코드로 알아보자.

package com.example.advanced_spring.proxy.jdkdynamic;

import java.lang.reflect.Method;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class ReflectionTest {

    @Test
    void reflectionV0() {
        Hello target = new Hello();

        // 공통 로직1
        log.info("Start...");
        String result1 = target.methodA();
        log.info("result1: {}", result1);

        // 공통 로직2
        log.info("Start...");
        String result2 = target.methodB();
        log.info("result2: {}", result2);

        /**
         * 13:03:01.047 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- Start...
         * 13:03:01.049 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling A...
         * 13:03:01.049 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result1: A
         * 13:03:01.049 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- Start...
         * 13:03:01.049 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling B...
         * 13:03:01.049 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result2: B
         */
    }

    @Slf4j
    static class Hello {
        public String methodA() {
            log.info("calling A...");
            return "A";
        }

        public String methodB() {
            log.info("calling B...");
            return "B";
        }
    }
}

위 코드에서 공통 로직1공통 로직2는 호출하는 메서드(methodA, methodB)만 다를 뿐 완전히 같은 구조다. 이 공통된 부분을 하나의 메서드로 뽑아낼 수 있을까? 중간에 호출하는 메서드가 다르기 때문에 일반적인 방법으로는 추상화하기가 몹시 까다롭다.

딱 저 메서드를 호출하는 부분만 동적으로 처리할 수 있다면 얼마나 좋을까? 이때 사용하는 것이 바로 리플렉션이다. 아래 리플렉션을 사용한 코드를 보자.

...

@Test
void reflectionV1() throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException {
    // 현재 패키지의 Hello 클래스의 내부 정보
    Class classHello = Class.forName("com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello");

    Hello target = new Hello();
    
    // callA 메서드 정보
    Method methodA = classHello.getMethod("methodA");
    Object result1 = methodA.invoke(target);
    log.info("result1: {}", result1);

	// callB 메서드 정보
    Method methodB = classHello.getMethod("methodB");
    Object result2 = methodB.invoke(target);
    log.info("result2: {}", result2);

    /**
     * 13:07:35.329 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling A...
     * 13:07:35.331 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result1: A
     * 13:07:35.331 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling B...
     * 13:07:35.331 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result2: B
     */
     
     ...

일단 클래스의 메타정보를 획득하기 위해 Class.forName("해당 클래스의 패키지 경로")를 사용한다. getMethod()를 이용해서 특정 메서드의 메타정보를 획득한 후에 invoke(target)로 실제 target 인스턴스의 methodA()methodB() 메서드를 동적으로 호출할 수 있다는 것이다.

 

꽤 복잡하다. 하지만 이렇게 메서드 정보를 획득해서 메서드를 호출하도록 처리해야 동적으로 메서드를 호출할 수 있도록 추상화할 수 있기 때문이다. 아래 코드를 보자.

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

 

사용은 호출을 원하는 메서드의 메타정보와 타깃 객체의 타입을 넘기면 된다.

...

@Test
void reflectionV2() throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetExceptio {
    Class classHello = Class.forName("com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello");

    Hello target = new Hello();

    Method methodA = classHello.getMethod("methodA");
    dynamicCall(methodA, target);  // 메서드의 메타정보를 파라미터로 넘김

    Method methodB = classHello.getMethod("methodB");
    dynamicCall(methodB, target);  // 메서드의 메타정보를 파라미터로 넘김

    /**
     * 13:13:49.726 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- Start...
     * 13:13:49.728 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling A...
     * 13:13:49.728 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result: A
     * 13:13:49.729 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- Start...
     * 13:13:49.729 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling B...
     * 13:13:49.729 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result: B
     */
}

이제 메서드 이름이 하드코딩 되어있지 않고, 메타정보(Method)를 파라미터로 넘겨 동적으로 메서드 호출이 가능해졌다.

하지만 리플렉션은 런타임에 동작하기 때문에 컴파일 시점에 에러를 잡을 수 없고 속도도 상대적으로 느리다. 따라서 일반적인 비즈니스 로직보다는 프레임워크 개발이나 매우 일반적인 공통 처리가 필요할 때 주의해서 사용해야 한다.

 

⚙ JDK 동적 프록시

프록시는 기존 코드는 건들지 않으면서 타깃 객체에 부가적인 기능을 부여해줄 수 있다. 하지만 프록시를 적용하려는 대상 클래스가 100개라면, 프록시 클래스도 100개를 만들어야 하는 엄청난 번거로움이 따른다.

다행히 자바는 프록시 객체를 동적으로 런타임에 만들어주는 JDK 동적 프록시 기술을 제공한다.

// A 인터페이스
package com.example.advanced_spring.proxy.jdkdynamic.code;

public interface AInterface {
    String call();
}
// A 인터페이스 구현체
package com.example.advanced_spring.proxy.jdkdynamic.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class AInterfaceImpl implements AInterface {
    @Override
    public String call() {
        log.info("A 호출...");
        return "a";
    }
}
// B 인터페이스
package com.example.advanced_spring.proxy.jdkdynamic.code;

public interface BInterface {
    String call();
}
// B 인터페이스 구현체
package com.example.advanced_spring.proxy.jdkdynamic.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class BInterfaceImpl implements BInterface {
    @Override
    public String call() {
        log.info("B 호출...");
        return "b";
    }
}

동적 프록시는 프록시 팩토리에 의해 런타임 시 동적으로 만들어진다. 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 프록시 인스턴스를 자동으로 만들어준다.

내가 해야 할 일은 그저 이 프록시가 수행할 부가기능(공통 로직)을 InvocationHandler에 정의하는 것뿐이다. 클라이언트의 모든 요청은 프록시를 거쳐 InvocationHandlerinvoke() 메서드로 집중된다.

 

아래는 java.lang.reflect 패키지에 있는 InvocationHandler 인터페이스다.

보다시피 하나의 메서드만 가진 아주 간단한 인터페이스다. invoke() 메서드는 리플렉션의 Method 인터페이스를 파라미터로 받고, 메서드를 호출할 때 전달되는 파라미터도 받는다. 동적 프록시는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현체의 invoke() 메서드로 넘기는 것이다. 타깃 인터페이스의 모든 메서드 요청이 하나의 메서드로 집중되기 때문에 중복되는 기능을 효과적으로 제공할 수 있다.

 

이제 해당 인터페이스를 구현해서 실행 시간을 측정하는 부가기능을 가진 구현체를 만들고 테스트 해보도록 하자.

package com.example.advanced_spring.proxy.jdkdynamic.code;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class TimeInvocationHandler implements InvocationHandler {

    private final Object target;  // 프록시가 호출할 대상(범용으로 사용하기 위해 Object로 받음)

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

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws IllegalAccessException, InvocationTargetException {
        log.info("TimeProxy 실행...");
        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;
    }
}
package com.example.advanced_spring.proxy.jdkdynamic;

import com.example.advanced_spring.proxy.jdkdynamic.code.AInterface;
import com.example.advanced_spring.proxy.jdkdynamic.code.AInterfaceImpl;
import com.example.advanced_spring.proxy.jdkdynamic.code.BInterface;
import com.example.advanced_spring.proxy.jdkdynamic.code.BInterfaceImpl;
import com.example.advanced_spring.proxy.jdkdynamic.code.TimeInvocationHandler;
import java.lang.reflect.Proxy;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class JdkDynamicProxyTest {

    @Test
    void dynamicA() {
        AInterface target = new AInterfaceImpl();
        TimeInvocationHandler handler = new TimeInvocationHandler(target);
        AInterface proxy = (AInterface) Proxy.newProxyInstance(
                AInterface.class.getClassLoader(),
                new Class[]{AInterface.class}, handler);

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

        /**
         * 13:39:51.584 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.TimeInvocationHandler -- TimeProxy 실행...
         * 13:39:51.586 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.AInterfaceImpl -- A 호출...
         * 13:39:51.586 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.TimeInvocationHandler -- TimeProxy 종료 resultTime=0
         * 13:39:51.587 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.JdkDynamicProxyTest -- targetClass=class com.example.advanced_spring.proxy.jdkdynamic.code.AInterfaceImpl
         * 13:39:51.587 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.JdkDynamicProxyTest -- proxyClass=class jdk.proxy3.$Proxy12
         */
    }

    @Test
    void dynamicB() {
        BInterface target = new BInterfaceImpl();
        TimeInvocationHandler handler = new TimeInvocationHandler(target);
        BInterface proxy = (BInterface) Proxy.newProxyInstance(
                BInterface.class.getClassLoader(),
                new Class[]{BInterface.class}, handler);

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

        /**
         * 13:40:09.633 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.TimeInvocationHandler -- TimeProxy 실행...
         * 13:40:09.635 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.BInterfaceImpl -- B 호출...
         * 13:40:09.635 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.TimeInvocationHandler -- TimeProxy 종료 resultTime=0
         * 13:40:09.636 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.JdkDynamicProxyTest -- targetClass=class com.example.advanced_spring.proxy.jdkdynamic.code.BInterfaceImpl
         * 13:40:09.636 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.JdkDynamicProxyTest -- proxyClass=class jdk.proxy3.$Proxy12
         */
    }
}

위의 테스트 코드에서 A 인터페이스, B 인터페이스를 각각 구현한 시간을 측정하는 부가기능이 탑재된 프록시를 만들어봤다. 동적 프록시는 Proxy 클래스의 newProxyInstance() 정적 팩토리 메서드를 사용해서만 생성할 수 있다.

보다시피 newProxyInstance() 정적 팩토리 메서드는 첫 번째 파라미터로 클래스 로더를 받아야 한다. 동적 프록시가 정의되는 클래스 로더를 지정하는 것이다. 두 번째는 동적 프록시가 구현해야 할 인터페이스다. 동적 프록시는 여러 개의 인터페이스를 구현할 수도 있기 때문에 인터페이스의 배열을 사용한다. 마지막으로 부가기능과 위임 관련 코드를 담고 있는 InvocationHandler 구현체를 제공해야 한다.

 

실행 순서를 자세히 뜯어보면서 아래와 같다.

  1. client는 JDK 동적 프록시의 call()을 실행한다.

  2. JDK 동적 프록시는 InvocationHandler.invoke()를 호출한다. 현재 예시 코드에서는 TimeInvocationHandler가 구현체로 있으므로 TimeInvocationHandler.invoke()가 호출된다.

  3. TimeInvocationHandler가 내부 로직을 수행하고, method.invoke(target, args)를 호출해서 target인 실제 객체를 호출한다.

  4. 실제 객체 인스턴스의 call()이 호출된다.

  5. 인스턴스의 call()의 실행이 끝나면 TimeInvocationHandler로 응답이 돌아오고, 시간 로그를 출력 후 결과를 반환한다.

 

정리하면 AInterfaceImpl, BInterfaceImpl 전용 프록시 클래스를 일일이 만든 것이 아니다. 런타임에 동적으로 프록시가 만들어지고 TimeInvocationHandler를 공통으로 사용하게 된다. 적용 대상이 100개여도 프록시 클래스를 만들 필요 없이 단일 핸들러만으로 부가기능을 부여할 수 있어 완벽하게 SRP 원칙을 지킬 수 있다.

 

🔨 JDK 동적 프록시 적용하기

기존의 LogTrace를 적용할 수 있는 InvocationHandler를 만들어보자.

package com.example.advanced_spring.proxy.config.v2_dynamicproxy.handler;

import com.example.advanced_spring.trace.TraceStatus;
import com.example.advanced_spring.trace.logtrace.LogTrace;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class LogTraceBasicHandler implements InvocationHandler {

    private static final Logger log = LoggerFactory.getLogger(LogTraceBasicHandler.class);
    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 {
		    // LogTrace에서 사용할 메시지
            String message = method.getDeclaringClass().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;
        }
    }
}

 

이제 동적 프록시를 사용할 수 있도록 수동으로 빈 등록을 진행해보자.

package com.example.advanced_spring.proxy.config.v2_dynamicproxy;

import com.example.advanced_spring.proxy.config.v2_dynamicproxy.handler.LogTraceBasicHandler;
import com.example.advanced_spring.proxy.v1.ProxyOrderControllerV1;
import com.example.advanced_spring.proxy.v1.ProxyOrderControllerV1Impl;
import com.example.advanced_spring.proxy.v1.ProxyOrderRepositoryV1;
import com.example.advanced_spring.proxy.v1.ProxyOrderRepositoryV1Impl;
import com.example.advanced_spring.proxy.v1.ProxyOrderServiceV1;
import com.example.advanced_spring.proxy.v1.ProxyOrderServiceV1Impl;
import com.example.advanced_spring.trace.logtrace.LogTrace;
import java.lang.reflect.Proxy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DynamicProxyBasicConfig {

    @Bean
    public ProxyOrderControllerV1 orderControllerV1(LogTrace logTrace) {
        ProxyOrderControllerV1 orderController = new ProxyOrderControllerV1Impl(orderServiceV1(logTrace));
        ProxyOrderControllerV1 proxy = (ProxyOrderControllerV1) Proxy.newProxyInstance(
                ProxyOrderControllerV1.class.getClassLoader(),
                new Class[]{ProxyOrderControllerV1.class},
                new LogTraceBasicHandler(orderController, logTrace)
        );

        return proxy;
    }

    @Bean
    public ProxyOrderServiceV1 orderServiceV1(LogTrace logTrace) {
        ProxyOrderServiceV1 orderService =  new ProxyOrderServiceV1Impl(orderRepositoryV1(logTrace));
        ProxyOrderServiceV1 proxy = (ProxyOrderServiceV1) Proxy.newProxyInstance(
                ProxyOrderServiceV1.class.getClassLoader(),
                new Class[]{ProxyOrderServiceV1.class},
                new LogTraceBasicHandler(orderService, logTrace)
        );

        return proxy;
    }

    @Bean
    public ProxyOrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
        ProxyOrderRepositoryV1 orderRepository = new ProxyOrderRepositoryV1Impl();
        ProxyOrderRepositoryV1 proxy = (ProxyOrderRepositoryV1) Proxy.newProxyInstance(
                ProxyOrderRepositoryV1.class.getClassLoader(),
                new Class[]{ProxyOrderRepositoryV1.class},
                new LogTraceBasicHandler(orderRepository, logTrace)
        );

        return proxy;
    }
}

아래 그림을 보면 알 수 있듯이, 이제 프록시 클래스를 직접 일일이 만들어주는 것이 아니라 OrderControllerV1, OrderServiceV1 각각 동적으로 프록시 클래스가 생성되고 두 클래스 모두 InvocationHandler를 참조하게 된다.

 

런타임에는 클라이언트가 요청하면 스프링 빈으로 등록된 OrderControllerV1 프록시가 컨트롤러에서 호출된다. 해당 JDK 동적 프록시는 LogTraceBasicHandler 핸들러를 호출하고 로그가 찍힐 것이다. 그리고 나서 핸들러가 실제 타깃 객체인 orderControllerV1Impl 구현체를 호출한다. 코드를 보면 알 수 있듯이 해당 구현체는 생성자 주입으로 orderServiceV1을 주입 받고 있다. 근데 OrderServiceV1도 프록시를 반환하고 있기 때문에 서비스 프록시 클래스가 호출된다. 그 프록시 클래스가 LogTraceBasicHandler를 호출하고 그 핸들러가 다시 orderServiceV1Impl 구현체를 호출하는 흐름이다.

 

추가로, 메서드 이름을 기준으로 특정 조건이 만족할 때만 로그를 출력하게 할 수도 있다. 로그를 남길 메서드 이름 패턴을 정의하고 생성자로 주입 받도록 하면 된다. 아래 코드를 보자.

package com.example.advanced_spring.proxy.config.v2_dynamicproxy.handler;

import com.example.advanced_spring.trace.TraceStatus;
import com.example.advanced_spring.trace.logtrace.LogTrace;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import org.springframework.util.PatternMatchUtils;

public class LogTraceFilterHandler implements InvocationHandler {

    private final Object target;
    private final LogTrace logTrace;
    private final String[] patterns;  // 로그를 출력하는 메서드 이름 패턴

    public LogTraceFilterHandler(Object target, LogTrace logTrace, String... patterns) {
        this.target = target;
        this.logTrace = logTrace;
        this.patterns = patterns;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String methodName = method.getName();
        if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
            return method.invoke(target, args);
        }

        TraceStatus status = null;
        try {
            String message = method.getDeclaringClass().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;
        }
    }
}

특정 메서드 이름이 매칭되는 경우에만 LogTrace를 로직을 실행한다. 이름이 매칭되지 않으면 실제 로직을 바로 호출하도록 한다. 위와 같이 스프링이 제공하는 PatternMatchUtils.simpleMatch()를 사용하면 매칭 로직을 쉽게 적용할 수 있다.

여기까지 JDK 동적 프록시를 살펴본 결과, 일일이 프록시 클래스를 정의하지 않고도 동적으로 프록시 객체를 생성할 수 있다는 매우 큰 장점이 있지만, 안타깝게도 프록시가 특정 인터페이스를 반드시 구현해야 한다는 단점이 있다.

 

🎃 CGLIB

JDK 동적 프록시는 인터페이스를 필수로 구현해야만 동작한다는 치명적인 단점이 있다. 만약 인터페이스 없이 구체 클래스만 있는 경우에는 어떻게 프록시를 적용해야 할까? 이때 사용하는 것이 바로 CGLIB 라이브러리다.

CGLIB는 바이트코드를 조작하여 동적으로 클래스를 생성하는 기술이다. 타깃 클래스를 상속받아 프록시를 생성하기 때문에 인터페이스 없이도 작동한다.

// 구현체 클래스만 존재
package com.example.advanced_spring.proxy.common.service;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ConcreteService {
    public void call() {
        log.info("ConcreteService 호출");
    } 
}

 

⛏️ CGLIB 적용

JDK 동적 프록시가 InvocationHandler를 사용했다면, CGLIB는 MethodInterceptor를 구현해야 한다.

package org.springframework.cglib.proxy;

public interface MethodInterceptor extends Callback {
		Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}

여기서 obj는 CGLIB가 적용된 객체, method는 호출된 메서드, args는 메서드를 호출하면서 전달된 파라미터, proxy는 메서드 호출에 사용되는 프록시다.

 

아래는 MethodInterceptor를 구현해서 CGLIB 프록시의 실행 로직을 정의한 TimeMethodInterceptor 구현체다.

package com.example.advanced_spring.proxy.cglib.code;

import java.lang.reflect.Method;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;

@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {

    private final Object target;

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

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

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

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

CGLIB에서 타깃 메서드를 호출할 때 리플렉션의 method.invoke()를 사용할 수도 있지만, MethodProxyproxy.invoke()를 사용하는 것이 성능상 훨씬 유리하다. 내부적으로 FastClass 기반으로 동작하여 리플렉션의 오버헤드를 줄여준다.

 

CGLIB는 Enhancer 객체를 통해 프록시를 생성한다. 타깃을 부모 클래스로 지정(setSuperclass)하여 상속을 통해 프록시를 덮어씌우는 방식이다.

package com.example.advanced_spring.proxy.cglib;

import com.example.advanced_spring.proxy.cglib.code.TimeMethodInterceptor;
import com.example.advanced_spring.proxy.common.service.ConcreteService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.cglib.proxy.Enhancer;

@Slf4j
public class CglibTest {

    @Test
    void cglib() {
        ConcreteService target = new ConcreteService();

        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(ConcreteService.class);
        enhancer.setCallback(new TimeMethodInterceptor(target));
        ConcreteService proxy = (ConcreteService) enhancer.create();
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());

        proxy.call();

        /**
         * 15:10:25.409 [Test worker] INFO com.example.advanced_spring.proxy.cglib.CglibTest -- targetClass=class com.example.advanced_spring.proxy.common.service.ConcreteService
         * 15:10:25.411 [Test worker] INFO com.example.advanced_spring.proxy.cglib.CglibTest -- proxyClass=class com.example.advanced_spring.proxy.common.service.ConcreteService$$EnhancerByCGLIB$$9dae7170
         * 15:10:25.411 [Test worker] INFO com.example.advanced_spring.proxy.cglib.code.TimeMethodInterceptor -- TimeProxy 실행
         * 15:10:25.417 [Test worker] INFO com.example.advanced_spring.proxy.common.service.ConcreteService -- ConcreteService 호출...
         * 15:10:25.417 [Test worker] INFO com.example.advanced_spring.proxy.cglib.code.TimeMethodInterceptor -- TimeProxy 종료 resultTime=6
         */
    }
}

정리하면 JDK 동적 프록시는 인터페이스를 구현해서 프록시를 만들고, CGLIB는 구체 클래스를 상속해서 프록시를 만드는 것이다.

 

CGLIB를 사용할 때의 클래스 의존 관계를 살펴보면 클라이언트(테스트 코드)가 ConcreteService를 호출하면 동적 프록시는 ConcreteService를 상속 받아서 생성된다. 그리고 생성된 동적 프록시가 MethodInterceptor를 호출하는 것이다.

 

실제 런타임에서는 클라이언트는 CGLIB가 만들어준 프록시를 호출하게 되고, 그 프록시가 timeMethodInterceptor를 호출하고, 로직을 수행하다가 중간에 invoke()를 통해 실제 타깃 객체를 call()하게 되는 것이다.

짐작했겠지만, CGLIB는 클래스를 상속 받아 사용하기 때문에 몇 가지 제약이 존재한다. 먼저, 부모 클래스의 생성자를 체크해야 하고, 클래스나 메서드에 final 키워드가 붙어 있는지 항상 확인해줘야 한다.


<참고 자료>
Java™ Platform, Standard Edition 8 API Specification
토비의 스프링 3.1 Vol 1: 스프링의 이해와 원리

0개의 댓글