Post

jlink — JRE가 사라진 시대, 런타임을 직접 조립한다

jlink — JRE가 사라진 시대, 런타임을 직접 조립한다

배경

GraalVM 포스트와 이어짐. GraalVM은 Native Image는 강력하지만 대가가 크다 — Closed World Assumption, 리플렉션 메타데이터 등록, 수 분짜리 빌드 시간.

그런데 실무에서 마주치는 문제의 상당수는 사실 이거다:

“왜 Hello World 하나 띄우는 컨테이너 이미지가 400MB냐?”

시작 속도가 아니라 배포 크기 문제라면, JVM을 버릴 필요까지는 없다. JVM은 그대로 유지하면서 필요한 것만 담은 런타임을 직접 조립하는 방법 — 그게 jlink다.


1. jlink란?

jlink는 JDK 9부터 포함된 Java Linker다. JEP 282로 정의됐고, Project Jigsaw(모듈 시스템)의 산출물 중 하나다.

  • 참고 : Project Jigsaw란 JAVA9 이전의 JAR중심의 느슨한 경계를, 모듈 단위로 쪼개고 의존성을 명확히 하자는 프로젝트 jdk

    jlink는 모듈 집합과 그 의존성을 조립하고 최적화해서 JEP 220에서 정의한 커스텀 런타임 이미지를 만드는 도구다. — JEP 282: jlink: The Java Linker

핵심은 : JEP 261은 컴파일 타임(javac)과 런타임(java) 사이에 Link Time이라는 선택적 단계를 새로 정의했다.

1
2
3
4
5
6
7
8
[기존]
javac (compile time) ──────────────▶ java (runtime)

[JDK 9+]
javac (compile time) ─▶ jlink (link time) ─▶ java (runtime)
                         ↑
              전체 프로그램을 아는 상태에서
              whole-world 최적화 가능한 지점

Link Time은 컴파일 타임에는 어렵고 런타임에는 비싼 최적화를 할 수 있는 지점이다.

“거의 GraalVM Native Image의 빌드 타임 정적 분석과 정확히 같은 발상임!”

차이는 jlink가 기계어 컴파일까지는 가지 않고, 모듈 조립과 이미지 최적화 선에서 멈춘다는 것.

Project Jigsaw 4대 JEP 속 위치

JEP내용
JEP 200JDK 자체를 모듈로 분해 (java.base, java.sql, …)
JEP 201JDK 소스코드를 모듈 구조로 재조직
JEP 220모듈형 런타임 이미지 포맷 정의 (rt.jar 제거)
JEP 261모듈 시스템 본체 + Link Time 단계 정의
JEP 282그 Link Time을 수행하는 도구 = jlink

2. 왜 문제인가 — JRE의 종말

rt.jar 시대

JDK 8까지 Java 런타임의 실체는 rt.jar 하나였다. 60MB가 넘는 단일 JAR에 플랫폼 클래스 전체가 들어 있었고, Swing을 안 쓰든 CORBA를 안 쓰든 무조건 통째로 로딩 대상이었다.

  • Swing = Java의 GUI(화면) 만들기 라이브러리
  • CORBA = 서로 다른 프로그램끼리 원격 통신하게 해주는 오래된 분산 객체 기술

JEP 220이 이 구조를 갈아엎었다. rt.jar와 tools.jar가 제거되고, 플랫폼 전체가 모듈 단위(java.base, java.xml, java.desktop, …)로 쪼개져 lib/modules라는 jimage 포맷 파일에 담기게 됐다.

그리고 JRE가 사라졌다

더 중요한 변화. JDK 11부터 Oracle은 JRE를 별도 배포하지 않는다. (앱마다 필요한 런타임이 다르기 떄문) “런타임만 필요한 사람”을 위한 공식 배포판이 없어진 것이다. 공식 입장은 이렇다:

런타임이 필요하면 jlink로 직접 만들어라.

즉 jlink는 편의 도구가 아니라, JRE라는 개념을 대체하는 공식 메커니즘이다. 그런데 현실은? 대부분의 팀이 그냥 전체 JDK를 컨테이너에 넣는다. eclipse-temurin:21 이미지를 그대로 base로 쓰면서, 앱이 쓰지도 않는 컴파일러, 디버거, 수십 개 모듈을 매 배포마다 레지스트리에서 끌어온다.

