AOT 프로세서가 만든 .java 소스가 정말 컴파일되고, 로딩하면 의도대로 동작하는지 확인하려면 테스트 중에 javac를 돌려야 한다. 하지만 디스크에 파일을 쓰고 별도 클래스패스를 구성하는 건 느리고 지저분하다. TestCompiler는 JDK의 javax.tools.JavaCompiler API를 메모리 기반 파일 시스템으로 감싸, 소스를 문자열로 받아 즉석에서 바이트코드로 만들고 그걸 클래스로 로딩하는 것까지 한 번에 처리한다.
핵심 사용 모습은 다음과 같다.
TestCompiler.forSystem()
.withSources(SourceFile.of(generatedSource))
.compile(compiled -> {
MyBean bean = compiled.getInstance(MyBean.class);
assertThat(bean.value()).isEqualTo("ok");
}); ┌───────────────────────────────────┐
│ TestCompiler │ 불변. with*()로 새 인스턴스
│ classLoader / compiler │
│ sourceFiles / resourceFiles │
│ classFiles / processors / options│
└───────────────┬───────────────────┘
│ compile(Consumer<Compiled>)
▼
┌───────────────────────┐ ┌──────────────────────────────┐
│ DynamicJavaFileManager │◀────────│ JavaCompiler.getTask(...) │
│ (메모리 파일 시스템) │ 산출물 │ 표준 시스템 컴파일러 │
└───────────┬───────────┘ └──────────────────────────────┘
│ dynamicClassFiles / dynamicResourceFiles
▼
┌───────────────────────┐ ┌──────────────────────────────┐
│ DynamicClassLoader │────────▶│ Compiled │
│ (생성 클래스/리소스 노출)│ │ getInstance / getAllClasses │
└───────────────────────┘ └──────────────────────────────┘
입력 모델 (sealed DynamicFile 계층):
DynamicFile ──┬── SourceFile (.java, className 파싱)
└── ResourceFile (임의 리소스)
ClassFile (이미 컴파일된 in-memory 바이트코드 — 의존 클래스 주입용)
*Files SourceFiles / ResourceFiles / ClassFiles (불변 컬렉션)
TestCompiler— 컴파일 진입점. 전부final필드이며withSources·withResources·withClasses·withProcessors·withCompilerOptions등 모든with*()가 새 인스턴스를 반환하는 불변 빌더다.forSystem()이ToolProvider.getSystemJavaCompiler()로 표준 컴파일러를 잡는다.SourceFile/ResourceFile—sealed abstract DynamicFile의 두 구현.path+content를 가진다.SourceFile은 추가로 소스 본문을 파싱해 완전수식 클래스명을 뽑아낸다(qdox 사용).ClassFile— 이미 바이트코드가 있는 클래스. 컴파일 대상이 의존하는 기존 클래스를 메모리로 주입할 때 쓴다.DynamicJavaFileManager—ForwardingJavaFileManager를 상속해, 컴파일러가 산출물을 디스크 대신 메모리 맵에 쓰게 만든다.DynamicClassLoader— 메모리에 모인 바이트코드/리소스를findClass/findResource로 노출하는 클래스로더.Compiled— 컴파일 결과 핸들. 로딩·인스턴스화 헬퍼 제공.
TestCompiler.compile(Consumer<Compiled>)
│
├─(1) private compile() 호출 → DynamicClassLoader 생성
│ │
│ ├─ sourceFiles → DynamicJavaFileObject 로 매핑 (컴파일 단위)
│ ├─ DynamicJavaFileManager 구성 (classFiles/resourceFiles 주입)
│ ├─ compiler.getTask(out=null, fileManager, Problems,
│ │ compilerOptions, null, units)
│ ├─ task.setProcessors(processors)
│ ├─ task.call() ──▶ 산출 바이트코드를 fileManager 메모리 맵에 기록
│ │
│ └─ 결과 false 또는 에러 보고 시 → CompilationException 던짐
│
├─(2) Thread 컨텍스트 클래스로더를 DynamicClassLoader 로 교체
│
├─(3) compiled.accept(new Compiled(loader, sourceFiles, resourceFiles))
│ └─ 사용자 콜백 안에서 getInstance() 등으로 클래스 사용
│
└─(4) finally: 원래 컨텍스트 클래스로더 복원
compile()의 try 블록은 사용자 콜백 중 발생한 IllegalAccessError를 잡아, **"접근하려면 @CompileWithForkedClassLoader를 붙여라"**는 친절한 메시지로 다시 던진다(TestCompiler.java). 이게 03장으로 이어지는 단서다.
소스가 비어 있지 않으면 컴파일 태스크를 실제로 돌린다. 컴파일 진단(diagnostic)은 내부 Problems 리스너가 수집하는데, ERROR·MANDATORY_WARNING·WARNING만 모은다. 에러가 하나라도 보고되면 결과 코드가 성공이어도 CompilationException을 던진다. failOnWarning()을 호출해 두면 -Xlint:all -Werror 옵션이 붙어 경고도 컴파일 실패로 격상된다.
SourceFile은 생성 시 본문에서 클래스명을 파싱해 둔다. 흥미로운 부분은 getClassName(String content):
JavaSource javaSource = builder.addSource(new StringReader(content));
if (javaSource.getClasses().isEmpty()) {
// qdox가 record를 못 읽으므로, record를 class처럼 보이게 변형
javaSource = builder.addSource(new StringReader(makeRecordsLookLikeClasses(content)));
}qdox 라이브러리가 record 선언을 아직 파싱하지 못하므로, makeRecordsLookLikeClasses가 record Foo(...) 를 class Foo 형태로 정규식 치환해 클래스명만이라도 뽑아낸다. AOT가 record를 흔히 생성하기 때문에 필요한 우회다. 파싱 결과는 "소스 당 정확히 1개 클래스"를 강제한다(Assert.state(... size() == 1 ...)).
컴파일러가 산출 클래스를 쓰려 할 때 호출된다. 이미 classFiles에 주입된 동일 클래스가 있으면 그 바이트코드를 재사용하고, 없으면 dynamicClassFiles 맵에 DynamicClassFileObject를 만들어 컴파일러가 거기에 바이트코드를 채우게 한다. list(...)와 inferBinaryName(...)도 오버라이드해, 주입된 ClassFile들이 컴파일 시 클래스패스 검색에 잡히도록 한다.
Compiled는 컴파일된 소스 파일들의 클래스명을 DynamicClassLoader로 로딩한다. getInstance(type)은 로딩된 클래스 중 해당 타입에 할당 가능한 클래스가 정확히 1개일 때 기본 생성자로 인스턴스를 만든다(0개면 "없음", 2개 이상이면 "모호함" 예외). getInstance(type, className)은 이름을 직접 줘서 로딩한다.
DynamicClassLoader.findResource(name)는 세 단계로 자원을 찾는다.
- 이름이
.class로 끝나면 → 클래스명을 역산해 메모리 바이트코드를 찾아resource:///...URL로 감싼다. - 동적 리소스 맵(
dynamicResourceFiles)에 있으면 그걸 노출. - 입력으로 받은
resourceFiles에 있으면 그걸 노출.
이때 URL은 실제 파일이 없으므로, 내부 ResourceFileHandler(커스텀 URLStreamHandler)가 getInputStream()에서 메모리 바이트 배열을 ByteArrayInputStream으로 돌려준다. 즉 파일 시스템 없이 URL 기반 리소스 로딩을 흉내낸다.
- 불변 + 함수형 합성: 모든 설정이 새 인스턴스를 반환하므로 베이스 컴파일러를 공유하고 테스트별로 가지를 친다.
with(UnaryOperator<TestCompiler>)는 외부 어댑터(예:CompilerFiles,TestGenerationContext)가 한 번에 설정을 주입하는 훅이다(04장). - 애너테이션 프로세서 테스트:
withProcessors(...)로 컴파일에 프로세서를 끼워, AOT가 의존하는 프로세서 동작까지 검증할 수 있다. WritableContent:SourceFile.of(generatedFile::writeTo)처럼, AOT가 가진 "쓰기 콜백"을 그대로 소스로 받아들이는 함수형 인터페이스. 생성 코드를 중간 문자열 변환 없이 흘려넣게 해준다.
TestCompiler는 JDK 컴파일러 API에 메모리 파일 매니저 + 메모리 클래스로더를 끼워, "소스 문자열 → 컴파일 → 클래스 로딩 → 인스턴스"의 전 과정을 디스크 없이, 그리고 불변 빌더로 안전하게 수행한다. AOT 생성 코드 검증의 토대이며, 다음 장의 클래스로더 격리와 04장의 AOT 어댑터가 모두 이 엔진 위에 얹힌다.