김영한님 자바 고급 2편을 보면서 리플렉션 이야기가 나왔는데, 리플렉션을 사용하면 성능이슈 + 비용을 크기 때문에 되도록 사용하지 않는게 좋다고 이야기를 많이 들어서 얼마나 안좋은지 궁금해서 직접 성능 테스트를 해보았다.
추가로 cglib과 JDK 동적 프록시를 통해 스프링에서 프록시 객체를 생성할 수 있는데, JDK Proxy가 리플렉션을 사용하고 성능이슈가 있기 때문에 cglib을 사용해서 프록시 객체를 만든다고 알고있는데, 구체적으로 얼마나 성능이 안좋은지도 같이 테스트해보았다.
리플렉션과 리플렉션을 사용하지 않았을때 객체 생성에 대해 테스트를 해보았고 테스트 방법은 아래와 같이 진행했다.
PerformanceUser
)PerformanceUser
를 생성하고, setter를 이용하여 name과 age를 설정PerformanceUser
를 생성하고, 리플렉션의 Field를 name, age를 set위 테스트는 결과는 for문을 이용하여 3번과 4번을 각각 8천만번 수행했을때 결과인데, 리플렉션을 사용했을때가 리플렉션을 사용하지 않았을때보다 시간이 10배정도 더 오래 걸렸고, CPU부하도 훨씬 더 오래걸린것을 확인할 수 있다.
리플렉션 사용 유무 | 소요시간 | CPU 사용률 |
---|---|---|
X | 4.9s | 25.1% |
O | 42s | 44.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);
}
}
스프링 AOP는 프록시 객체를 사용하여 반복되는 로직(aspect)를 처리하는데, 프록시 객체를 만드는 대표적인 기술로 JDK Dynamic Proxy와 cglib를 소개하고 있다.
관련 자료를 찾아보면 JDK Dynamic Proxy는 리플렉션을 사용하기 때문에 성능이슈가 있고 cglib을 이용하면 리플렉션을 사용하지 않고 바이트 코드를 직접 조작하기 때문에 JDK Dynamic Proxy보다 성능이 더 좋다는 글들을 많이 봤는데, 이것도 같이 테스트해보기로 했다.
cglib과 dymanic proxy의 경우 프록시 객체 생성과 이미 생성된 프록시 객체로 ASPECT 메서드 호출 테스트를 각각 진행했다.
InvocationHandler
인터페이스를 구현한 handler 정의 및 ASPECT 로직 추가MethodInterceptor
를 구현한 Interceptor 정의 및 ASPECT 로직 추가 => ASPECT 로직은 JDK Proxy 로직과 동일프록시 객체 자체 생성에 대한건 jdk dynamic proxy를 사용했을때가 cglib을 사용했을때보다 성능이 훨씬 잘 나온다. jdk dynamic proxy의 경우 리플렉션을 이용하여 객체를 생성하고, cglib은 바이트 코드 조작을 하는데, 리플렉션보다 바이트코드 조작이 더 많은 CPU 부하를 사용하는것을 확인할 수 있다.
종류 | 소요시간 | CPU 사용률 |
---|---|---|
jdk dynamic proxy | 5s | 35.2% |
cglib | 11.3s | 60.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);
}
MethodInterceptor
를 구현한 구현체에서, JDK Dynamic Proxy의 경우 InvocationHandler
를 구현한 구현체에서 로직이 동일종류 | 소요시간 | CPU 사용률 |
---|---|---|
jdk dynamic proxy | 29.9s | 13.2% |
cglib | 28.1s | 13.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 로직이 수행테스트하면서 스프링은 어떻게 프록시 객체를 생성하는지 궁금해졌다. 스프링은 스프링 컨텍스트가 생성될때 Cglib
과 jdk dynamic proxy
를 이용하여 프록시 객체를 빈으로 미리 생성하고, AOP 로직을 처리할때 미리 생성한 프록시 객체에서 ASPECT로직을 실행한다.
막연하게 알고 있던 개념들을 성능 테스트를 통해 구체적으로 어느정도 차이나는지 확인해서 개인적으로 많은 도움이 되었다. 실무에서 아직 리플렉션을 크게 사용할 일이 없었는데 이번 테스트를 통해 리플렉션을 사용할때 너무 많은 호출이 발생되면 CPU에 많은 부하를 주기 때문에 조심해서 사용해야겠다는 교훈을 얻었다.
추가로 리플렉션을 테스트하면서 cglib과 jdk dymanic proxy를 사용할때 어떤 부분이 성능 차이가 발생하는지도 명확히 알게되어 좋았다.