Java Reflection 내부 구조 — Method Inflation, MagicAccessorImpl, 그리고 JEP 416의 MethodHandle 재구현
Method.invoke()한 줄이 실제로 무엇을 하는지 들여다봅니다. JDK 17 이하의 inflation 메커니즘과 JDK 18(JEP 416)이 도입한 MethodHandle 기반 재구현을 같은 호출 경로 위에서 비교합니다. Jackson, Spring, Hibernate처럼 리플렉션을 빈번하게 쓰는 라이브러리의 동작을 이해하려는 자바 백엔드 개발자를 대상으로 합니다.
들어가며
리플렉션은 자바에서 가장 오래된 우회 통로 중 하나입니다. Class.forName(...).getMethod("foo").invoke(target) 같은 코드는 1.1 시절부터 거의 그대로의 모습으로 살아남았고, 직렬화·DI 컨테이너·ORM·테스트 프레임워크의 기반이 되어 있습니다. API 모양은 단순하지만, 그 단순함은 JVM 내부의 상당한 복잡성을 가리고 있습니다.
본문에서는 Method.invoke() 한 줄이 호출됐을 때 실제로 어떤 경로를 거치는지를 두 시대로 나누어 따라가 봅니다. JDK 17까지의 MethodAccessor 인플레이션(inflation) 구조와, JDK 18에서 JEP 416이 도입한 MethodHandle 기반 재구현이 그 두 시대입니다. API는 동일하지만 내부 구현이 통째로 교체된 사례로, 자바 백엔드 코드 대부분이 의식하지 못한 채 영향을 받습니다.
타임라인
| JDK 버전 | 변화 |
|---|---|
| 1.1 (1997) | java.lang.reflect 도입. setAccessible 없음 |
| 1.2 (1998) | AccessibleObject.setAccessible 추가 |
| 1.4 | MethodAccessor 인플레이션 도입. NativeMethodAccessorImpl ↔ GeneratedMethodAccessorN |
| 7 (2011) | java.lang.invoke.MethodHandle 도입 (JSR 292, invokedynamic) |
| 9 (2017) | 모듈 시스템. setAccessible이 모듈 경계와 만남. 패키지 이름이 sun.reflect → jdk.internal.reflect로 이동 |
| 11 (2018) | Constant_Dynamic(condy) 정식화. Lookup.defineHiddenClass 추가 |
| 15 (2020) | hidden class 정식 출시 |
| 18 (2022) | JEP 416 — 리플렉션을 MethodHandle 위에 재구현 |
| 21 (2023) | LTS. MagicAccessorImpl 가정에 의존한 코드가 본격적으로 깨지기 시작 |
JEP 416은 1.4부터 25년간 유지된 인플레이션 구조를 단번에 교체한 사건이고, 그 사이에 MethodHandle·hidden class·condy 같은 도구가 충분히 성숙해서야 가능했던 변화입니다.
표면: Method.invoke()의 호출 경로
java.lang.reflect.Method는 메서드 메타데이터를 표현하는 AccessibleObject의 하위 클래스이지만, 실제 호출 로직은 본인이 직접 들고 있지 않습니다. 대신 MethodAccessor라는 위임 인터페이스에 호출을 던집니다.
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, InvocationTargetException {
// 접근 제어 검사 (생략)
MethodAccessor ma = methodAccessor;
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
methodAccessor 필드는 lazy로 채워지며, 최초 1회 acquireMethodAccessor()가 ReflectionFactory에 위임해 실제 구현체를 만듭니다. ReflectionFactory가 어떤 구현체를 돌려주는지가 시대를 가릅니다.
flowchart LR
A[Method.invoke] --> B{methodAccessor cached?}
B -- no --> C[ReflectionFactory.newMethodAccessor]
C --> D[MethodAccessor impl]
B -- yes --> D
D --> E[Target method body]
ReflectionFactory는 jdk.internal.reflect 패키지에 있고, 직렬화 프레임워크와 DI 컨테이너가 사용하는 몇 가지 내부 진입점도 함께 노출합니다. 자바 9의 모듈 시스템 이후로는 모듈 경계를 우회한 접근이 막혔지만, JDK 내부에서는 여전히 모든 리플렉션 호출이 이 팩토리를 거칩니다.
Method만 그런 게 아닙니다. Constructor.newInstance()는 ConstructorAccessor로, Field.get/set은 FieldAccessor로 동일한 위임 패턴을 따릅니다. 세 인터페이스 모두 ReflectionFactory가 만들어 내고, 캐싱 시점·인플레이션 정책·접근 검사 면제 트릭을 공유합니다. 다만 시대 2에 와서 FieldAccessor의 백엔드는 MethodHandle이 아니라 VarHandle로 갈라집니다. VarHandle이 필드 읽기·쓰기·CAS·volatile 변종을 한 핸들 안에서 표현할 수 있기 때문입니다.
시대 1: MethodAccessor 인플레이션 (JDK 17 이하)
두 가지 구현체와 임계값
JDK 17까지의 ReflectionFactory.newMethodAccessor()는 다음 두 종류의 MethodAccessor 중 하나를 돌려줍니다.
NativeMethodAccessorImpl— JNI를 통해 VM의 메서드 호출 진입점으로 들어가는 구현. 부팅 시 사용됩니다.MethodAccessorGenerator가 동적으로 생성한GeneratedMethodAccessorN바이트코드 — 자바 바이트코드로 작성된 직접 호출.
기본 정책은 인플레이션입니다. NativeMethodAccessorImpl이 호출될 때마다 카운터를 1씩 올리고, 그 값이 sun.reflect.inflationThreshold(기본 15)에 도달하면 MethodAccessorGenerator.generateMethod()가 동기적으로 호출되어 그 메서드 전용의 새 바이트코드 클래스를 만들어 냅니다. 이후의 호출은 새 구현체로 대체됩니다.
flowchart TB
A[Method.invoke #1..#15] --> B[NativeMethodAccessorImpl]
B --> C[JNI -> VM method entry]
A2[Method.invoke #16+] --> D[GeneratedMethodAccessor1]
D --> E[Direct bytecode call]
B -. "after 15 invocations" .-> F[MethodAccessorGenerator.generateMethod]
F -. "spin bytecode" .-> D
이 임계값은 두 가지 시스템 프로퍼티로 조정할 수 있었습니다.
sun.reflect.inflationThreshold— 인플레이션이 일어나는 호출 횟수 (기본 15)sun.reflect.noInflation—true이면 임계값을 0으로 만들어 첫 호출부터 바로 바이트코드 생성. 인플레이션 자체를 끄는 것이 아니라 "처음부터 inflate된 상태로 시작"하는 의미입니다.
왜 두 가지 구현체를 두었는가
순수 바이트코드 호출이 더 빠른데도 처음 15회를 JNI 진입으로 처리한 이유는 클래스 로딩 비용 때문입니다. GeneratedMethodAccessorN을 만들려면 MethodAccessorGenerator가 asm이 없던 시절의 자체 클래스 작성기로 바이트코드를 한 줄씩 만들어 defineClass를 호출해야 합니다. 한 번 쓰고 버려지는 리플렉션 호출에 이 비용을 매번 지불할 수는 없기 때문에, "충분히 많이 쓰일 때만" 인플레이션을 발동시킨 것입니다.
JIT 관점에서도 두 구현체는 차이가 큽니다. NativeMethodAccessorImpl.invoke0는 네이티브 메서드라서 JIT이 내부로 들어가지 못합니다. 반면 GeneratedMethodAccessorN은 자바 바이트코드로 작성되어 있고, 메서드 본문이 단순한 직접 호출(return target.foo(args[0], args[1]))이라 인라이닝 후보가 됩니다. 핫 패스에서 Method.invoke 자체가 거의 사라질 수 있다는 뜻입니다.
생성된 GeneratedMethodAccessor1.invoke의 바이트코드는 대략 다음과 같이 보입니다. MethodAccessorGenerator가 한 줄씩 작성하는 결과물입니다.
public Object invoke(Object target, Object[] args) {
// 1. target을 declaring class로 캐스팅
com.example.Foo t = (com.example.Foo) target;
// 2. args 길이 검사 (생략 가능)
// 3. 박싱된 인자를 풀어 직접 호출
int result = t.add((Integer) args[0], (Integer) args[1]);
// 4. 결과를 박싱해 반환
return Integer.valueOf(result);
}
각 메서드마다 호출 시그니처에 맞춰 캐스팅·언박싱·박싱이 하드코딩됩니다. MethodAccessorGenerator는 ASM이 표준화되기 전 시점에 작성된 코드라 자체 바이트코드 빌더를 들고 있고, 한 메서드 호출을 위해 클래스 파일 한 개를 통째로 만드는 비싼 경로입니다.
MagicAccessorImpl이라는 마법
생성된 GeneratedMethodAccessorN은 일반 클래스가 아닙니다. 모든 리플렉션 생성 클래스는 jdk.internal.reflect.MagicAccessorImpl(JDK 11 이전엔 sun.reflect)을 상속합니다. 이름 그대로 마법인데, JVM은 이 클래스의 하위 클래스에 대해 바이트코드 접근 제어 검사를 면제합니다.
이 면제가 없으면 GeneratedMethodAccessor1이 target.privateField에 접근하는 바이트코드가 IllegalAccessError로 죽습니다. setAccessible(true)로 자바 단의 접근 검사를 끌 수는 있지만, 그건 Method.invoke() 진입 시점의 검사일 뿐 생성된 바이트코드 자체에 적용되는 건 아닙니다. JVM은 MagicAccessorImpl 상속 여부를 별도로 검사해 클래스 검증 단계에서 이 클래스들의 비공개 접근을 통째로 통과시킵니다.
flowchart LR
A[GeneratedMethodAccessor1] -->|extends| B[MagicAccessorImpl]
B -. "JVM bypass" .-> C[Skip bytecode access checks]
D[Normal class] -->|extends| E[Object]
E -. "No bypass" .-> F[Verify access at link time]
리플렉션이 private 멤버에 접근할 수 있는 핵심 메커니즘이 바로 이 MagicAccessorImpl 트릭입니다. JEP 416 이전의 리플렉션은 사실상 setAccessible(true) + MagicAccessorImpl 면제의 조합으로 동작했습니다.
Metaspace 누적 문제
인플레이션 구조의 가장 큰 운영 부담은 클래스 누적이었습니다. 서로 다른 Method 객체 1000개에서 각각 15회씩 호출이 일어나면 GeneratedMethodAccessor1부터 GeneratedMethodAccessor1000까지 1000개의 클래스가 메타스페이스에 적재됩니다. 이 클래스들의 클래스 로더는 보통 부트스트랩 혹은 시스템 로더라 GC 대상이 잘 되지 않습니다.
Jackson이나 Hibernate처럼 모든 엔티티의 모든 게터·세터를 리플렉션으로 호출하는 라이브러리에서는 이 누적이 수만 개 단위로 늘어났고, Metaspace 압박과 그로 인한 풀 GC 빈발이 알려진 문제였습니다. Oracle 지원 문서에도 "sun.reflect.GeneratedMethodAccessorN이 Metaspace를 채우는 현상"이 별도 항목으로 기재되어 있습니다.
시대 2: JEP 416 — MethodHandle 기반 재구현 (JDK 18)
동기
JDK에는 이미 리플렉션과 별도로 java.lang.invoke 패키지가 있습니다. MethodHandle은 JDK 7의 invokedynamic을 위해 도입된, 타입 안전하면서 직접 호출 가능한 메서드 참조입니다. VarHandle은 JDK 9에서 추가된 필드 액세스의 동일 개념입니다. 두 구조는 핫 패스에서 직접 호출만큼 빠르도록 JIT 친화적으로 설계되어 있습니다.
JEP 416의 동기는 단순합니다. "왜 리플렉션과 MethodHandle이 별개의 구현으로 유지되어야 하는가?" java.lang.reflect와 java.lang.invoke는 각각 자체적인 바이트코드 생성기, 캐시, 접근 제어 우회 메커니즘을 들고 있었고, 같은 일을 두 번씩 정의해 둔 상태였습니다. 한쪽이 다른 쪽 위에 다시 세워지면 유지보수 비용이 절반으로 줄어듭니다.
JEP 416은 이 통합을 수행했습니다. JDK 18에서 정식 릴리스됐고, java.lang.reflect.Method·Constructor·Field의 내부 구현이 MethodHandle·VarHandle 위로 옮겨갔습니다. 공개 API는 한 줄도 바뀌지 않았습니다.
새 호출 경로
JDK 18 이후의 ReflectionFactory.newMethodAccessor()는 기본적으로 DirectMethodHandleAccessor를 돌려줍니다. 이 클래스는 jdk.internal.reflect 패키지에 있고, 내부에 대상 메서드의 MethodHandle을 들고 있습니다.
final class DirectMethodHandleAccessor extends MethodAccessorImpl {
private final Class<?> declaringClass;
private final int paramCount;
private final MethodHandle target;
private final int flags;
// ...
}
flags는 비트 패킹된 정수입니다.
IS_STATIC_BIT = 0x0200HAS_CALLER_PARAM_BIT = 0x0100PARAM_COUNT_MASK = 0x00FF
파라미터 개수까지 flags 안에 끼워 넣은 이유는 한 번의 필드 로드로 분기에 필요한 정보를 모두 가져오기 위함입니다. invoke()는 핫 패스에서 작은 정수 비트 검사 몇 번으로 분기를 마칩니다.
HAS_CALLER_PARAM_BIT는 caller-sensitive 메서드를 위한 분기입니다. Class.forName(String), MethodHandles.lookup()처럼 호출자 클래스에 따라 동작이 달라지는 메서드는 @CallerSensitive로 표시되어 있고, 리플렉션을 통해 호출될 때도 진짜 호출자(스택 위쪽 자바 프레임)를 전달받아야 합니다. JEP 416 구현은 caller 인자를 받는 어댑터 핸들을 미리 준비해 두고, 이 비트가 켜져 있을 때 호출자 클래스를 추가 인자로 주입합니다. JDK 17까지는 이 처리가 Reflection.getCallerClass()의 특수 호출과 NativeMethodAccessorImpl의 분기로 분산되어 있었는데, 단일 핸들 합성으로 깔끔하게 정리됐습니다.
파라미터 개수 0~3은 인라인 분기
DirectMethodHandleAccessor.invokeImpl()은 파라미터 개수가 0, 1, 2, 3인 경우를 별도 분기로 처리하고, 그 이상은 가변 인자 경로로 떨어뜨립니다. 각 분기는 target.invokeExact(...)를 직접 호출합니다.
@ForceInline
Object invokeImpl(Object receiver, Object[] args) throws Throwable {
return switch (paramCount) {
case 0 -> target.invokeExact(receiver);
case 1 -> target.invokeExact(receiver, args[0]);
case 2 -> target.invokeExact(receiver, args[0], args[1]);
case 3 -> target.invokeExact(receiver, args[0], args[1], args[2]);
default -> target.invokeWithArguments(prepend(receiver, args));
};
}
@ForceInline은 JIT이 이 메서드를 호출 측에 무조건 인라인하도록 지시합니다. invokeExact는 MethodHandle의 직접 호출 진입점이고, JIT이 핫 메서드로 판단하면 그 안쪽 핸들 체인까지 평탄화해 결과적으로 target 자체가 인라인됩니다. 정적 분석이 가능한 호출 측 코드(Method 객체가 static final 필드에 매달려 있는 등)에서는 Method.invoke 호출 전체가 직접 호출 한 줄로 압축될 수 있습니다.
핫 코드의 hidden class 스피닝
JEP 416 본문에 따르면, 같은 MethodHandle을 충분히 자주 호출하면 런타임이 그 핸들 호출을 감싸는 hidden class 바이트코드 스텁을 동적으로 생성합니다. 이 스텁은 Lookup.defineHiddenClassWithClassData(...)로 정의되고, MethodHandle을 condy(동적으로 계산되는 상수, JVMS의 Constant_Dynamic)로 받아 클래스 데이터에서 끌어옵니다.
flowchart TB
A[Method.invoke] --> B[DirectMethodHandleAccessor]
B --> C{paramCount}
C -->|0..3| D[Inline switch]
C -->|4+| E[invokeWithArguments]
D --> F[MethodHandle invokeExact]
F -->|hot path| G[Hidden class stub]
G -.->|condy load| F
F --> H[Target method]
hidden class는 클래스 로더에 등록되지 않아 GC 시점이 일반 클래스보다 자유롭고, 자체 클래스 데이터를 condy로 가져오기 때문에 별도의 정적 필드를 두지 않아도 메서드 핸들을 참조할 수 있습니다. 같은 Method에 대해 한 번만 생성되고 재사용됩니다.
JDK 17까지처럼 GeneratedMethodAccessor1, 2, 3, ...이 매번 새로 정의되어 메타스페이스를 점유하는 패턴은 사라졌습니다. hidden class는 명시적으로 비공개이고 클래스 로더의 핫 그래프에서 빠질 수 있어 메타스페이스 누적이 크게 완화됩니다.
condy(Constant_Dynamic, JVMS §4.4.10)는 JDK 11에서 정식화된 상수 풀 엔트리 형식으로, 부트스트랩 메서드의 반환값을 상수처럼 취급할 수 있게 해 줍니다. JEP 416의 hidden class 스텁은 condy를 통해 MethodHandle target 자체를 상수로 받습니다. 다음과 같은 의사 바이트코드를 떠올리면 됩니다.
public Object invoke(Object receiver, Object[] args) {
// ldc_w로 condy 로드: bootstrap이 class data에서 target MethodHandle 반환
MethodHandle h = <condy: ClassData.bootstrap>;
return h.invokeExact(receiver, args[0], args[1]);
}
호출 측에서 보면 MethodHandle 필드 로드조차 없이 상수 풀에서 직접 가져오는 모양새가 되어, JIT이 invokeExact 체인 전체를 정적으로 펴 들어갈 여지가 더 큽니다.
Field와 VarHandle
Field.get / Field.set도 같은 원리로 재구현됐습니다. FieldAccessor는 내부에 VarHandle target을 들고, varHandle.get(receiver) 한 줄로 필드를 읽습니다. 기존 구현은 인스턴스 필드와 정적 필드, primitive 타입별로 UnsafeFieldAccessorImpl 계통의 클래스가 8가지 이상 갈라져 있었고, setAccessible이 비공개에 적용됐는지에 따라 또 가지가 갈렸습니다. VarHandle 하나가 그 분기를 모두 흡수합니다.
특히 final 필드 쓰기가 그렇습니다. 자바 9 이후로 setAccessible(true) 후의 final 필드 쓰기는 권장되지 않지만, 직렬화·테스트 도구의 우회로로 여전히 사용됩니다. VarHandle은 메모리 모델 측면에서 일반 쓰기·volatile 쓰기·release 쓰기를 명시적으로 구분해 표현할 수 있어, 옛 구현이 Unsafe.putObject로 처리하던 모호한 가시성 의미가 명시적으로 바뀌었습니다.
MagicAccessorImpl 트릭의 종말
새 구현에서는 접근 검사 우회를 별도로 할 필요가 없습니다. MethodHandles.Lookup.unreflect(method)로 만든 핸들은 setAccessible(true)를 호출한 적이 있는 Method에 대해서는 이미 접근 검사를 통과한 상태로 만들어지고, 이후 invokeExact는 어떤 접근 검사도 거치지 않습니다. MagicAccessorImpl이 떠받치고 있던 마법은 MethodHandles.Lookup의 신뢰 모델로 대체된 셈입니다.
호환성 측면에서는 이게 깨짐 포인트입니다. 디버거·프로파일러·일부 AOP 라이브러리는 스택 트레이스에 sun.reflect.GeneratedMethodAccessorN 또는 MagicAccessorImpl 하위 클래스가 등장한다는 가정을 깔고 있었는데, JDK 18 이후로는 jdk.internal.reflect.DirectMethodHandleAccessor 또는 hidden class 이름이 등장합니다.
호환성 escape hatch
깨짐을 완화하기 위해 JDK 18은 옛 구현으로 되돌아가는 시스템 프로퍼티를 한시적으로 남겼습니다.
-Djdk.reflect.useDirectMethodHandle=false— JEP 416 구현을 끄고 JDK 17 시절의MethodAccessor인플레이션으로 되돌립니다.-Djdk.reflect.useNativeAccessorOnly=true— 인플레이션을 끄고 항상NativeMethodAccessorImpl만 사용합니다. 진단 용도.
JEP 416 문서는 이 escape hatch가 향후 릴리스에서 제거될 것이라 명시했고, 실제로 후속 JEP에서 제거 절차가 진행되고 있습니다. 즉 새 코드는 MagicAccessorImpl 가정에서 벗어나야 안전합니다.
두 시대의 비교
| 항목 | JDK 17 이하 | JDK 18 이후 (JEP 416) |
|---|---|---|
| 기본 구현체 | NativeMethodAccessorImpl → 임계값 후 GeneratedMethodAccessorN |
DirectMethodHandleAccessor |
| 접근 검사 우회 | MagicAccessorImpl 상속 |
MethodHandles.Lookup 신뢰 모델 |
| 동적 코드 생성 단위 | 메서드마다 새 일반 클래스 | 핫 패스에 한해 hidden class 1개 |
| Metaspace 누적 | 메서드 수 비례, 잘 회수되지 않음 | hidden class, GC 친화적 |
| JIT 인라이닝 | 임계값 이후 자바 호출이라 가능 | 첫 호출부터 @ForceInline + invokeExact |
| 튜닝 프로퍼티 | sun.reflect.inflationThreshold, sun.reflect.noInflation |
jdk.reflect.useDirectMethodHandle (escape) |
| 필드 접근 백엔드 | FieldAccessor 분리 |
VarHandle 위에 통합 |
실전 영향
진단 도구로 확인하는 방법
운영 중인 JVM에서 어느 시대의 리플렉션이 동작 중인지는 다음 두 가지로 빠르게 확인할 수 있습니다.
# 1. 클래스 로드 로그에서 패턴 비교
java -Xlog:class+load:file=class-load.log -jar app.jar
grep -E 'GeneratedMethodAccessor|DirectMethodHandleAccessor|/hidden' class-load.log | head
# 2. 힙 덤프에서 클래스 개수 집계
jcmd <PID> GC.class_histogram | grep -E 'MethodAccessor|FieldAccessor|VarHandle' | sort -k2 -n -r
JDK 17 이하에서는 class-load.log에 jdk.internal.reflect.GeneratedMethodAccessor1, 2, ... 패턴이 시간이 지남에 따라 줄지어 등장합니다. JDK 18 이후에는 그 자리에 /0x... 형태의 hidden class 이름이 짧게 보이고, 같은 hidden class가 반복 등록되지는 않습니다. 그리고 class_histogram에 MagicAccessorImpl 하위가 보이지 않으면 새 구현이 동작 중이라는 신호입니다.
워밍업 곡선의 변화
JDK 17 이하에서는 동일한 Method 객체로 16번째 호출이 들어올 때 동기적으로 클래스가 생성되며 일시적인 지연이 발생합니다. 마이크로벤치마크에서 14회·15회·16회 호출의 시간이 들쑥날쑥했던 이유가 이것이고, JMH로 Method.invoke를 측정할 때 @Warmup이 충분히 길지 않으면 결과가 왜곡됐습니다.
JDK 18 이후에는 이 단차가 거의 사라졌습니다. 첫 호출부터 DirectMethodHandleAccessor가 사용되고, MethodHandle 자체는 JDK 시동 시점에 이미 워밍업된 코드 경로라 첫 호출도 비교적 평탄합니다. hidden class 스피닝은 후행 단계라 호출자 입장에서 따로 관측되지 않습니다.
Metaspace 사용 패턴
대규모 엔터프라이즈 애플리케이션에서 Metaspace에 적재되는 GeneratedMethodAccessor 클래스 수가 사라집니다. Spring Boot 3 + Hibernate 6 조합을 JDK 17과 21에서 같은 워크로드로 비교하면, 메타스페이스 점유가 안정 상태에서 수십~수백 MB 단위로 줄어드는 사례가 보고되어 있습니다. 정확한 수치는 엔티티 수와 호출 패턴에 따라 달라지므로 측정이 필요합니다.
스택 트레이스가 달라진다
운영 환경에서 가장 먼저 체감되는 변화입니다.
// JDK 17
at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:566)
// JDK 21
at jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(DirectMethodHandleAccessor.java:172)
at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
스택 프레임 수 자체가 줄어들었고, NativeMethodAccessorImpl / DelegatingMethodAccessorImpl 짝이 사라졌습니다. 로그에서 리플렉션 호출을 정규식으로 거르던 모니터링 시스템은 패턴 갱신이 필요합니다.
JIT 인라이닝과 인텐션
JIT 인라이닝은 양 시대 모두에서 가능하지만, JDK 18 이후가 조건이 너그럽습니다. JDK 17까지는 인플레이션 발동 이후에야 자바 호출이 되므로, 호출 횟수가 적은 콜드 경로의 Method.invoke는 영영 JNI 진입을 빠져나오지 못했습니다. JDK 18 이후엔 첫 호출부터 인라이닝 가능한 경로입니다.
라이브러리 입장에서 보면, JDK 21을 타깃으로 한 Jackson 2.16, Hibernate 6.x는 Method.invoke를 부담스러워하지 않게 되었고, 일부는 명시적으로 사용하던 MethodHandle 캐싱 레이어를 단순화하는 방향의 패치를 받아들였습니다.
라이브러리 코드에서의 권장 패턴
새 구현 하에서도 가장 빠른 리플렉션 호출은 결국 직접 MethodHandle을 쓰는 코드입니다. JEP 416은 Method.invoke를 그 수준에 가깝게 끌어올렸지만, 핸들을 직접 잡고 bindTo·asType으로 시그니처를 미리 결정해 두면 호출 측 콜사이트가 더 좁아집니다. 예를 들면 다음과 같이 단순화할 수 있습니다.
// 옛날 패턴 — Method 객체 캐싱
private static final Method M;
static {
M = Foo.class.getDeclaredMethod("bar", String.class);
M.setAccessible(true);
}
public Object call(Foo target, String arg) throws Throwable {
return M.invoke(target, arg);
}
// 새로 권장되는 패턴 — MethodHandle 캐싱
private static final MethodHandle MH;
static {
Method m = Foo.class.getDeclaredMethod("bar", String.class);
m.setAccessible(true);
MH = MethodHandles.lookup().unreflect(m);
}
public Object call(Foo target, String arg) throws Throwable {
return MH.invoke(target, arg);
}
JEP 416 이후엔 두 경로의 성능 차이가 크게 좁혀졌지만, 후자는 invokeExact로 더 좁히면 거의 직접 호출 수준의 콜사이트가 됩니다. 새 코드를 작성할 때는 후자를 선택하는 편이 합리적입니다.
라이브러리 작성자에게 더 중요한 변화는 Lookup의 신뢰 모델을 이해하고 적용해야 한다는 점입니다. JDK 17까지 라이브러리들이 setAccessible만 호출하고 MagicAccessorImpl 면제에 기댔다면, 새 구현에서는 MethodHandles.privateLookupIn(targetClass, MethodHandles.lookup()) 같은 호출로 명시적으로 권한을 확보해야 핸들을 만들 수 있습니다. 모듈이 닫혀 있으면 --add-opens 또는 Module.isOpen 검사를 통과해야 한다는 규칙은 동일합니다.
setAccessible은 여전히 필요하다
JEP 416은 구현을 바꿨을 뿐이고, 모듈 시스템 이후의 강제 캡슐화 규칙은 그대로입니다. 모듈이 닫혀 있는 비공개 멤버에 접근하려면 여전히 --add-opens나 Module.isOpen 검사가 필요하고, setAccessible(true)은 그 위에서 일반 자바 코드의 접근 제어를 뚫는 호출입니다. 새 구현에서도 이 검사는 Method.invoke() 진입부에서 변함없이 수행됩니다.
정리
Method.invoke()는 표면적으로 동일한 한 줄이지만, 그 아래의 구현 두 세대를 통과하며 거의 모든 것이 바뀌었습니다.
- JDK 17까지의 인플레이션 구조는 JNI 진입과 동적 바이트코드 생성을 결합한 영리한 해법이었지만, 메서드마다 새 클래스를 만들고
MagicAccessorImpl면제에 의존하는 비용을 치렀습니다. - JEP 416은 그 비용을
MethodHandle로 갈음했습니다.DirectMethodHandleAccessor는 비트 패킹과@ForceInline을 통해 핫 패스를 가볍게 유지하고, 핫 코드는 hidden class 스텁으로 평탄화되어 GC 친화적인 메타스페이스 사용을 만듭니다.
자바 리플렉션을 사용하는 코드 자체는 다시 짤 필요가 없지만, 내부 구조를 알면 운영 중인 애플리케이션의 GC 패턴 변화나 스택 트레이스 변화의 원인을 더 빨리 짚을 수 있습니다. 그리고 이 변화는 java.lang.reflect와 java.lang.invoke가 같은 기반 위에 올라간 첫 사례로, 앞으로의 자바 메타프로그래밍이 어디로 향하는지를 보여주는 이정표이기도 합니다.
참고자료
- JEP 416: Reimplement Core Reflection with Method Handles
- JDK-8271820: Implementation of JEP 416
- JDK-8277244: Release Note for JEP 416
- OpenJDK source — jdk.internal.reflect.DirectMethodHandleAccessor
- OpenJDK source — jdk.internal.reflect.ReflectionFactory
- OpenJDK PR #5027 — Implementation of JEP 416
- java.lang.invoke.MethodHandles.Lookup API documentation
- java.lang.reflect.Method API documentation

