프록시를 공부하는 과정에서 동적프록시 기술로 CGLib을 접할 수 있었다.
CGLib은 클래스기반으로 바이트코드를 조작하여 동작하며, Dynamic Proxy에서와 같이 InvocationHandler
를 이용하는 방식( 리플렉션 사용 ), 그리고 MethodInterceptor
를 이용하는 방식( ASM 사용 )이 있었다.
이를 조사하는 과정에서 ChatGPT에게 물어봤더니 이런 말을 들을 수 있었다
- 리플렉션 방식은 JVM 환경과 호환성이 높고 코드가 상대적으로 단순하지만 성능이 다소 낮을 수 있습니다.
- ASM 방식은 더 높은 성능을 제공하지만 코드가 복잡하며, JVM 버전 간 호환성에 주의가 필요합니다.
여기서 궁금했던 부분은 왜 ASM방식이 리플렉션보다 성능이 뛰어나냐는 부분이었다.
이번 포스팅에서는 이와 관련한 내용들을 알아볼 생각이다.
리플렉션( java.lang.reflect ) 은 런타임 시에 프로그램 요소에 접근할 수 있는 기능이다.
Java 라이브러리에 포함된 기능으로, 런타임( 프로그램 동작 시 )에 코드를 통해 클래스 관련 정보에 접근하고 조작할 수 있는 기능을 제공한다.
// SampleController.java
@RestController @Slf4j
@InjectHelper(value = {SampleAService.class, SampleBService.class})
@RequiredArgsConstructor
public class SampleController {
private final InjectHelper.Finder serviceFinder;
@GetMapping("/")
public String getSomething(){
SampleAService service = serviceFinder.getBeanWithType(this.getClass(), SampleAService.class);
log.info(String.valueOf(service.getClass()));
return "/";
}
}
// InjectHelper.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface InjectHelper {
Class<? extends Injectable>[] value();
@Component
@RequiredArgsConstructor
class Finder{
private final ApplicationContext context;
public <T extends Injectable> T getBeanWithType(Class<?> source, Class<T> target){
InjectHelper helper = source.getAnnotation(InjectHelper.class);
if(helper == null){
throw new RuntimeException("부적절한 source 오류");
}
Class<? extends Injectable>[] values = helper.value();
for(Class<? extends Injectable> service : values){
if(service.equals(target)){
return context.getBean(target);
}
}
throw new RuntimeException("부적절한 target 오류");
}
}
}
리플렉션을 적용시킨 간단한 예시를 하나 가져왔다.
@InjectHelper
내의 Finder
클래스에서 애노테이션에 입력된 클래스 목록을 가져오는 등의 작업에 리플렉션이 사용되었다.
빌드를 통해 생성되는 클래스파일을 살펴보자
// SampleController.class
@RestController
@InjectHelper({SampleAService.class, SampleBService.class})
public class SampleController {
@Generated
private static final Logger log = LoggerFactory.getLogger(SampleController.class);
private final Finder serviceFinder;
@GetMapping({"/"})
public String getSomething() {
SampleAService service = (SampleAService)this.serviceFinder.getBeanWithType(this.getClass(), SampleAService.class);
log.info(String.valueOf(service.getClass()));
return "/";
}
@Generated
public SampleController(final Finder serviceFinder) {
this.serviceFinder = serviceFinder;
}
}
생성된 클래스파일은 원본 자바파일과 큰 차이가 없다는 사실을 알 수 있다.
즉, 리플렉션은 바이트코드 생성단계에 관여하지않고 프로그램 동작 시에 작업을 처리한다는 것을 확인할 수 있다.
리플렉션은 런타임 시에 클래스 정보를 획득하고, 이를 바탕으로 작업을 처리하는 기능을 제공한다.
즉, 런타임 시에 동작정보가 결정되기 때문에 컴파일 과정에서 최적화가 불가능하다는 단점이있다.
JVM은 JIT컴파일러를 이용하여 바이트코드를 기계어로 변환하는데, 리플렉션은 런타임 시에 동작이 결정되기때문에 이러한 최적화가 불가능하다. 따라서, 리플렉션이 아닌 일반코드에 비해 추가적인 비용이 발생하므로 성능이 떨어진다.
JIT 컴파일과 관련하여 좀 더 자세한 정보는 이 쪽 글을 참고해보자
대략적으로 초기 실행에서는 바이트코드를 인터프리터로 한 줄씩 읽지만, 반복해서 호출되는 부분은 핫스팟(HotSpot)으로 지정하고 이를 기계어로 컴파일 및 최적화를 진행한다.
ASM은 Java 바이트코드 조작 및 분석 프레임워크를 뜻한다.
ClassWriter cw = new ClassWriter(0);
cw.visit(V1_5, ACC_PUBLIC + ACC_ABSTRACT + ACC_INTERFACE, "com/example/vanilla/Comparable", null, "java/lang/Object", null);
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "LESS", "I", null, -1).visitEnd();
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "EQUAL", "I", null, 0).visitEnd();
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "GREATER", "I", null, 1).visitEnd();
cw.visitMethod(ACC_PUBLIC + ACC_ABSTRACT, "compareTo", "(Ljava/lang/Object;)I", null, null).visitEnd();
cw.visitEnd();
byte[] b = cw.toByteArray();
예를들어, 위 형태로 ASM을 이용해 클래스파일을 생성할 수 있다.
최종적으로 생성된 클래스파일은 cw.toByteArray()
를 이용해 byte[]
로 얻어낼 수 있으며, FileOutputStream
을 이용해 파일화 시키면 아래와 같다.
package com.example.vanilla;
public interface Comparable {
int LESS = -1;
int EQUAL = 0;
int GREATER = 1;
int compareTo(Object var1);
}
리플렉션과 비교하여 빌드단계에서 클래스파일 자체를 생성하는 특징이 있다.
클래스파일이 생성되므로 JVM의 최적화를 받을 수 있기때문에 추가적인 자원을 소모하지 않는다.
따라서, 리플렉션과 비교하여 성능이 우세하다.
정리하자면 리플렉션은 클래스파일을 생성하지않고, 런타임 시에 동작이 결정되기 때문에 JVM의 최적화를 받지 못한다.
반면, ASM같은 바이트코드 조작방식은 실제 클래스파일을 생성해내므로 JVM의 최적화를 받을 수 있다.
따라서, 성능이 중요하다면 리플렉션은 최대한 지양해야한다.