이번 글에서는 Java의 Reflection에 대해서 알아보도록 하겠다. 런타임에서 코드를 조작할 수 있는 강력한 기술이지만, 그만큼 부작용도 갖고 있는 기술이다. 한번 자세히 알아보자.
Java로 작성한 코드는 컴파일 과정을 거쳐서 바이트 코드로 변환되고, 이 바이트 코드는 JVM에 올라가서 OS에 맞는 바이너리 프로그램으로 변환되어 실행되게 된다.
여기서, 개발자가 뭔가를 할 수 있는 순간은 당연히, 컴파일하기 전까지이다.
그런데, Reflection을 사용하면 컴파일이 된 후, JVM에서 올라가서 실행되는 런타임 환경에서 동적으로 뭔가가 이루어지게 할 수 있다. 그렇게 할 수 있도록 기능을 제공하는 것이 바로 Java의 Reflection이다.
Reflection은 클래스, 메서드등등의 메타 정보에 접근하고 제어할 수 있게 해주는 기능인데, 이를 통해서 런타임에서 동적으로 동작할 수 있는 코드를 작성할 수 있게 된다.
먼저, Reflection을 적용하지 않은 일반적인 Java코드를 살펴보자.
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class HelloReflection {
public String callA() {
log.info("A");
return "A";
}
public String callB() {
log.info("B");
return "B";
}
}
위와 같은 아주 간단한 형태의 클래스가 있다고 생각해보자.
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@Slf4j
public class ReflectionTest {
@Test
@DisplayName("noReflection")
public void noReflection() throws Exception {
HelloReflection helloReflection = new HelloReflection();
String result1 = helloReflection.callA();
log.info("result={}", result1);
String result2 = helloReflection.callB();
log.info("result={}", result2);
}
}
그리고 위와 같은 테스트 코드를 실행해서, 정상적으로 callA()
와 callB()
가 잘 실행되는 것을 확인하자.
너무도 당연하게 A, B라는 문자열이 잘 찍혀있는 것을 볼 수 있고, 평범한 Java코드임을 확인할 수 있다.
그렇다면, 이 코드에 Reflection을 사용해보자.
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
@Slf4j
public class ReflectionTest {
@Test
@DisplayName("reflectionDynamic")
public void reflectionDynamic() throws Exception {
Class<?> classHello = Class.forName("hello.springaop.jdkdynamic.HelloReflection"); // 클래스의 메타 정보에 접근
HelloReflection helloReflection = new HelloReflection();
Method methodCallA = classHello.getMethod("callA"); // 메서드의 메타 정보에 접근
dynamicCall(methodCallA, helloReflection);
Method methodCallB = classHello.getMethod("callB");
dynamicCall(methodCallB, helloReflection);
}
private void dynamicCall(Method method, Object target) throws InvocationTargetException, IllegalAccessException {
Object result = method.invoke(target); // 메서드의 메타 정보를 갖고 .invoke() 실행함으로써, 실제 메서드를 실행시킴
log.info("result={}", result);
}
}
위 코드를 요약하자면, HelloReflection 메타 정보를 통해서 실제 인스턴스에 접근하고, callA, callB 메서드의 메타 정보를 통해서 실제 메서드를 실행하도록 한 것이다.
이 코드의 결과도 역시 Reflection을 사용하지 않은 코드와 동일하게 잘 출력이 된다.
여기서 주목할 점들은 주석을 작성한 부분들이다. 각 코드들은 클래스나 메서드의 메타 정보에 접근하고, 그것을 기반으로 메서드를 실행하거나 인스턴스를 생성할 수 있도록 해주는 것이다.
이러한 기능들이 바로 Java의 Reflection이라고 부르는 것들이다.
위 예시 코드에서도 보다시피, 동적으로 접근 및 실행을 제어할 수 있는 건 알겠는데 코드가 전혀 Java스럽지 않다.
그리고, 런타임 환경에서 클래스나 메서드를 핸들링하는 기술이여서, 컴파일 과정에서 버그가 잡히지 않게 된다. 예를 들어서, 클래스의 경로를 입력하는 부분에서 오타가 있다고 하더라도 단순한 문자열이므로, 컴파일 과정에서는 오타가 있는지 없는지 알 수 없고 버그가 있는채로 컴파일이되고, 프로그램이 실행되게 되는 것이다.
그래서 Reflection은 가급적이면 사용을 지양하는 것이 바람직하다.
Reflection을 활용해서 직접 기능을 구현하는 것이 리스크가 크다는 것이지 기술 자체가 좋지 않은 것은 전혀 아니다. Reflection의 중요한 점은 런타임 환경에서 동적으로 코드를 조작할 수 있다는 것이고, 이는 매우 강력한 이점이 된다.
그래서 Reflection을 활용한 강력한 기술들이 많은데, 몇가지 열거해보자면 아래와 같다.
Reflection을 활용한 기술 중, JDK 동적 프록시에 대해서 좀 더 자세히 알아보도록 하겠다.
다양한 메스드들에 대해서 각 메서드의 실행 시간을 측정하는 기능을 프록시 클래스를 통해서 구현해보도록 하겠다.
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
@Slf4j
@RequiredArgsConstructor
public class TimeProxy implements InvocationHandler {
private final Object target;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
log.info("시간 측정 시작");
long startMs = System.currentTimeMillis();
Object result = method.invoke(target, args);
long endMs = System.currentTimeMillis();
log.info("시간 측정 종료 : {}ms", endMs - startMs);
return result;
}
}
Reflection을 활용해서 클래스와 메서드의 메타 정보에 접근할 수 있는 틀을 갖고 있는InvocationHandler
를 상속받은 구현 클래스를 만들어주고, invoke()
메서드를 오버라이드하면 된다.
Object result = method.invoke(target, args);
이 부분이 실제 비지니스 로직이 수행되는 코드이다.
public interface SomeClass {
String runSomething();
}
먼저, 어떠한 클래스도 프록시를 사용할 수 있다는 의미로, SomeClass라는 인터페이스를 만들어주자.
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class SomeClassImpl implements SomeClass {
@Override
public String runSomething() {
log.info("Run something");
return "Data";
}
}
그리고, 이 인터페이스의 구현 클래스도 만들어주자.
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Proxy;
@Slf4j
public class JdkProxyTest {
@Test
@DisplayName("JDK동적프록시테스트")
public void JDK동적프록시테스트() throws Exception {
SomeClass someClass = new SomeClassImpl();
// JDK 동적 프로시 객체 생성
SomeClass proxy = (SomeClass) Proxy.newProxyInstance(
SomeClass.class.getClassLoader(),
new Class[]{SomeClass.class},
new TimeProxy(someClass)
);
proxy.runSomething();
log.info("proxyClass={}", proxy.getClass());
}
}
위 코드를 간단히 설명하자면, SomeClass 인스턴스를 하나 만들어주고, 이 인스턴스를 프록시 인스턴스로 만들어주는 과정을 거친다.
그러면, runSomething()
에 소요 시간을 측정하는 기능이 추가된 메서드를 갖고 있는 프록시 인스턴스가 만들어진다.
이 프록시 인스턴스는 SomeClass
라는 타입을 갖고 있지만, runSomething()
을 실행했을 때, 소요 시간까지 로그로 기록되게 된다.
이렇게 소요 시간만 측정될 뿐만 아니라, proxy
의 클래스 정보를 찍어보면 아래와 같이 SomeClass가 아닌 JDK 동적 프록시 클래스임을 확인할 수도 있다.
위 테스트 코드로 확인할 수 있듯이, JDK 동적 프록시 기술을 사용하면, 어떤 하나의 기능을 여러 클래스에 동적으로 추가할 수 있게 된다.
즉, 하나의 기능을 프록시 기능을 활용해서 여러 클래스에 추가하고 싶다면 오직 하나의 프록시 클래스만을 만들어주면 된다는 것이다.