GraalVM 네이티브 이미지

리플렉션이 없는 프레임워크는 native-image가 꿰뚫어 볼 수 있는 프레임워크다. 라우팅은 리스트이고, 핸들러는 닫힌 세계 분석(closed-world analysis)이 따라가는 람다이며, core를 위해 작성할 리플렉션 설정은 없다. 준비가 필요한 것은 프레임워크 밖의 부분뿐이며, 예제 프로젝트가 그 각각을 보여준다. gradle :example-flashcard:nativeCompile은 같은 앱을 서비스하는 자기 완결적인 바이너리를 빌드한다.

예제가 준비하는 것

템플릿

네이티브 바이너리는 컴파일러를 싣고 있지 않으므로, 런타임에 템플릿을 컴파일하는 jte의 기본 동작은 선택지가 아니다. jte Gradle 플러그인의 generate()는 빌드 시점에 각 템플릿을 Java 클래스로 바꾸고(템플릿), 플러그인의 NativeResourcesExtension은 jte가 이름으로 로드하는 이 생성 클래스들의 리플렉션 설정을 써낸다:

plugins {
    id 'gg.jte.gradle' version '3.2.4'
}

jte {
    sourceDirectory = file('src/main/resources/jte').toPath()
    contentType = gg.jte.ContentType.Html
    generate()
    jteExtension 'gg.jte.nativeimage.NativeResourcesExtension'
}

dependencies {
    jteGenerate 'gg.jte:jte-native-resources:3.2.4'
}

그 설정이 jte 리플렉션의 전부다. 엔진은 생성된 클래스를 이름으로 찾아 그 render 메서드를 호출하고, 템플릿 안의 표현식은 모델을 리플렉션으로 들여다보는 일 없이 컴파일된 코드로 실행된다. 자세한 내용은 "리플렉션 없음"의 범위에 있다. 어떤 모델 클래스도, 어떤 프레임워크 클래스도 항목이 필요 없다.

행 매핑

리포지토리는 spring-jdbc의 DataClassRowMapper로 행을 매핑하고 SimplePropertySqlParameterSource를 통해 insert를 넘기는데, 둘 다 도메인 레코드에 리플렉션으로 접근한다. 예제는 기존 JDBC 코드의 상당수가 정확히 이런 모양이기 때문에 이 클래스들을 일부러 유지한다. 네이티브 이미지에서 이 선택의 비용은 모든 도메인 타입을 나열한 리플렉션 설정이며, 빌드는 그 목록을 손으로 관리하는 대신 패키지에서 도출한다(아래 참고).

리소스와 드라이버

나머지는 빌드에 선언한다: 앱이 런타임에 읽는 클래스패스 리소스와, 플러그인이 GraalVM의 공유 저장소에서 가져오는 H2와 Logback의 도달 가능성 메타데이터다. logback.xml은 그 리소스 가운데 하나이며, 로깅이 이를 설명한다.

graalvmNative {
    binaries {
        main {
            imageName = 'flashcard'
            resources.includedPatterns.addAll(['schema\\.sql', 'public/.*', 'logback\\.xml'])
        }
    }
}

spider-silk 플러그인이 블록의 나머지를 공급한다: --no-fallback, 아래 컨테이너 절이 의존하는 대부분 정적 링크인 --static-nolibc, 그리고 메타데이터 저장소다. 손으로 쓰면 org.graalvm.buildtools.native 플러그인 아래의 buildArgs.add('--no-fallback'), buildArgs.add('--static-nolibc'), metadataRepository { enabled = true }다.

빌드와 실행

# needs a GraalVM JDK, e.g. `sdk install java 25.2.4-graalce`
gradle :example-flashcard:nativeCompile
example-flashcard/build/native/nativeCompile/flashcard

GraalVM CE 25로 빌드하면 바이너리는 65MiB이고, 프로세스가 뜬 뒤 약 55ms 만에 시작 로그를 남긴다(같은 앱을 JVM에서 돌리면 약 1.2초다). 예제가 하는 모든 일이 손으로 쓴 리플렉션 설정 없이 바이너리에서 돌아간다: 라우팅, jte 템플릿, 정적 파일, 세션과 플래시, JSON API, OpenAPI 내보내기, 그리고 트랜잭션을 포함한 spring-jdbc 위의 H2다. 이미지의 모든 리플렉션 설정 항목은 생성된 것으로, 템플릿 클래스는 jte 플러그인이, 도메인 패키지는 빌드 자체가 생성하며, 그중 어느 것도 프레임워크 클래스를 지목하지 않는다. --no-fallback이 이를 강제한다: 여전히 JVM이 필요한 바이너리를 조용히 내놓는 대신 빌드를 실패시킨다.

