module-info.java가classpath 지옥을 끝낸 방법 — JPMS와 모듈 시스템
Java 9 이전에는 모든 클래스가 classpath에 평등하게 놓여 있었다. 의존성 충돌(Guava 20 vs 30이 동시에 필요), 내부 API 무단 사용(sun.misc.Unsafe), 공개 API와 내부 API의 구분 불가 — 이 문제들을 해결하기 위해 JPMS(Java Platform Module System)가 도입됐다. (JEP-261)
classpath는 "모든 클래스가 평등하게 섞여 있는 거대한 주머니"다. 의존성 충돌, 내부 API 무단 사용, "이 클래스가 어디서 왔는지" 모르는 혼란. JPMS는 이것에 경계(wall)를 그린다. 이 글은 모듈이 무엇을 막고 무엇을 여는지 풀어간다.
모듈이란 — 패키지 상위의 캡슐화 단위
flowchart TD
MOD["Module<br/>(module-info.java)"]
MOD --> EXP["exports 패키지<br/>(공개 API)"]
MOD --> HID["미exports 패키지<br/>(내부 구현)"]
MOD --> REQ["requires 모듈<br/>(의존성)"]
MOD --> OPN["opens 패키지<br/>(리플렉션 허용)"]
모듈은 이름이 있는 패키지의 집합이다. 모듈은 어떤 패키지를 공개(exports)하고, 어떤 모듈에 의존(requires)하는지 선언한다.
module-info.java — 모듈 선언
// Java 25 — module-info.java
module com.example.myapp {
requires java.sql; // java.sql 모듈에 의존
requires spring.context; // 외부 모듈 의존
requires transitive java.logging; // 이 모듈을 사용하는 곳에도 전파
exports com.example.myapp.api; // 공개 API 패키지
// com.example.myapp.internal는 미export → 외부 접근 불가
opens com.example.myapp.entity to hibernate.core; // 특정 모듈에 리플렉션 허용
opens com.example.myapp.dto; // 모든 모듈에 리플렉션 허용
}
| 지시어 | 의미 |
|---|---|
requires M |
모듈 M에 의존 |
requires transitive M |
M에 의존 + 이 모듈을 사용하는 곳에 M도 전파 |
exports P |
패키지 P를 모든 모듈에 공개 |
exports P to M1, M2 |
패키지 P를 특정 모듈에만 공개 |
opens P |
패키지 P의 모든 멤버(포함 private)에 리플렉션 허용 |
opens P to M |
특정 모듈에만 리플렉션 허용 |
uses S |
서비스 S의 소비자 (ServiceLoader) |
provides S with Impl |
서비스 S의 구현체 제공 |
requires는 컴파일 타임 의존성,exports는 공개 범위. 둘 다 생략하면 모듈이 아닌 "unnamed module"(classpath)로 동작한다 — 기존 코드와의 하위 호환성을 유지한다.
모듈이 해결하는 문제
1. 강한 캡슐화 — 내부 패키지 보호
// Java 25 — 모듈이 없으면 (classpath)
// 누구나 com.example.internal.InternalUtils에 접근 가능 (리플렉션으로 private까지)
// Java 25 — 모듈이 있으면
module com.example.myapp {
exports com.example.myapp.api; // API만 공개
// com.example.myapp.internal은 미export → 컴파일 에러 + 런타임 접근 차단
}
2. 의존성 명시 — "classpath 지옥" 종결
모듈은 자신이 필요로 하는 모듈을 requires로 명시한다. 누락된 의존성은 컴파일 타임에 발견된다. classpath에서는 런타임 NoClassDefFoundError로 나중에 발견된다.
3. jlink — 사용자 정의 경량 런타임
# Java 25 — 필요한 모듈만 포함한 경량 JRE 생성
jlink --module-path $JAVA_HOME/jmods \
--add-modules com.example.myapp \
--output custom-runtime
ls -lh custom-runtime/bin/java # 약 40~80MB (전체 JDK 대비 극소)
모듈 vs classpath — 공존 모델
Java 9+는 모듈과 classpath를 공존시킨다:
- named module:
module-info.java가 있는 JAR → 모듈 경로(module path)에서 동작 - unnamed module:
module-info.java가 없는 JAR → classpath에서 동작 (기존 방식) - automatic module: classpath JAR에 이름을 자동 부여하여 모듈 경로에서 사용
Spring Boot 애플리케이션은 대부분 unnamed module(classpath)로 실행된다 —
module-info.java없이도 Java 25에서 정상 동작한다. 모듈화는 라이브러리/JDK 수준에서 의미가 있고, 애플이케이션 수준에서는 선택적이다.
ServiceLoader와 모듈 — 느슨한 결합
모듈 시스템의 uses/provides 지시어는 ServiceLoader 메커니즘과 결합하여 플러그인 아키텍처를 지원한다.
// Java 25 — 서비스 인터페이스 정의 (api 모듈)
package com.example.api;
public interface PaymentProcessor {
boolean pay(long amount);
}
// Java 25 — 서비스 구현체 (impl 모듈)
package com.example.impl;
import com.example.api.PaymentProcessor;
public class StripeProcessor implements PaymentProcessor {
@Override public boolean pay(long amount) {
System.out.println("Stripe: " + amount + "원 결제");
return true;
}
}
// impl 모듈의 module-info.java
module com.example.impl {
requires com.example.api;
provides com.example.api.PaymentProcessor with com.example.impl.StripeProcessor;
}
// api 모듈의 module-info.java
module com.example.api {
exports com.example.api;
uses com.example.api.PaymentProcessor; // 서비스 소비자 선언
}
// 사용 — ServiceLoader로 구현체 발견
import java.util.ServiceLoader;
ServiceLoader<PaymentProcessor> loaders = ServiceLoader.load(PaymentProcessor.class);
for (PaymentProcessor processor : loaders) {
processor.pay(50000);
}
provides ... with ...로 구현체를 등록하고,uses로 소비를 선언한다. ServiceLoader가 런타임에 classpath/module path에서 구현체를 자동 발견한다 — JDBC 드라이버 자동 로딩이 이 메커니즘으로 동작한다.
classpath에서 모듈로의 마이그레이션
단계적 마이그레이션 전략
- unnamed module로 시작 — 기존 코드를 그대로 classpath에서 실행 (변경 없음)
- automatic module 도입 — 라이브러리 JAR를 모듈 경로에 배치. JAR 파일명에서 자동 모듈명 추출 (
spring-core-6.1.jar→spring.core) - named module로 전환 — 핵심 모듈부터
module-info.java추가
자주 발생하는 문제
| 문제 | 원인 | 해결 |
|---|---|---|
module not found |
모듈 경로에 JAR가 없음 | --module-path 확인 |
package not exported |
다른 모듈의 내부 패키지 접근 | exports 추가 또는 접근 방식 변경 |
InaccessibleObjectException |
리플렉션으로 내부 접근 시도 | opens 추가 또는 --add-opens |
| 순환 의존 | 모듈 A가 B를, B가 A를 requires | 모듈 재설계 (순환 제거) |
| split package | 같은 패키지가 두 모듈에 존재 | 패키지 재구성 (한 모듈로 통합) |
split package는 모듈 시스템에서 금지된다 — 같은 패키지(
com.example.foo)가 두 모듈에 있으면 컴파일/실행 에러. classpath 시절에는 가능했지만(악습), 모듈 시스템에서는 원천 차단한다.
디버깅 도구
# Java 25 — 모듈 확인
java --describe-module java.sql # java.sql 모듈의 exports/requires 출력
java --list-modules # 사용 가능한 모든 모듈
java --show-module-resolution # 모듈 해석 과정 출력
# 의존성 분석
jdeps --module-path libs myapp.jar # 모듈 의존성 분석
jdeps --jdk-internals myapp.jar # 내부 API 사용 검사
JDK 자체 모듈화 — java.base부터 시작
Java 9에서 JDK 자체가 모듈화되었다. 거대한 rt.jar가 사라지고, 기능별로 분할된 모듈로 재구성됐다:
| 모듈 | 포함 | 비고 |
|---|---|---|
java.base |
java.lang, java.util, java.io |
모든 모듈이 암묵적으로 requires |
java.sql |
JDBC API | requires java.logging, java.transaction.xa |
java.net.http |
HttpClient | Java 11+ |
java.desktop |
Swing, AWT | 무거움, 서버에서 보통 불필요 |
jdk.unsupported |
sun.misc.Unsafe 등 |
공식 미지원이지만 존재 |
# Java 25 — java.base가 exports하는 패키지
java --describe-module java.base | head -20
java.base는 모든 모듈이 암묵적으로 의존하는 최상위 모듈이다 —requires java.base를 명시할 필요 없다.String,List,System등이 여기에 있다.
jlink 실전 — 컨테이너 이미지 크기 최적화
# Java 25 — Spring Boot 앱용 경량 런타임 생성
jlink --module-path "$JAVA_HOME/jmods:app/modules" \
--add-modules com.example.myapp \
--include-locales=en,ko \
--strip-debug \
--compress=zip-6 \
--no-header-files \
--no-man-pages \
--output /opt/myapp-jre
# Docker 이미지에서 사용
# FROM debian:bookworm-slim
# COPY --from=builder /opt/myapp-jre /opt/jre
# COPY app.jar /app/app.jar
# ENTRYPOINT ["/opt/jre/bin/java", "-jar", "/app/app.jar"]
# 전체 JDK 이미지(~400MB) → 경량 런타임(~60MB)
jlink로 생성한 런타임은 JDK가 아닌 JRE 수준의 맞춤형 이미지다.java명령만 포함되고javac,jshell등은 없다. 컨테이너 환경에서 이미지 크기를 획기적으로 줄일 수 있다.
Automatic Module — 레거시 JAR를 모듈 경로에서 사용
module-info.java가 없는 JAR를 모듈 경로에 배치하면 automatic module이 된다. 모듈명은 JAR 파일명에서 추론된다:
spring-core-6.1.4.jar → spring.core
jackson-databind-2.16.jar → jackson.databind
commons-lang3-3.14.jar → commons.lang3
// module-info.java에서 automatic module을 requires
module com.example.myapp {
requires spring.core; // automatic module
requires jackson.databind; // automatic module
}
automatic module은 모든 패키지를 exports하는 것으로 간주된다 — 캡슐화의 이점이 없지만, 레거시 JAR와의 호환성을 제공한다. 모듈화된 라이브러리와 모듈화되지 않은 라이브러리가 혼재하는 현실적인 과도기 해법이다.
실습 — 모듈화된 간단한 프로젝트
myapp/
├── src/
│ ├── com.example.myapp/
│ │ ├── module-info.java
│ │ └── com/example/myapp/
│ │ ├── Main.java
│ │ └── api/PublicService.java// module-info.java
module com.example.myapp {
exports com.example.myapp.api;
uses com.example.myapp.api.PublicService;
}
// Main.java
package com.example.myapp;
import com.example.myapp.api.PublicService;
public class Main {
public static void main(String[] args) {
// module path에서 실행 시 exports된 패키지만 접근 가능
PublicService service = new PublicService();
service.run();
}
}
# 컴파일 및 실행
javac -d out $(find src -name "*.java")
java --module-path out --module com.example.myapp/com.example.myapp.Main
요약 — 이 글의 결론
- JPMS(Java 9+)는 모듈이라는 캡슐화 단위를 추가했다. 모듈은
module-info.java로 공개 범위(exports)와 의존성(requires)을 선언한다. - 모듈은 강한 캡슐화를 제공한다. exports하지 않은 패키지는 외부 모듈에서 접근할 수 없다 — classpath 시절에는 리플렉션으로 모든 것에 접근할 수 있었다.
- 대부분의 애플리케이션은 unnamed module(classpath)로 동작한다. 모듈화는 라이브러리와 JDK 내부 모듈에 주로 적용된다. Spring Boot 앱은 module-info 없이도 정상 동작한다.
jlink로 경량 런타임을 생성할 수 있다. 애플리케이션이 사용하는 모듈만 포함한 맞춤형 JRE를 만들어 배포 크기를 줄인다.opens로 리플렉션 접근을 제어한다. Hibernate, Jackson 등 리플렉션 기반 라이브러리는opens가 필요하다 — Java 16+에서는opens없이 internal에 접근하면InaccessibleObjectException이 발생한다.
생각해 볼 문제
- unnamed module(classpath)에서 named module의 exports된 패키지에 접근할 수 있는가?
requires transitive를 쓰면 의존성이 어떻게 전파되는가? 언제 유용한가?- 기존 JAR를 automatic module로 변환할 때 모듈명은 어떻게 결정되는가?
jlink로 생성한 런타임에 추가 모듈을 나중에 포함할 수 있는가?- Spring Boot fat JAR는 모듈화되어 있는가? unnamed module인가?
- 상향식(bottom-up) 마이그레이션이 권장되는 이유는 무엇인가? 하향식의 문제점은?
--add-opensJVM 옵션은 언제 필요한가? Hibernate/Jackson이 사용하는 사례를 조사하라.
Module Import Declarations (Java 25, JEP-511) — import의 진화
Java 25에서 import module 선언이 정식(finalized)으로 도입됐다. (JEP-511) 모듈이 export하는 모든 패키지를 한 줄로 임포트할 수 있다.
// Java 25 — 모듈 임포트
import module java.base;
// → java.lang, java.util, java.io, java.math 등 java.base의 모든 패키지 사용 가능
public class Demo {
public static void main(String[] args) {
List<String> list = new ArrayList<>(); // java.util
Map<String, Integer> map = Map.of("a", 1); // java.util
Path path = Path.of("file.txt"); // java.nio.file
// 모두 import 문 없이 사용 가능
}
}
| 방식 | 예 | 특징 |
|---|---|---|
| 기존 type-import-on-demand | import java.util.*; |
패키지 단위 |
| Module Import (Java 25+) | import module java.base; |
모듈 단위 (모든 export 패키지) |
Module Import는 초보자가 패키지 계층을 외우지 않고도 핵심 API를 바로 사용할 수 있게 한다. 또한 다양한 패키지를 사용하는 간단한 스크립트에서
import문의 노이즈를 줄인다. (JEP-511)
참고
- JEP-261: Module System - 접근 2026-07-10
- JLS §7 - Packages and Modules - 접근 2026-07-10
- Module API - 접근 2026-07-10
- jlink 가이드 - 접근 2026-07-10
- jdeps 가이드 - 접근 2026-07-10
- ServiceLoader API - 접근 2026-07-10
- JEP-200: Modular JDK - 접근 2026-07-10
- JEP-511: Module Import Declarations - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 25. classloader jit (0) | 2026.07.12 |
|---|---|
| Java - 24. jvm gc (0) | 2026.07.12 |
| Java - 22. networking (0) | 2026.07.12 |
| Java - 21. jdbc (0) | 2026.07.12 |
| Java - 20. virtual threads (0) | 2026.07.12 |
