이 장의 공개 진입점(
RuntimeHintsAgent,RuntimeHintsRecorder,RuntimeHintsInvocations)은 7.0에서@Deprecated(forRemoval = true)다. 권장 대체재는 GraalVM의-XX:MissingRegistrationReportingMode=Warn|Exit플래그. 그래도 설계가 명료해 동작 원리를 정리한다.
GraalVM 네이티브 이미지는 리플렉션·리소스·프록시를 빌드 시점 RuntimeHints로 사전 등록해야만 런타임에 동작한다. 등록을 빠뜨리면 JVM에서는 멀쩡하지만 네이티브에서만 ClassNotFoundException 같은 게 터진다. 이걸 일반 JVM 테스트에서 미리 잡으려면, 코드가 실행 중 실제로 어떤 리플렉션 호출을 하는지 관찰하고 그게 등록된 힌트로 전부 덮이는지 대조해야 한다.
이 모듈의 Java Agent가 그 관찰을 한다. Class.forName, Method.invoke, ClassLoader.getResource, Proxy.newProxyInstance 같은 힌트가 필요한 JDK 메서드 호출을 바이트코드 수준에서 가로채 기록하고, 테스트는 그 기록을 RuntimeHints와 맞춰본다.
[적재] RuntimeHintsAgent.premain(args, inst)
│ addTransformer
▼
[계측] InvocationsRecorderClassTransformer (ClassFileTransformer)
│ ASM 으로 클래스 방문
▼
InvocationsRecorderClassVisitor
│ 호출 명령(invoke*)을 재작성
▼ 원본: Class.forName(s)
재작성: InstrumentedBridgeMethods.classforName(s)
[실행 중 기록]
InstrumentedBridgeMethods.classforName(...)
│ 실제 JDK 호출 + 기록
▼
RecordedInvocation ──build──▶ RecordedInvocationsPublisher.publish(...)
│ │ LISTENERS 에 전파
InstrumentedMethod(enum) ▼
(대상 메서드 + 힌트 매처) RecordedInvocationsListener
│
[테스트 API] ▼
RuntimeHintsRecorder.record(Runnable) ── 수집 ──▶ RuntimeHintsInvocations
│ assertThat(...)
▼
RuntimeHintsInvocationsAssert.match(hints)
RuntimeHintsAgent는 JAR 매니페스트의 Premain-Class로 등록되어, -javaagent:spring-core-test.jar로 붙으면 premain이 먼저 돈다.
public static void premain(String agentArgs, Instrumentation inst) {
loaded = true;
ParsedArguments arguments = ParsedArguments.parse(agentArgs);
inst.addTransformer(new InvocationsRecorderClassTransformer(
arguments.getInstrumentedPackages(), arguments.getIgnoredPackages()));
}인자 형식은 +계측패키지,-제외패키지다. 기본값은 org.springframework만 계측. isLoaded()가 true를 반환하는지로 다른 코드가 Agent 존재를 감지한다(이게 @EnabledIfRuntimeHintsAgent의 조건이다).
InvocationsRecorderClassTransformer.transform(...)는 클래스가 계측 후보인지 거른다. 시스템 클래스(로더 null), Agent 자기 자신, CGLIB 생성 클래스($$ 포함), 그리고 흥미롭게도 DynamicClassLoader(02장)는 제외한다 — 테스트 컴파일러가 만든 동적 클래스까지 계측하면 잡음이 되기 때문이다. 후보면 ASM ClassReader로 InvocationsRecorderClassVisitor를 돌려 바이트코드를 다시 쓴다.
InvocationsRecorderClassVisitor는 모든 메서드 호출 명령(visitMethodInsn)을 보고, 그 대상이 계측 목록에 있으면 호출을 InstrumentedBridgeMethods의 정적 메서드로 갈아끼운다.
// 원본: INVOKEVIRTUAL java/lang/Class.forName ...
// 재작성: INVOKESTATIC .../InstrumentedBridgeMethods.classforName ...명명 규칙은 "소문자 타입명 + 메서드명"이다(Class#forName → classforName). visitInvokeDynamicInsn도 처리해 람다/메서드 레퍼런스 형태의 호출까지 잡는다. 인스턴스 메서드를 정적 브리지로 바꾸므로, 디스크립터 앞에 수신자 타입(this)을 첫 인자로 끼워 넣는 보정(rewriteDescriptor)을 한다.
재작성된 코드는 이제 원래 JDK 메서드 대신 브리지 메서드를 부른다. 브리지는 진짜 JDK 호출을 그대로 수행하면서, 그 사실을 기록한다.
public static Class<?> classforName(String className) throws ClassNotFoundException {
Class<?> result = null;
try {
result = Class.forName(className); // 원래 동작은 보존
}
finally {
RecordedInvocation invocation = RecordedInvocation.of(InstrumentedMethod.CLASS_FORNAME)
.withArguments(className).returnValue(result).build();
RecordedInvocationsPublisher.publish(invocation); // 기록 발행
}
return result;
}RecordedInvocation은 호출 1건의 스냅샷이다 — 어떤 InstrumentedMethod였는지, 수신자(this), 인자, 반환값, 그리고 build() 시점에 StackWalker로 떠낸 스택 프레임까지 담는다. 스택은 Agent 패키지 프레임을 걷어내(dropWhile) 사용자 코드 지점만 남긴다. RecordedInvocationsPublisher는 등록된 모든 RecordedInvocationsListener에 이 호출을 전파하는 정적 발행기다.
@EnabledIfRuntimeHintsAgent
class MyHintsTests {
@Test
void methodsReflectionShouldMatch() {
RuntimeHints hints = new RuntimeHints();
hints.reflection().registerType(String.class,
hint -> hint.withMembers(MemberCategory.INTROSPECT_PUBLIC_METHODS));
RuntimeHintsInvocations invocations = RuntimeHintsRecorder.record(() -> {
Method[] methods = String.class.getMethods(); // 계측되어 기록됨
});
assertThat(invocations).match(hints);
}
}RuntimeHintsRecorder.record(Runnable)는 임시 리스너를 발행기에 붙이고, 액션을 돌리는 동안 들어온 호출을 모은 뒤 리스너를 떼고 RuntimeHintsInvocations로 감싼다. synchronized이고, 시작 시 RuntimeHintsAgent.isLoaded()를 Assert.state로 강제한다 — Agent 없이는 아무 호출도 가로채지 못하므로.
대조의 핵심은 InstrumentedMethod다. 각 enum 상수는 "계측 대상 메서드"와 "그 호출이 어떤 힌트로 덮이는지 판정하는 매처 생성기"를 함께 들고 있다.
CLASS_FORNAME(Class.class, "forName", HintType.REFLECTION,
invocation -> {
String className = invocation.getArgument(0);
return reflection().onType(TypeReference.of(className));
}),
METHOD_INVOKE(Method.class, "invoke", HintType.REFLECTION,
invocation -> reflection().onMethodInvocation(invocation.getInstance())),
PROXY_NEWPROXYINSTANCE(Proxy.class, "newProxyInstance", HintType.JDK_PROXIES,
invocation -> {
Class<?>[] classes = invocation.getArgument(1);
return RuntimeHintsPredicates.proxies().forInterfaces(classes);
});매처는 spring-core의 RuntimeHintsPredicates가 만드는 Predicate<RuntimeHints>다. 같은 호출이 여러 힌트 형태로 덮일 수 있음을 고려한다 — 예컨대 getMethod("x")는 "그 메서드만의 힌트"로도, "공개 메서드 전체 힌트"로도 만족될 수 있다. RecordedInvocation.matches(hints)가 이 매처를 적용한다.
RuntimeHintsInvocationsAssert.match(hints)는 기록된 호출 중 어느 힌트와도 매칭되지 않는 첫 호출을 찾아, 있으면 "Missing for invocation <...>" + 스택트레이스로 단언 실패를 낸다. withRegistrar(...)/withSpringFactoriesRegistrars(location)로 검사 전 RuntimeHintsRegistrar를 적용해 힌트를 채울 수 있다.
- 계측 명세의 단일 출처:
InstrumentedMethodenum 하나가 (a) 무엇을 재작성할지(InvocationsRecorderClassVisitor가 이 목록을 읽음), (b) 무슨 힌트로 검증할지(매처)를 동시에 정의한다. 새 계측 대상을 추가하려면 enum 상수와 대응 브리지 메서드만 더하면 된다. HintType로 분류: 리플렉션/리소스 패턴/리소스 번들/JDK 프록시(그리고 deprecated 자바 직렬화)로 호출을 분류해, 실패 메시지에 어떤 종류의 힌트가 빠졌는지 표시한다.- 테스트 활성 게이트:
@EnabledIfRuntimeHintsAgent는@EnabledIf("...RuntimeHintsAgent#isLoaded")+@Tag("RuntimeHintsTests")메타 애너테이션이다. Agent 없이 도는 일반 빌드에서는 이 테스트들이 조용히 비활성화되고, Agent를 붙인 전용 실행에서만 켜진다.
RuntimeHints Agent는 ASM 바이트코드 재작성 → 브리지 메서드로 호출 기록 → 리스너 발행 → RuntimeHints와 매처 대조라는 4단계로, "코드가 실제로 쓰는 리플렉션/리소스/프록시"를 추적해 힌트 누락을 일반 JVM 테스트에서 잡아낸다. 7.0부터 GraalVM 자체 보고 플래그로 대체되며 deprecated 되었지만, "단일 enum이 계측과 검증을 동시에 정의하는" 설계는 들여다볼 가치가 있다.