1
2
3
4
5
6
전체 JDK 컨테이너에 실제로 들어있는 것:
├─ javac, jshell, jconsole ...   ← 프로덕션에서 안 씀
├─ jmods/ (패키지드 모듈)         ← JDK 22 기준 약 89MB, 안 씀
├─ java.desktop (Swing/AWT)      ← 서버 앱이면 안 씀
├─ java.corba였던 것들의 후예들   ← 안 씀
└─ java.base + 내가 쓰는 몇 개    ← 이것만 필요

3. 동작 원리 — jlink는 무엇을 하나

jlink 실행 시 일어나는 일:

  1. 모듈 그래프 해석 (Module Resolution) --add-modules로 지정한 루트 모듈부터 requires 선언을 따라 전이 의존성 전체를 계산한다. 그래프에 없는 모듈은 이미지에서 완전히 배제된다. GraalVM의 Points-to Analysis가 “메서드 단위 도달 가능성”을 추적한다면, jlink는 “모듈 단위 도달 가능성”을 추적하는 셈이다.

  2. jimage 생성 선택된 모듈들의 클래스, 네이티브 라이브러리, 설정 파일을 lib/modules 단일 파일로 통합한다. jimage는 JAR보다 클래스 탐색이 빠르도록 설계된 컨테이너 포맷이다.

  3. 플러그인 파이프라인 (이미지 최적화) Link Time 최적화가 여기서 일어난다.

    1
    2
    3
    4
    5
    6
    7
    8
    
    jlink --module-path $JAVA_HOME/jmods:mods \
          --add-modules com.example.myapp \
          --strip-debug \          # 디버그 심볼 제거
          --no-header-files \      # JNI 헤더 제거
          --no-man-pages \         # man 페이지 제거
          --compress zip-6 \       # 리소스 압축 (zip-0 ~ zip-9)
          --launcher run=com.example.myapp/com.example.Main \
          --output myruntime
    

    compress 옵션 참고: JDK 21부터 --compress=2 표기가 zip-[0-9] 표기로 바뀌었다. 기본값은 zip-6.

  4. 독립 런타임 이미지 출력 myruntime/bin/java로 실행 가능한, 그 자체로 완결된 디렉토리가 나온다. JAVA_HOME 설치 불필요.


4. 현실적인 문제 — 내 앱은 모듈화가 안 돼 있는데?

여기가 jlink의 가장 큰 진입장벽이자, 가장 많이 오해받는 지점이다.

jlink 이미지에는 명시적 모듈(module-info.class를 가진 모듈)만 들어갈 수 있다. classpath 기반 레거시 앱은 물론이고, module-info 없는 JAR를 모듈 경로에 올렸을 때 만들어지는 automatic module도 jlink에 포함할 수 없다. automatic module은 classpath 전체를 읽을 수 있는 특권이 있어서 의존성 그래프를 확정할 수 없기 때문이다.

Spring Boot 생태계 대부분의 라이브러리가 아직 완전한 모듈화가 안 돼 있으므로, “Spring Boot 앱 자체를 모듈로 만들어 jlink에 넣는” 그림은 현실적으로 어렵다.

실무 표준 패턴: 커스텀 JRE + classpath 실행

그래서 실무에서는 접근을 뒤집는다. 앱을 모듈화하는 게 아니라, 앱이 필요로 하는 JDK 모듈만 알아내서 런타임을 조립하고, 앱은 그 위에서 classpath(fat jar)로 실행한다.

1단계 — jdeps로 앱이 실제 사용하는 JDK 모듈 분석:

1
2
3
4
5
6
7
jdeps
  --ignore-missing-deps \             # 못 찾는 의존성이 있어도 중단하지 말고 계속 분석
  --multi-release 21 \                # 멀티 릴리즈 JAR를 Java 21 기준으로 해석
  --print-module-deps \               # 앱 실행에 필요한 JDK 모듈 목록만 출력
  --class-path 'BOOT-INF/lib/*' \     # app.jar가 의존하는 라이브러리 JAR들의 위치 지정
  app.jar                             # 분석 대상 애플리케이션 JAR
# 출력 예: java.base,java.logging,java.sql,java.naming,java.desktop,...

