리플렉션은 얼마나 느릴까?

김재연·2024년 11월 16일
8
post-thumbnail

리플렉션은 얼마나 느릴까?

배경

김영한님 자바 고급 2편을 보면서 리플렉션 이야기가 나왔는데, 리플렉션을 사용하면 성능이슈 + 비용을 크기 때문에 되도록 사용하지 않는게 좋다고 이야기를 많이 들어서 얼마나 안좋은지 궁금해서 직접 성능 테스트를 해보았다.

추가로 cglib과 JDK 동적 프록시를 통해 스프링에서 프록시 객체를 생성할 수 있는데, JDK Proxy가 리플렉션을 사용하고 성능이슈가 있기 때문에 cglib을 사용해서 프록시 객체를 만든다고 알고있는데, 구체적으로 얼마나 성능이 안좋은지도 같이 테스트해보았다.

리플렉션 테스트

리플렉션과 리플렉션을 사용하지 않았을때 객체 생성에 대해 테스트를 해보았고 테스트 방법은 아래와 같이 진행했다.

테스트 방법

  1. 사용할 객체를 생성(PerformanceUser)
  2. 리플렉션 사용 X : 기본생성자로 PerformanceUser 를 생성하고, setter를 이용하여 name과 age를 설정
  3. 리플렉션 사용 O : 리플렉션을 이용하여 PerformanceUser 를 생성하고, 리플렉션의 Field를 name, age를 set
  4. main문에서 tryCount만큼 2번과 3번 메서드 수행 및 visualVM 측정

테스트 결과

notUsingReflection

"리플렉션사용"

위 테스트는 결과는 for문을 이용하여 3번과 4번을 각각 8천만번 수행했을때 결과인데, 리플렉션을 사용했을때가 리플렉션을 사용하지 않았을때보다 시간이 10배정도 더 오래 걸렸고, CPU부하도 훨씬 더 오래걸린것을 확인할 수 있다.

리플렉션 사용 유무소요시간CPU 사용률
X4.9s25.1%
O42s44.5%

(테스트 건수 : 80,000,000)

리플렉션을 사용하면 컴파일 시점이 아닌 런타임에 바이트 코드를 조작한다고 하는데, 이 과정이 CPU 자원을 많이 사용하고 시간도 오래 걸리는걸 알 수 있다. 실무에서 리플렉션을 많이 호출한다면 서버 CPU에 부하를 많이 주기 때문에 사용하는데 주의해야한다.

코드

// 1. 사용할 객체 생성
@Getter
@Setter
public class PerformanceUser {
    private String name;
    private Integer age;
}

// 2. 리플렉션 사용 X
private static void usingNormal(int tryCount) {
        System.out.println("normal start");
        List<Object> list = new ArrayList<>(tryCount);
        for (int i = 0; i < tryCount; i++) {
            PerformanceUser performanceUser = new PerformanceUser();
            performanceUser.setName("name" + i);
            performanceUser.setAge(i);
            list.add(performanceUser);
        }
        System.out.println("normalEnd, listSize = " + list.size());
    }

// 3. 리플렉션 사용 O
private static void usingReflection(int tryCount) throws Exception {
        System.out.println("reflect start");
        List<Object> list = new ArrayList<>(tryCount);
        for (int i = 0; i < tryCount; i++) {
            Class<?> aClass = Class.forName("me.jimmy.blogsource.reflection.PerformanceUser");
            Constructor<?> constructor = aClass.getDeclaredConstructor();
            constructor.setAccessible(true);
            Object instance = constructor.newInstance();

            Field idField = aClass.getDeclaredField("name");
            idField.setAccessible(true);
            idField.set(instance, "name" + i);

            Field ageField = aClass.getDeclaredField("age");
            ageField.setAccessible(true);
            ageField.set(instance, i);
            list.add(instance);
        }

        System.out.println("reflect end, listSize = " + list.size());
    }

// 4. tryCount만큼 수행
public class ReflectionTestMain {
    public static void main(String[] args) throws Exception  {
        int tryCount = 10_000_00;
        long start = System.currentTimeMillis();
        usingReflection(tryCount);
//        usingNormal(tryCount);
        long end = System.currentTimeMillis();
        System.out.println("소요시간 : " + (end - start) + "ms");
        Thread.sleep(3000);
    }
}

Cglib과 동적 프록시 테스트

스프링 AOP는 프록시 객체를 사용하여 반복되는 로직(aspect)를 처리하는데, 프록시 객체를 만드는 대표적인 기술로 JDK Dynamic Proxy와 cglib를 소개하고 있다.

관련 자료를 찾아보면 JDK Dynamic Proxy는 리플렉션을 사용하기 때문에 성능이슈가 있고 cglib을 이용하면 리플렉션을 사용하지 않고 바이트 코드를 직접 조작하기 때문에 JDK Dynamic Proxy보다 성능이 더 좋다는 글들을 많이 봤는데, 이것도 같이 테스트해보기로 했다.

cglib과 dymanic proxy의 경우 프록시 객체 생성과 이미 생성된 프록시 객체로 ASPECT 메서드 호출 테스트를 각각 진행했다.

