프록시로 부가 기능을 추가하고 싶은데,
클래스를 생성할 때마다 매번 프록시 객체를 따로 만들어야 할까요?
이번글은 동적 프록시에 사용되는 리플렉션에 대해서 정리했습니다.
참고 강의 : 김영한의 스프링 핵심 원리 - 고급편
만약 새로 생기는 클래스 100만개가 있다면, 어떻게 될까요?
만약 새로운 클래스가 100만 개나 생긴다면, 이 100만 개의 클래스마다 일일이 프록시 객체를 생성해주는 것은 매우 비효율적입니다.
이러한 문제를 해결하기 위해, 동적 프록시기법을 활용할 수 있습니다.
동적 프록시는 리플렉션(reflection)을 이용해 런타임에 필요한 부가 기능을 프록시 객체에 주입하는 방식으로,
별도의 프록시 클래스를 100만 개 준비할 필요 없이, 프록시 생성 로직을 공통화하여 관리할 수 있습니다.
이로써, 메모리 절약은 물론, 유지보수성도 크게 개선할 수 있습니다.
리플렉션
리플렉션은 런타임 시간에 클래스의 메타 정보를 읽고, 객체를 생성하거나 메서드를 호출하고, 필드 값을 조회 및 수정할 수 있게 해주는 Java의 매커니즘입니다. 컴파일 시점이 아닌 실행 시점에 타입 정보를 다룰 수 있기 때문에, 프레임워크나 라이브러리에서 동적 프록시 생성, 의존성 주입, 어노테이션 기반 처리 등 다양한 기능에 활용이 됩니다.
코드로 리플렉션을 간단하게 알아보겠습니다.
ReflectionTest
@Slf4j
public class ReflectionTest {
@Test
void reflection0() {
Hello target = new Hello();
// 공통 로직 1
log.info("start");
String result1 = target.callA();
log.info("result={}", result1);
// 공통 로직 2
log.info("start");
String result2 = target.callB();
log.info("result={}", result2);
}
@Slf4j
static class Hello {
public String callA() {
log.info("callA");
return "A";
}
public String callB() {
log.info("callB");
return "B";
}
}
}
출력 결과
공통 로직 1부분과 공통 로직 2부분은 호출하는 메서드만 callA(), callB()로 다르고 코드 흐름이 완전히 동일합니다.
중복된 흐름이므로 하나의 메서드로 뽑아내자니 중간에 호출되는 메서드(callA, callB)가 서로 다르기 때문에 쉽지 않아 보입니다.
이 문제는 리플렉션을 사용해서 해결할 수 있습니다.
리플렉션을 적용한 간단한 코드는 다음과 같습니다.
ReflectionTset
@Test
void reflection1() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
Hello target = new Hello();
// callA 메서드 정보
Method methodCallA = classHello.getMethod("callA");
Object result1 = methodCallA.invoke(target);
log.info("result={}", result1);
// callB 메서드 정보
Method methodCallB = classHello.getMethod("callB");
Object result2 = methodCallB.invoke(target);
log.info("result2={}", result2);
}
출력 결과
클래스의 메서드를 따로 호출하지 않았는데도 리플렉션을 사용해서 callA와 callB 메서드를 호출해줍니다.
각 코드는 어떤 의미를 가질까요?
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
Class.forName을 사용해서 Hello 클래스의 메타정보를 획득합니다. $는 내부 클래스 구분을 위해서 사용합니다.
$가 없다면 ReflectionTestHello가 되고, 이는 어디서부터 클래스 이름의 시작인지 알 수 없게 됩니다.
Method methodCallA = classHello.getMethod("callA");
classHello.getMethod("callA")를 사용해서 해당 클래스(Hello)의 call 메서드 정보를 획득합니다.
Object result1 = methodCallA.invoke(target);
획득한 callA 메서드 메타 정보를 통해서 실제 인스턴스의 메서드를 호출합니다.
invoke 메서드를 호출하면서 인스턴스를 넘겨주면, 해당 인스턴스의 callA() 메서드를 찾아서 실행해줍니다.
그냥 target.callA(), target.callB()를 호출하면 되는데, 굳이 왜 이런 방식으로 하나요?
리플릭션을 사용하면 같은 흐름을 가지는 로직 1부분과 공통 로직 2부분을 공통 처리 로직으로 분리할 수 있습니다.
ReflectionTest - dynamicCall
@Test
void reflection2() throws Exception {
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
Hello target = new Hello();
Method methodCallA = classHello.getMethod("callA");
dynamicCall(methodCallA,target);
Method methodCallB = classHello.getMethod("callB");
dynamicCall(methodCallB, target);
}
private void dynamicCall(Method method, Object target) throws Exception {
log.info("start");
Object result = method.invoke(target);
log.info("result={}", result);
}
dynamicCall 메서드를 선언해서 공통 처리 로직으로 분리했습니다.
Method method 파라미터에는 호출할 메서드 정보가 넘어오고, Object target에는 실제 실행할 인스턴스 정보가 넘어오게 됩니다.
단, method.invoke(target)을 호출할 때 클래스와 메서드 정보가 서로 다르면 예외가 발생합니다.
예외가 발생하는 이유는 invoke 메서드 내부에 확인을 하는 로직이 존재합니다.
확인하는 로직이 정말 존재하는지 메서드 DFS를 해보겠습니다.
@CallerSensitive
@ForceInline // to ensure Reflection.getCallerClass optimization
@HotSpotIntrinsicCandidate
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz,
Modifier.isStatic(modifiers) ? null : obj.getClass(),
modifiers);
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
checkAccess 메서드의 내부 로직은 다음과 같습니다.
final void checkAccess(Class<?> caller, Class<?> memberClass,
Class<?> targetClass, int modifiers)
throws IllegalAccessException
{
if (!verifyAccess(caller, memberClass, targetClass, modifiers)) {
IllegalAccessException e = Reflection.newIllegalAccessException(
caller, memberClass, targetClass, modifiers);
if (printStackTraceWhenAccessFails()) {
e.printStackTrace(System.err);
}
throw e;
}
}
Verify 로직은 다음과 같습니다.
final boolean verifyAccess(Class<?> caller, Class<?> memberClass,
Class<?> targetClass, int modifiers)
{
if (caller == memberClass) { // quick check
return true; // ACCESS IS OK
}
Object cache = securityCheckCache; // read volatile
if (targetClass != null // instance member or constructor
&& Modifier.isProtected(modifiers)
&& targetClass != memberClass) {
// Must match a 2-list of { caller, targetClass }.
if (cache instanceof Class[]) {
Class<?>[] cache2 = (Class<?>[]) cache;
if (cache2[1] == targetClass &&
cache2[0] == caller) {
return true; // ACCESS IS OK
}
// (Test cache[1] first since range check for [1]
// subsumes range check for [0].)
}
} else if (cache == caller) {
// Non-protected case (or targetClass == memberClass or static member).
return true; // ACCESS IS OK
}
아래 로직에서 실제 호출자 클래스와 리플렉션 대상 클래스가 동일한 클래스인지 확인 합니다.
이 코드에서 caller는 실제 호출자 클래스를 memberClass는 리플렉션 대상 클래스를 의미합니다.
if (caller == memberClass) { // quick check
return true; // ACCESS IS OK
}
출력 결과
target.callA(), target.callB()로 각각 호출할때와 동일하게 출력되었습니다.
traget.callA()와 target.callB() 코드를 리플렉션을 사용해서 Method라는 메타정보로 추상화하여 공통 로직을 분리했습니다.
리플렉션을 사용할 때, 주의해야 할 점은 없나요?
리플렉션은 런타임 시점에 동작하기 때문에, 컴파일 시점에서는 타입 체크나 메서드 존재 여부 등을 확인할 수 없습니다.
이로 인해, 잘못된 접근이 있더라도 컴파일 에러가 아닌, 런타임 에러로 이어집니다.
이러한 런타임 에러는 실제 서비스 중에 사용자에게 직접 영향을 줄 수 있는 치명적인 오류로 이어질 가능성이 있습니다.
따라서, 리플렉션은 프레임워크 개발이나, 다양한 클래스에 공통적으로 부가 기능을 적용하는 경우에만 제한적으로 사용해야 합니다.
필요할 때는 반드시 충분한 검증과 주의가 필요합니다.
'Java' 카테고리의 다른 글
[JVM] 핫스팟 VM에서의 객체 돌아보기 (0) | 2025.04.16 |
---|---|
[JVM] OutOfMemory 예외 (0) | 2025.04.15 |
ArrayList가 크기를 늘리는 방법 (0) | 2025.02.08 |
[Java] Serialization (2) | 2025.01.27 |
부동 소수점 (0) | 2025.01.11 |