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중심의 느슨한 경계를, 모듈 단위로 쪼개고 의존성을 명확히 하자는 프로젝트

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 200 | JDK 자체를 모듈로 분해 (java.base, java.sql, …) |
| JEP 201 | JDK 소스코드를 모듈 구조로 재조직 |
| 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 실행 시 일어나는 일:
모듈 그래프 해석 (Module Resolution)
--add-modules로 지정한 루트 모듈부터requires선언을 따라 전이 의존성 전체를 계산한다. 그래프에 없는 모듈은 이미지에서 완전히 배제된다. GraalVM의 Points-to Analysis가 “메서드 단위 도달 가능성”을 추적한다면, jlink는 “모듈 단위 도달 가능성”을 추적하는 셈이다.jimage 생성 선택된 모듈들의 클래스, 네이티브 라이브러리, 설정 파일을
lib/modules단일 파일로 통합한다. jimage는 JAR보다 클래스 탐색이 빠르도록 설계된 컨테이너 포맷이다.플러그인 파이프라인 (이미지 최적화) 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.독립 런타임 이미지 출력
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)

- 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의 영역이다.
6. jlink vs GraalVM Native Image
둘 다 “빌드 타임에 필요한 것만 추린다”는 철학을 공유하지만, 자르는 깊이가 다르다.
| jlink | GraalVM 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 한 줄. 그걸로 이미지가 절반이 된다면 안 할 이유를 찾는 게 더 어렵다.
참고 자료
- JEP 282: jlink: The Java Linker
- JEP 220: Modular Run-Time Images
- JEP 493: Linking Run-Time Images without JMODs
- Baeldung — Guide to jlink
- Coding Steve — JLink: Java Runtime Optimization Reference Guide
- DZone — Ways To Reduce JVM Docker Image Size
- Snyk — Using JLink to create smaller Docker images for Spring Boot
- Adoptium — Eclipse Temurin JDK 24 enables JEP 493
- ej-technologies — Cross-platform JRE bundle creation under threat from JEP 493