프록시 객체 생성 테스트

  1. JDK Proxy : JDK Proxy를 사용할 인터페이스 및 구현체 정의 InvocationHandler 인터페이스를 구현한 handler 정의 및 ASPECT 로직 추가
  2. Cglib : 클래스 + 메서드 정의 및 MethodInterceptor 를 구현한 Interceptor 정의 및 ASPECT 로직 추가 => ASPECT 로직은 JDK Proxy 로직과 동일
  3. trycount만큼 수행(테스트에서는 50,000,000번 수행)

결과

다이나믹 프록시를 활용하여 객체 생성

cglib을 이용한 프록시 객체 생성 테스트

프록시 객체 자체 생성에 대한건 jdk dynamic proxy를 사용했을때가 cglib을 사용했을때보다 성능이 훨씬 잘 나온다. jdk dynamic proxy의 경우 리플렉션을 이용하여 객체를 생성하고, cglib은 바이트 코드 조작을 하는데, 리플렉션보다 바이트코드 조작이 더 많은 CPU 부하를 사용하는것을 확인할 수 있다.

종류소요시간CPU 사용률
jdk dynamic proxy5s35.2%
cglib11.3s60.5%

(건수 : 50,000,000)

코드

// jdk dynamic proxy
private static void generateDynamicProxyAndRun(int tryCount) {
        List<DynamicProxyUser> users = new ArrayList<>();
        System.out.println("DynamicProxyTestMain#generateDynamicProxy start");
        for (int i = 0; i < tryCount; i++) {
            DynamicProxyUser proxyUser = (DynamicProxyUser) Proxy.newProxyInstance(
                    DynamicProxyGenerateTestMain.class.getClassLoader(),
                    new Class[]{DynamicProxyUser.class},
                    new DynamicProxyUserNameUpperCaseHandler(new DynamicProxyUserImpl())
            );
            users.add(proxyUser);
            proxyUser.hello("will " + i);
        }
        System.out.println("DynamicProxyTestMain#generateDynamicProxy end, userSize : " + users.size());
    }

// cglib
private static void generate(int tryCount) {
        List<CglibUser> users = new ArrayList<>();
        System.out.println("CglibGenerator#generate start");
        for (int i = 0; i < tryCount; i++) {
            CglibUser target = new CglibUser();
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(CglibUser.class);
            enhancer.setCallback(new UserNameUpperCaseMethodInterceptor(target));

            CglibUser proxy = (CglibUser) enhancer.create();
            users.add(proxy);
            proxy.hello("jimmy " + i);
        }
        System.out.println("CglibGenerator#generate end, userSize : " + users.size());
}

// aspect 로직
if (method.getName().equals("hello")) {
            String name = (String) args[0];
            args[0] = name.toUpperCase() + " hello world";
            return proxy.invoke(target, args);
}
  • aspect 로직의 경우 cglib은 MethodInterceptor 를 구현한 구현체에서, JDK Dynamic Proxy의 경우 InvocationHandler 를 구현한 구현체에서 로직이 동일

프록시 객체 메서드 호출 테스트

  1. jdk dynamic proxy와 cglib을 이용하여 프록시 객체 생성(1건)
  2. jdk dynamic proxy와 cglib에서 사용할 aspect정의, aspect 내부에서 for-loop를 이용하여 간단한 string 연산
  3. trycount만큼 수행(테스트에서는 50,000,000번 수행)

결과

jdk dynamic proxy method call test

cglib method call test

종류소요시간CPU 사용률
jdk dynamic proxy29.9s13.2%
cglib28.1s13.8%

(건수 : 100,000,000)

프록시 객체 생성의 경우 cglib이 훨씬 부하가 컸는데, 객체가 생성되고 aspect 메서드를 호출하는 측면에서는 두 방식 모두 큰 차이가 없는것을 확인할 수 있다.

코드

 @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        if (method.getName().equals("hello")) {
            String name = (String) args[0];
            for (int i = 0; i < 10; i++) {
                name += ("Test" + i).toUpperCase();
            }
            args[0] = name;
            return proxy.invoke(target, args);
        }
        return target;
}
  • InvocationHandler 를 구현한 메서드에서도 동일한 invoke 로직이 수행
  • 적절한 CPU 로직을 주기 위해 for-loop문과 string 연산을 aspect에 추가

스프링 프록시 객체 생성

테스트하면서 스프링은 어떻게 프록시 객체를 생성하는지 궁금해졌다. 스프링은 스프링 컨텍스트가 생성될때 Cglibjdk dynamic proxy 를 이용하여 프록시 객체를 빈으로 미리 생성하고, AOP 로직을 처리할때 미리 생성한 프록시 객체에서 ASPECT로직을 실행한다.

정리

막연하게 알고 있던 개념들을 성능 테스트를 통해 구체적으로 어느정도 차이나는지 확인해서 개인적으로 많은 도움이 되었다. 실무에서 아직 리플렉션을 크게 사용할 일이 없었는데 이번 테스트를 통해 리플렉션을 사용할때 너무 많은 호출이 발생되면 CPU에 많은 부하를 주기 때문에 조심해서 사용해야겠다는 교훈을 얻었다.

추가로 리플렉션을 테스트하면서 cglib과 jdk dymanic proxy를 사용할때 어떤 부분이 성능 차이가 발생하는지도 명확히 알게되어 좋았다.

소스코드

profile
이제 블로그 좀 쓰자

0개의 댓글