--dev 템플릿 모드만 JVM에 남는데, 템플릿을 핫 리로드하려면 런타임에 컴파일해야 하기 때문이다.

컨테이너 안의 바이너리

-Pnative는 배포가 설정하는 Jib 빌드의 목표를 바이너리로 돌린다. 그 빌드는 빌드 머신에 Docker 데몬 없이 JRE 베이스 위에 앱을 컨테이너화한다. 이 전환은 spider-silk 플러그인에 들어 있으므로, 예제의 빌드 파일은 이를 위해 아무것도 설정하지 않는다. 손으로 풀어 쓰면, 이 전환은 Jib의 native-image 확장을 설정하는 조건 블록이며, 이 확장은 buildscript 클래스패스에 있어야 한다:

buildscript {
    dependencies {
        classpath 'com.google.cloud.tools:jib-native-image-extension-gradle:0.1.0'
    }
}

if (providers.gradleProperty('native').present) {
    jib {
        from.image = 'gcr.io/distroless/base-debian12'
        to.image = 'ghcr.io/benelog/spider-silk-flashcard:native'
        pluginExtensions {
            pluginExtension {
                implementation = 'com.google.cloud.tools.jib.gradle.extension.nativeimage.JibNativeImageExtension'
                properties = [imageName: 'flashcard']
            }
        }
    }
}

이 확장은 Jib이 조립할 의존성 레이어와 클래스 레이어를 바이너리를 담은 단일 레이어로 대체하고, 그 실행 비트를 설정하며, 엔트리포인트를 /app/flashcard로 향하게 한다. imageName은 build/native/nativeCompile에서 어느 파일을 가져올지 알려주며, graalvmNative 블록의 imageName과 일치해야 한다.

베이스 이미지는 프로세스와 함께 줄어든다. 바이너리는 대부분 정적으로 빌드되어(--static-nolibc, 플러그인 기본값) glibc를 뺀 모든 것이 링크되어 들어가고, distroless/base는 그 glibc와 CA 인증서, 파일시스템 뼈대에 지나지 않는다. 이미지에는 셸도, 패키지 관리자도, JVM도 없는데, 바이너리는 컴파일 시점부터 JVM이 필요 없기 때문이다. 대부분 정적인 링크 덕분에 그 베이스로 충분하다: 동적으로 링크하면 바이너리는 libz도 필요로 하는데, distroless/base에는 libz가 없어 컨테이너가 시작 시 죽는다.

gradle :example-flashcard:jibBuildTar -Pnative
# then, on a machine that runs containers:
docker load < example-flashcard/build/jib-image.tar
docker run -p 8080:8080 ghcr.io/benelog/spider-silk-flashcard:native

tar는 JVM 이미지의 119MiB에 비해 34MiB다. 그 대가는 이식성이다. Java 바이트코드는 JRE 이미지가 있는 어떤 아키텍처에서도 실행되지만, 네이티브 바이너리는 한 플랫폼용으로 컴파일되므로 이 이미지는 빌드한 아키텍처에서 실행된다. 크로스 컴파일은 없다.

라이브러리가 리플렉션을 쓸 때

런타임에 이름으로 클래스를 찾는 라이브러리는 네이티브 빌드를 가로막을 수 있다. 프레임워크는 가로막지 않는다. 미리 컴파일한 템플릿이나 손으로 쓴 매퍼처럼, 의도를 그대로 말하는 코드 경로를 우선한다. 라이브러리가 리플렉션을 써야만 하는 곳에서는 native-image에 그 설정을 건넨다: jte의 확장처럼 라이브러리 자체의 도구에서, H2처럼 GraalVM의 메타데이터 저장소에서, 또는 아래처럼 자신의 타입을 위해 직접 관리해서다.

DataClassRowMapper를 유지하기, 측정 결과