2단계 — 그 목록으로 커스텀 JRE 생성:

1
2
3
jlink --add-modules $(jdeps ... --print-module-deps app.jar) \
      --strip-debug --no-man-pages --no-header-files --compress zip-6 \
      --output /customjre

3단계 — 커스텀 JRE 위에서 평범하게 실행:

1
/customjre/bin/java -jar app.jar

주의점 하나. jdeps는 정적 분석이라 리플렉션으로만 쓰이는 모듈(JDBC 드라이버의 java.sql 연쇄, 암호화 관련 jdk.crypto.ec 등)을 놓칠 수 있다. Spring Boot이면 java.instrument, jdk.unsupported 같은 모듈을 수동으로 추가해야 하는 경우가 많다. 그래서 커스텀 JRE는 반드시 통합 테스트를 통과시켜야 한다 — 여기는 GraalVM의 리플렉션 힌트 문제의 축소판이다. 규모가 훨씬 작을 뿐.


5. 실전 — Docker 멀티스테이지 빌드

컨테이너 환경에서의 전형적인 구성:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Stage 1: 빌드 + 커스텀 JRE 생성
FROM eclipse-temurin:21 AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootJar

RUN jar xf build/libs/app.jar && \
    MODULES=$(jdeps --ignore-missing-deps --multi-release 21 \
        --print-module-deps \
        --class-path 'BOOT-INF/lib/*' \
        build/libs/app.jar) && \
    jlink --add-modules "$MODULES" \
          --strip-debug --no-man-pages --no-header-files \
          --compress zip-6 \
          --output /customjre

# Stage 2: 최소 베이스 + 커스텀 JRE + 앱
FROM debian:bookworm-slim
ENV JAVA_HOME=/opt/java
ENV PATH="$JAVA_HOME/bin:$PATH"
COPY --from=builder /customjre $JAVA_HOME
COPY --from=builder /app/build/libs/app.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

수치로 보는 효과

공개된 벤치마크들에서 일관되게 나오는 범위:

  • 한 레퍼런스 가이드에서는 jlink 최적화 플래그를 전부 적용해 배포 크기를 473MB에서 191MB로, 60% 감소시킨 결과를 보고했다 (Coding Steve — JLink Reference Guide)
  • 전체 JDK 포함 이미지가 약 304MB일 때, jdeps + jlink로 만든 커스텀 JRE 이미지는 약 125MB로 기존의 35% 수준이었고, JAVA_HOME 디렉토리 자체는 약 90MB까지 줄었다 (Rajat Panda — Custom JRE using Jlink) before after
  • Spring Petclinic 기준으로 jlink 적용 컨테이너가 217MB로, 이전 대비 절반 크기가 됐다는 사례도 있다 (DZone — Ways To Reduce JVM Docker Image Size)
  • BellSoft 엔지니어 가이드에서도 적절한 베이스 이미지와 결합하면 컨테이너 이미지 크기를 50% 이상 줄일 수 있다고 정리한다 (Catherine Edelveis — How to use jlink)

앱 구성에 따라 다르지만 대략 “전체 JDK 대비 50~70% 감소, 최종 이미지 100~200MB대”가 현실적인 기대치다.

크기 말고 얻는 것

  • 공격 표면(Attack Surface) 감소 — 이미지에 없는 모듈은 취약점 스캔 대상도, 익스플로잇 경로도 아니다. 컨테이너에 javac와 jshell이 들어있을 이유가 없다.
  • 배포/스케일아웃 속도 — 레지스트리 pull 시간은 이미지 크기에 비례한다. 노드가 처음 이미지를 받는 Cold Node 상황에서 체감이 크다.
  • 클래스로딩 소폭 개선 — 로딩 대상 모듈 자체가 줄어드니 시작 시간도 약간 개선된다. 다만 이건 부수 효과 수준이다. Spring Boot 기동 시간의 지배 요인은 빈 초기화지 플랫폼 클래스로딩이 아니므로, jlink에 Cold Start 극복을 기대하면 안 된다. 그건 Native Image의 영역이다.

둘 다 “빌드 타임에 필요한 것만 추린다”는 철학을 공유하지만, 자르는 깊이가 다르다.

 jlinkGraalVM Native Image
