Skip to content

Latest commit

 

History

History
126 lines (96 loc) · 9.61 KB

File metadata and controls

126 lines (96 loc) · 9.61 KB

02 — TestCompiler: 메모리 안에서 자바를 컴파일하다

무엇을 / 왜

AOT 프로세서가 만든 .java 소스가 정말 컴파일되고, 로딩하면 의도대로 동작하는지 확인하려면 테스트 중에 javac를 돌려야 한다. 하지만 디스크에 파일을 쓰고 별도 클래스패스를 구성하는 건 느리고 지저분하다. TestCompilerJDK의 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 / ResourceFilesealed abstract DynamicFile의 두 구현. path+content를 가진다. SourceFile은 추가로 소스 본문을 파싱해 완전수식 클래스명을 뽑아낸다(qdox 사용).
  • ClassFile — 이미 바이트코드가 있는 클래스. 컴파일 대상이 의존하는 기존 클래스를 메모리로 주입할 때 쓴다.
  • DynamicJavaFileManagerForwardingJavaFileManager를 상속해, 컴파일러가 산출물을 디스크 대신 메모리 맵에 쓰게 만든다.
  • DynamicClassLoader — 메모리에 모인 바이트코드/리소스를 findClass/findResource로 노출하는 클래스로더.
  • Compiled — 컴파일 결과 핸들. 로딩·인스턴스화 헬퍼 제공.

동작 흐름: compile() 한 번에 일어나는 일

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장으로 이어지는 단서다.

핵심 메서드

TestCompiler.compile(Consumer<Compiled>)

소스가 비어 있지 않으면 컴파일 태스크를 실제로 돌린다. 컴파일 진단(diagnostic)은 내부 Problems 리스너가 수집하는데, ERROR·MANDATORY_WARNING·WARNING만 모은다. 에러가 하나라도 보고되면 결과 코드가 성공이어도 CompilationException을 던진다. failOnWarning()을 호출해 두면 -Xlint:all -Werror 옵션이 붙어 경고도 컴파일 실패로 격상된다.

SourceFile.of(...)getClassName()

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 선언을 아직 파싱하지 못하므로, makeRecordsLookLikeClassesrecord Foo(...)class Foo 형태로 정규식 치환해 클래스명만이라도 뽑아낸다. AOT가 record를 흔히 생성하기 때문에 필요한 우회다. 파싱 결과는 "소스 당 정확히 1개 클래스"를 강제한다(Assert.state(... size() == 1 ...)).

DynamicJavaFileManager.getJavaFileForOutput(...)

컴파일러가 산출 클래스를 쓰려 할 때 호출된다. 이미 classFiles에 주입된 동일 클래스가 있으면 그 바이트코드를 재사용하고, 없으면 dynamicClassFiles 맵에 DynamicClassFileObject를 만들어 컴파일러가 거기에 바이트코드를 채우게 한다. list(...)inferBinaryName(...)도 오버라이드해, 주입된 ClassFile들이 컴파일 시 클래스패스 검색에 잡히도록 한다.

Compiled.getInstance(Class<T>) / getAllCompiledClasses()

Compiled는 컴파일된 소스 파일들의 클래스명을 DynamicClassLoader로 로딩한다. getInstance(type)은 로딩된 클래스 중 해당 타입에 할당 가능한 클래스가 정확히 1개일 때 기본 생성자로 인스턴스를 만든다(0개면 "없음", 2개 이상이면 "모호함" 예외). getInstance(type, className)은 이름을 직접 줘서 로딩한다.

DynamicClassLoader가 리소스를 노출하는 법

DynamicClassLoader.findResource(name)는 세 단계로 자원을 찾는다.

  1. 이름이 .class로 끝나면 → 클래스명을 역산해 메모리 바이트코드를 찾아 resource:///... URL로 감싼다.
  2. 동적 리소스 맵(dynamicResourceFiles)에 있으면 그걸 노출.
  3. 입력으로 받은 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 어댑터가 모두 이 엔진 위에 얹힌다.