예제의 DataClassRowMapper와 SimplePropertySqlParameterSource에는 파일 하나가 필요하다: 클래스패스의 META-INF/native-image/<any-directory>/ 아래에 있는, 도메인 타입마다 항목 하나를 둔 reflect-config.json이다. 손으로 쓰거나, 예제의 빌드가 하는 방식대로 생성한다.

[
  { "name": "flashcard.domain.Card",
    "allDeclaredConstructors": true,
    "allDeclaredMethods": true,
    "allDeclaredFields": true }
]

allDeclaredConstructors 덕분에 DataClassRowMapper가 레코드의 정규 생성자를 찾고, allDeclaredMethods 덕분에 SimplePropertySqlParameterSource가 접근자를 읽는다. 도메인 타입 자체 외에는 아무것도 필요하지 않았다. 예제는 한 머신에서 두 방식 모두로 빌드하고, 바이너리의 모든 리포지토리 경로를 거쳐 실행해 보았다:

손으로 쓴 매퍼 리플렉션 + 설정

이미지 빌드 시간

65s, 82s

73s — 실행 간 편차 이내

Jetty 시작, 5회 중앙값

38ms

35ms — 측정 가능한 차이 없음

바이너리 크기

58.1MiB

60.9MiB (+4.8%)

이 비교는 예제의 이전 버전에서 측정했으므로, 두 크기 모두 현재 바이너리의 65MiB보다 작다. 리플렉션 메타데이터는 이미지를 빌드할 때 들어가므로, 부팅 시에는 아무 비용도 들지 않는다. 비용은 다른 곳에 있다. 리플렉션 매핑은 요청 시점에 행마다 더 느리고, 무엇보다 빠진 항목이 빌드 시점이 아니라 런타임에 실패한다. 설정 항목 없이 추가된 레코드는 컴파일 에러가 아니며, --no-fallback도 잡지 못한다: 빌드는 성공하고, 그 레코드에 대한 첫 쿼리가 프로덕션에서 500으로 응답한다. 트레이싱 에이전트(-agentlib:native-image-agent=config-output-dir=…​)는 앱을 클릭해 다니는 JVM 실행에서 설정을 써낼 수 있지만, 그 대가로 실행 중에 본 다른 모든 것까지 쓸어 담는다.

패키지에서 설정을 도출하기

예제는 항목을 빠뜨리는 실패를 빌드 시점에 막는다: 설정을 손으로 관리하는 대신 패키지에서 도출한다. example-flashcard 빌드의 이 태스크는 flashcard.domain의 모든 타입을 등록하므로, 패키지에 추가된 레코드는 빠질 수 없다:

def domainReflectConfig = tasks.register('domainReflectConfig') {
    def domainDir = file('src/main/java/flashcard/domain')
    def outDir = layout.buildDirectory.dir('generated/reflect-config')
    inputs.dir(domainDir)
    outputs.dir(outDir)
    doLast {
        def entries = domainDir.listFiles()
                .findAll { it.name.endsWith('.java') }
                .collect { 'flashcard.domain.' + it.name.replace('.java', '') }
                .sort()
                .collect { ['name': it, 'allDeclaredConstructors': true,
                            'allDeclaredMethods': true, 'allDeclaredFields': true] }
        def json = outDir.get().file('META-INF/native-image/flashcard-domain/reflect-config.json').asFile
        json.parentFile.mkdirs()
        json.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(entries))
    }
}
sourceSets.main.resources.srcDir(domainReflectConfig)

srcDir(domainReflectConfig)는 생성된 디렉터리를 main 리소스에 추가하면서 태스크 의존성도 함께 실어 오므로, processResources가 그 파일을 집어 가고, jar든 네이티브 이미지든 거기서 빌드되는 모든 것도 마찬가지다. 쓸어 담는 것이 핵심이자 주의점이다: 패키지의 모든 타입이 최상위 타입 하나당 소스 파일 하나라는 전제로 리플렉션에 열린다. 따라서 이 태스크는 리플렉션 대상 타입만 담은 패키지에 적합하다. 이 페이지의 두 변형은 같은 방식으로 검증했다: GraalVM CE 25로 빌드하고, 실행 중인 바이너리에서 모든 리포지토리 코드 경로를 거쳐 실행했다.