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에서 모듈로의 마이그레이션

단계적 마이그레이션 전략

  1. unnamed module로 시작 — 기존 코드를 그대로 classpath에서 실행 (변경 없음)
  2. automatic module 도입 — 라이브러리 JAR를 모듈 경로에 배치. JAR 파일명에서 자동 모듈명 추출 (spring-core-6.1.jarspring.core)
  3. 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이 발생한다.

생각해 볼 문제

  1. unnamed module(classpath)에서 named module의 exports된 패키지에 접근할 수 있는가?
  2. requires transitive를 쓰면 의존성이 어떻게 전파되는가? 언제 유용한가?
  3. 기존 JAR를 automatic module로 변환할 때 모듈명은 어떻게 결정되는가?
  4. jlink로 생성한 런타임에 추가 모듈을 나중에 포함할 수 있는가?
  5. Spring Boot fat JAR는 모듈화되어 있는가? unnamed module인가?
  6. 상향식(bottom-up) 마이그레이션이 권장되는 이유는 무엇인가? 하향식의 문제점은?
  7. --add-opens JVM 옵션은 언제 필요한가? 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)


참고

'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