Java 모듈 시스템 — JPMS로 강한 캡슐화를 달성하는 방법
JAR 파일을 클래스패스에 넣으면 모든 public 클래스에 접근할 수 있는데, 이게 정말 괜찮은 걸까요?
Java 9 이전에는 패키지의 public 클래스가 어디서든 접근 가능했습니다. 내부 API인 sun.misc.Unsafe조차 마음만 먹으면 쓸 수 있었습니다. JPMS(Java Platform Module System)는 이 문제를 해결하기 위해 등장했습니다.
JPMS란 무엇인가
JPMS 는 Java 9에서 도입된 모듈 시스템으로, module-info.java를 통해 패키지 단위의 접근 제어와 명시적 의존성 선언 을 가능하게 합니다.
public이라고 무조건 외부에서 접근할 수 있는 것이 아니라, exports로 명시적으로 공개한 패키지만 외부 모듈에서 사용할 수 있습니다. 이 덕분에 "라이브러리 내부 구현을 숨기고 API만 노출"하는 것이 언어 차원에서 가능해졌습니다.
module-info.java 작성법
// src/module-info.java
module com.myapp.core {
// 다른 모듈에 공개할 패키지
exports com.myapp.core.api;
exports com.myapp.core.model;
// 특정 모듈에만 공개
exports com.myapp.core.internal to com.myapp.web;
// 리플렉션 접근 허용 (프레임워크용)
opens com.myapp.core.model to com.google.gson;
// 의존하는 모듈
requires java.sql;
requires transitive java.logging; // 전이적 의존성
// ServiceLoader 사용/제공
uses com.myapp.core.spi.PluginService;
provides com.myapp.core.spi.PluginService
with com.myapp.core.internal.DefaultPlugin;
}
주요 키워드 정리
| 키워드 | 의미 |
|---|---|
exports | 패키지를 외부에 공개 (컴파일+런타임) |
exports ... to | 특정 모듈에만 공개 |
opens | 리플렉션 접근 허용 |
opens ... to | 특정 모듈에만 리플렉션 허용 |
requires | 다른 모듈에 대한 의존성 선언 |
requires transitive | 전이적 의존성 (사용자에게도 전파) |
requires static | 컴파일 타임에만 필요한 의존성 |
uses | ServiceLoader로 소비할 서비스 인터페이스 |
provides ... with | 서비스 구현체 등록 |
exports vs opens — 왜 둘 다 필요한가
module com.myapp.core {
exports com.myapp.core.model; // public 접근만 허용
opens com.myapp.core.model; // 리플렉션까지 허용
}
exports만 하면:public타입과 멤버에 정상 접근 가능하지만,setAccessible(true)로 private 필드에 접근하려 하면InaccessibleObjectException발생opens까지 하면: 리플렉션으로 private 멤버까지 접근 가능
Spring, Jackson, Hibernate 같은 프레임워크는 리플렉션을 많이 사용하므로, 해당 패키지를 opens로 열어줘야 합니다.
// 모듈 전체를 리플렉션에 개방 (프레임워크 호환용)
open module com.myapp.core {
exports com.myapp.core.api;
requires java.sql;
}
모듈 유형 이해하기
1. 명시적 모듈 (Explicit Module)
module-info.java가 있는 JAR. 가장 이상적인 형태입니다.
2. 자동 모듈 (Automatic Module)
module-info.java가 없지만 모듈 경로 에 놓인 JAR.
# JAR 파일명에서 모듈 이름 추론
guava-31.1-jre.jar → guava
jackson-databind-2.15.jar → jackson.databind
자동 모듈의 특성:
- 모든 패키지가 자동으로 exports됨
- 다른 모든 모듈을 읽을 수 있음
Automatic-Module-Name매니페스트 속성으로 이름 지정 가능
3. 이름 없는 모듈 (Unnamed Module)
클래스패스 에 놓인 모든 JAR. 모듈 시스템 입장에서는 하나의 거대한 이름 없는 모듈로 취급됩니다.
명시적 모듈 → requires로 자동 모듈 참조 가능
자동 모듈 → 이름 없는 모듈의 클래스도 접근 가능
명시적 모듈 → 이름 없는 모듈 직접 참조 불가
ServiceLoader 연동
JPMS와 ServiceLoader를 함께 사용하면 플러그인 아키텍처를 깔끔하게 구현할 수 있습니다.
// API 모듈
module com.myapp.api {
exports com.myapp.api;
}
// 인터페이스 정의
package com.myapp.api;
public interface PaymentGateway {
void process(Payment payment);
}
// 구현 모듈
module com.myapp.payment.stripe {
requires com.myapp.api;
provides com.myapp.api.PaymentGateway
with com.myapp.payment.stripe.StripeGateway;
}
// 소비 모듈
module com.myapp.web {
requires com.myapp.api;
uses com.myapp.api.PaymentGateway;
}
// 사용 코드
ServiceLoader<PaymentGateway> loader = ServiceLoader.load(PaymentGateway.class);
for (PaymentGateway gateway : loader) {
gateway.process(payment);
}
마이그레이션 전략
기존 프로젝트를 JPMS로 마이그레이션하는 단계별 접근법입니다.
단계 1: Bottom-Up 전략
1. 의존성이 가장 적은 라이브러리부터 module-info.java 추가
2. 외부 라이브러리는 자동 모듈로 사용
3. 점진적으로 상위 모듈에 module-info.java 추가
단계 2: 컴파일 옵션 활용
# 캡슐화 경고 확인
java --illegal-access=warn -jar myapp.jar
# 특정 패키지 강제 개방 (임시 조치)
java --add-opens java.base/java.lang=ALL-UNNAMED -jar myapp.jar
# 모듈 추가
java --add-modules java.sql -jar myapp.jar
단계 3: jdeps로 의존성 분석
# 모듈 의존성 분석
jdeps --module-path libs -s myapp.jar
# module-info.java 자동 생성
jdeps --generate-module-info out/ myapp.jar
주의할 점
--add-opens 지옥
Java 16부터 --illegal-access=deny가 기본값이 되면서, 리플렉션으로 내부 API에 접근하던 라이브러리가 대량으로 깨졌습니다. Spring, Hibernate, Jackson 같은 프레임워크가 setAccessible(true)을 광범위하게 사용하기 때문에, 마이그레이션 시 --add-opens 옵션이 줄줄이 늘어나는 현상이 발생합니다.
# 실무에서 흔히 보이는 --add-opens 목록
java --add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.lang.reflect=ALL-UNNAMED \
--add-opens java.base/java.util=ALL-UNNAMED \
-jar myapp.jar
근본적 해결은 해당 패키지를 opens로 열어주는 것이지만, 모든 의존 라이브러리가 모듈화되지 않은 현실에서는 임시방편이 오래 갑니다.
split package 문제
같은 패키지가 여러 모듈에 나뉘어 있으면 JPMS가 거부합니다. 클래스패스에서는 문제없던 것이 모듈 경로로 전환하면 LayerInstantiationException으로 터집니다. 라이브러리들이 같은 패키지 이름을 공유하는 경우(예: javax.annotation) 이 문제가 자주 발생합니다.
jlink와 Docker 이미지 최적화
jlink로 필요한 모듈만 포함한 커스텀 런타임을 만들면 Docker 이미지 크기를 크게 줄일 수 있습니다.
jlink --module-path $JAVA_HOME/jmods:mods \
--add-modules com.myapp.core \
--output custom-runtime \
--strip-debug --compress=2
기본 JDK 이미지(300MB) 대신 4060MB 수준의 런타임을 만들 수 있습니다.
정리
| 항목 | 설명 |
|---|---|
| 핵심 개념 | module-info.java로 패키지 단위 접근 제어 + 명시적 의존성 |
| exports vs opens | exports는 컴파일/런타임 공개, opens는 리플렉션까지 허용 |
| requires transitive | 의존성을 전이시켜 사용자 모듈에도 전파 |
| 자동 모듈 | module-info 없는 JAR. 모듈 경로에 놓으면 JAR 이름으로 모듈 생성 |
| 마이그레이션 | bottom-up 전략. jdeps로 의존성 분석 후 점진적 전환 |
| jlink | 필요한 모듈만 포함한 경량 런타임 생성. Docker 이미지 최적화 |