잘라내는 단위JDK 모듈메서드/필드 (Points-to Analysis)
결과물JVM 포함 런타임 디렉토리JVM 없는 단일 네이티브 바이너리
실행 방식일반 JVM (JIT 그대로)AOT 컴파일된 기계어 + Substrate VM
시작 시간소폭 개선극적 개선 (ms 단위)
Peak 성능JIT 그대로 = 손해 없음런타임 프로파일 없어 상대적 열세
리플렉션/동적 프록시제약 없음메타데이터 등록 필요
빌드 시간수 초수 분
앱 코드 요구사항없음 (jdeps 패턴 사용 시)Native 호환성 검증 필요
크기이미지 100~200MB대수십 MB 단일 바이너리

정리하면:

  • jlink = 리스크 거의 없이 배포 크기와 보안 표면을 줄이는 도구. JIT, 리플렉션, 기존 라이브러리 생태계 전부 그대로.
  • Native Image = Cold Start와 메모리가 지상 과제일 때, 호환성 비용을 지불하고 얻는 도구.

상호 배타적이지도 않다. “지금 당장 jlink로 이미지 다이어트 → Serverless로 옮길 컴포넌트만 선별적으로 Native Image” 같은 단계적 전략이 가능하다.


7. 제약 정리

  • automatic module 포함 불가 — 앱/라이브러리를 이미지 안에 넣으려면 진짜 모듈화(module-info)가 필요. 아니면 커스텀 JRE + classpath 패턴으로 우회
  • jdeps 정적 분석의 한계 — 리플렉션 기반 모듈 사용 누락 가능. 커스텀 JRE는 통합 테스트 필수 -> ex. 모듈이 필요없으니 빼야지 ~ 어? 알고보니 필요한거였네.
  • 플랫폼 종속 — 런타임 이미지는 OS/아키텍처별로 만들어야 함. 멀티 플랫폼 배포면 플랫폼별 빌드 파이프라인 필요 (JEP 493 적용 JDK에선 크로스 링크 자체가 불가)
  • JDK 마이너 업데이트 대응 — 커스텀 JRE는 “내가 만든 배포물”이다. 베이스 이미지 벤더가 보안 패치를 넣어주는 게 아니라, JDK 패치 릴리즈마다 내 파이프라인에서 재링크해야 한다. CI 자동화가 사실상 전제조건

언제 뭘 쓰나

시나리오권장
컨테이너 배포 크기/보안 표면 축소✅ jlink (비용 대비 효과 최고)
Serverless / Lambda Cold Start✅ Native Image (jlink로는 해결 안 됨)
데스크탑 앱 배포 (JavaFX 등)✅ jlink + jpackage 조합
장기 실행 고부하 서버✅ jlink된 런타임 위 JVM — JIT 이득 유지하면서 이미지만 슬림
일단 간단하게 이미지 줄이기JRE 베이스 이미지(temurin:21-jre)부터. jlink는 그다음 단계

마지막 행이 중요하다. 멀티스테이지 + JRE 베이스만으로 이미 이미지의 70%가 줄어들고, 100MB 아래로 내려가고 싶을 때 jlink를 더하고, 시작 시간과 메모리가 진짜 문제일 때(Serverless, scale-to-zero) GraalVM으로 가라는 단계적 접근이 실무적으로 합리적이다.


8. 결론

GraalVM 글의 결론이 “Serverless에서 JVM을 고집할 이유가 없다”였다면, jlink의 결론은 이거다:

“런타임에 전체 JDK를 고집할 이유가 없다.”

JRE 배포가 사라진 순간부터 런타임 조립은 개발자의 책임이 됐고, jlink는 그 책임을 수행하는 공식 도구다. Native Image처럼 실행 모델을 갈아엎는 게 아니라, JVM·JIT·리플렉션·기존 생태계를 전부 유지한 채 배포물만 다이어트한다. 도입 리스크가 낮은 만큼, GraalVM 도입을 고민하기 전에 먼저 깔아둘 수 있는 기본기에 가깝다.

빌드 파이프라인에 jdeps 한 줄, jlink 한 줄. 그걸로 이미지가 절반이 된다면 안 할 이유를 찾는 게 더 어렵다.


참고 자료

This post is licensed under CC BY 4.0 by the author.