12.5 자바 모듈 시스템 (Java Module System / JPMS)
자바 9부터 Jigsaw(직소) 프로젝트의 결과물로 모듈 시스템(Java Platform Module System, JPMS) 이 도입되었습니다. 모듈 시스템은 기존의 패키지와 JAR 파일 기반의 구조를 넘어서, 애플리케이션을 더 작고 안전하게 구성할 수 있도록 해줍니다.
1. 모듈 시스템이 도입된 배경
기존 자바의 문제점
JAR 지옥(JAR Hell) 이란 자바 9 이전에 발생하던 여러 문제점들을 통칭합니다.
기존 자바의 문제들:
1. classpath에 같은 클래스가 여러 JAR에 존재 → 어떤 클래스가 사용될지 예측 불가
2. 패키지가 여러 JAR에 분산 → "Split Package" 문제
3. public 클래스는 모든 곳에서 접근 가능 → 내부 API 숨길 방법 없음
4. JDK 전체를 다 포함해야 함 → 최소 수백 MB (IoT, 컨테이너 환경 부적합)
5. 의존성 누락을 런타임이 되어서야 발견 → 배포 후 에러 발생
모듈 시스템 이 이를 어떻게 해결했는지 살펴봅니다.
모듈 시스템 해결책:
1. 모듈 간 의존성을 명시적으로 선언 → 누락 시 시작 단계에서 에러 발생
2. 공개할 패키지만 exports → 내부 구현 완벽 은닉
3. JDK를 모듈로 분리 → 필요한 모듈만 선택하여 경량 런타임 생성
4. Split Package 금지 → 하나의 패키지는 하나의 모듈에만 속함
2. 모듈(Module)이란?
모듈은 패키지들의 컨테이너 입니다. 하나의 모듈 내에는 관련된 패키지들이 모여 있으며, 외부에 어떤 패키지를 공개할지(exports), 자기가 동작하기 위해 어떤 외부 모듈이 필요한지(requires)를 명확하게 선언합니다.
Java 계층 구조:
클래스(Class) → 패키지(Package) → 모듈(Module) → 애플리케이션(Application)
JDK 모듈 구조 예시
자바 9부터 JDK 자체도 모듈로 분리되었습니다.
java.base - String, Object, Collection 등 핵심 클래스 (자동 포함)
java.sql - JDBC, ResultSet 등 데이터베이스
java.desktop - AWT, Swing
java.xml - XML 파서
java.logging - java.util.logging
java.net.http - HttpClient (Java 11+)
jdk.crypto.ec - 타원곡선 암호화
...
3. module-info.java 작성법
모듈을 정의하려면 프로젝트의 최상위 소스 루트에 module-info.java 파일을 생성합니다.
기본 구조
my-app/
├── src/
│ └── com.myapp.core/ ← 모듈 소스 루트
│ ├── module-info.java ← 모듈 정의 파일
│ └── com/
│ └── myapp/
│ └── core/
│ ├── service/
│ │ └── UserService.java
│ └── internal/
│ └── DatabaseHelper.java
// module-info.java
module com.myapp.core {
// 외부에 노출할 패키지 선언 (기본적으로 모든 패키지는 비공개)
exports com.myapp.core.service;
// com.myapp.core.internal은 exports 없으므로 외부 접근 불가
// 이 모듈이 필요로 하는 다른 모듈 선언
requires java.sql; // JDBC 사용
requires java.logging; // 로깅 사용
// 컴파일/런타임 모두에 필요: requires (기본)
// 컴파일 시에만 필요: requires static (어노테이션 프로세서 등)
// 전이 의존성 (이 모듈을 쓰는 모듈도 자동으로 해당 모듈 사용 가능): requires transitive
requires transitive java.net.http; // 이 모듈을 쓰는 모듈도 HttpClient 사용 가능
}
4. module-info.java 키워드 완전 정리
requires: 의존 모듈 선언
module com.myapp {
// 기본 requires: 컴파일 + 런타임 모두 필요
requires java.sql;
// requires static: 컴파일 시에만 필요 (선택적 의존성)
// 런타임에 없어도 됨 - 어노테이션 프로세서나 빌드 전용 도구에 사용
requires static com.google.auto.service;
// requires transitive: 전이 의존성
// 이 모듈을 의존하는 모듈도 java.net.http를 자동으로 사용 가능
requires transitive java.net.http;
}
exports: 패키지 공개
module com.myapp.api {
// 모든 모듈에게 공개
exports com.myapp.api.model;
exports com.myapp.api.service;
// 특정 모듈에게만 공개 (친구 모듈)
exports com.myapp.api.internal to com.myapp.impl, com.myapp.test;
// 비공개 (exports 없는 패키지): 외부 모듈에서 접근 불가
// com.myapp.api.secret → 외부 접근 불가
}
opens: 리플렉션 접근 허용
module com.myapp {
// 런타임 리플렉션 접근 허용 (exports는 컴파일 타임 접근)
// Spring, JPA 같은 프레임워크가 리플렉션으로 private 필드에 접근할 때 필요
opens com.myapp.model; // 모든 모듈에 리플렉션 허용
opens com.myapp.entity to org.hibernate.orm; // Hibernate에만 리플렉션 허용
// exports + opens 차이:
// exports: 컴파일 타임 접근 허용 (import, 타입 참조 가능)
// opens: 런타임 리플렉션 접근 허용 (getDeclaredField, getDeclaredMethod 등)
}
uses / provides: 서비스 로더 (SPI)
// 서비스 소비자 모듈
module com.myapp.client {
// 이 인터페이스의 구현체를 ServiceLoader로 찾겠다고 선언
uses com.myapp.api.PaymentService;
}
// 서비스 제공자 모듈
module com.myapp.payment.kakao {
requires com.myapp.api; // PaymentService 인터페이스가 있는 모듈
// PaymentService 인터페이스를 KakaoPaymentService 클래스로 구현해서 제공
provides com.myapp.api.PaymentService
with com.myapp.payment.kakao.KakaoPaymentService;
}
5. 완전한 예제: 다중 모듈 프로젝트 구조
multi-module-project/
├── com.myapp.api/
│ ├── module-info.java
│ └── com/myapp/api/
│ ├── model/
│ │ └── User.java
│ └── service/
│ └── UserService.java (인터페이스)
│
├── com.myapp.core/
│ ├── module-info.java
│ └── com/myapp/core/
│ ├── service/
│ │ └── UserServiceImpl.java
│ └── internal/
│ └── DatabaseHelper.java (비공개)
│
└── com.myapp.app/
├── module-info.java
└── com/myapp/app/
└── Main.java
// com.myapp.api/module-info.java
module com.myapp.api {
exports com.myapp.api.model;
exports com.myapp.api.service;
}
// com.myapp.api/com/myapp/api/model/User.java
package com.myapp.api.model;
public record User(int id, String name, String email) {}
// com.myapp.api/com/myapp/api/service/UserService.java
package com.myapp.api.service;
import com.myapp.api.model.User;
import java.util.List;
import java.util.Optional;
public interface UserService {
void save(User user);
Optional<User> findById(int id);
List<User> findAll();
}
// com.myapp.core/module-info.java
module com.myapp.core {
requires com.myapp.api; // api 모듈에 의존
// UserServiceImpl만 공개 (DatabaseHelper는 내부 구현으로 숨김)
exports com.myapp.core.service;
// api 모듈의 UserService를 구현체로 제공
provides com.myapp.api.service.UserService
with com.myapp.core.service.UserServiceImpl;
}
// com.myapp.core/com/myapp/core/service/UserServiceImpl.java
package com.myapp.core.service;
import com.myapp.api.model.User;
import com.myapp.api.service.UserService;
import com.myapp.core.internal.DatabaseHelper;
import java.util.*;
public class UserServiceImpl implements UserService {
private final Map<Integer, User> store = new HashMap<>();
// DatabaseHelper는 내부 구현 - 외부에서 접근 불가
private final DatabaseHelper db = new DatabaseHelper();
@Override
public void save(User user) {
store.put(user.id(), user);
System.out.println("저장: " + user);
}
@Override
public Optional<User> findById(int id) {
return Optional.ofNullable(store.get(id));
}
@Override
public List<User> findAll() {
return new ArrayList<>(store.values());
}
}
// com.myapp.app/module-info.java
module com.myapp.app {
requires com.myapp.api; // 인터페이스 사용
requires com.myapp.core; // 구현체 사용
// com.myapp.core.internal.DatabaseHelper에는 접근 불가!
}
// com.myapp.app/com/myapp/app/Main.java
package com.myapp.app;
import com.myapp.api.model.User;
import com.myapp.api.service.UserService;
import com.myapp.core.service.UserServiceImpl;
public class Main {
public static void main(String[] args) {
UserService service = new UserServiceImpl();
service.save(new User(1, "김철수", "kim@example.com"));
service.save(new User(2, "이영희", "lee@example.com"));
service.findById(1).ifPresent(u ->
System.out.println("찾음: " + u));
service.findAll().forEach(System.out::println);
// DatabaseHelper db = new DatabaseHelper(); // 컴파일 에러!
// exports 되지 않은 internal 패키지는 접근 불가
}
}
6. 명시적 모듈 vs 자동 모듈 vs 이름 없는 모듈
┌─────────────────────────────────────────────────────────────┐
│ 모듈 유형 │
│ │
│ 1. 명시적 모듈 (Named Module) │
│ - module-info.java가 있는 JAR │
│ - 모든 의존성이 명시적으로 선언됨 │
│ - 강한 캡슐화 적용 │
│ │
│ 2. 자동 모듈 (Automatic Module) │
│ - module-info.java가 없는 JAR을 module-path에 배치 │
│ - JAR 이름이 모듈 이름이 됨 (MANIFEST.MF에 선언 가능) │
│ - 모든 패키지를 exports, 모든 명시적 모듈을 requires │
│ - 레거시 라이브러리 마이그레이션 시 임시로 사용 │
│ │
│ 3. 이름 없는 모듈 (Unnamed Module) │
│ - classpath에 있는 모든 클래스 │
│ - 하위 호환성을 위해 존재 │
│ - 명시적 모듈에서는 requires 불가 (접근 불가) │
└─────────────────────────────────────────────────────────────┘
7. Maven/Gradle 프로젝트에서 모듈 사용
Maven 설정
<!-- pom.xml -->
<project>
<groupId>com.myapp</groupId>
<artifactId>my-module-app</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>21</release>
<!-- module-info.java가 있으면 자동으로 모듈 모드 활성화 -->
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<!-- 레거시 라이브러리 (자동 모듈로 처리됨) -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
</dependencies>
</project>
Gradle 설정
// build.gradle
plugins {
id 'java'
id 'application'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
application {
mainModule = 'com.myapp.app' // 모듈 이름 지정
mainClass = 'com.myapp.app.Main'
}
dependencies {
implementation 'org.slf4j:slf4j-api:2.0.9'
}
Spring Boot와 모듈 시스템
// Spring Boot 프로젝트의 module-info.java
module com.myapp.springboot {
requires spring.boot;
requires spring.boot.autoconfigure;
requires spring.context;
requires spring.web;
requires spring.data.jpa;
requires jakarta.persistence;
// Spring이 리플렉션으로 @Component 스캔을 위해 opens 필요
opens com.myapp.controller to spring.web;
opens com.myapp.service to spring.context;
opens com.myapp.entity to org.hibernate.orm;
exports com.myapp; // @SpringBootApplication 클래스가 있는 패키지
}
실제 Spring Boot 프로젝트에서는 모듈 시스템 적용이 복잡합니다. 수많은 Spring 내부 모듈과 라이브러리들이 opens 선언을 요구하기 때문입니다. 대규모 엔터프라이즈 라이브러리 개발이 아닌 일반 Spring Boot 앱에서는 모듈 시스템을 강제하지 않는 경우가 많습니다.
8. 모듈 관련 JVM 옵션
# --module-path (-p): 모듈 경로 지정
java --module-path mods -m com.myapp.app/com.myapp.app.Main
# --module (-m): 실행할 모듈과 메인 클래스 지정
java -p out --module com.myapp.app/com.myapp.app.Main
# --add-opens: 런타임에 특정 패키지 리플렉션 허용 (모듈 선언 없이)
# Spring/Hibernate가 필요로 하는 경우
java --add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.util=ALL-UNNAMED \
-jar myapp.jar
# --add-exports: 런타임에 특정 패키지 공개
java --add-exports java.base/sun.security.util=com.myapp \
-jar myapp.jar
# --add-reads: 특정 모듈이 다른 모듈을 읽도록 허용
java --add-reads com.myapp=ALL-UNNAMED \
-jar myapp.jar
# --list-modules: 사용 가능한 모듈 목록 출력
java --list-modules
# --describe-module: 특정 모듈 정보 출력
java --describe-module java.sql
9. jlink: 경량 커스텀 JRE 생성
모듈 시스템의 핵심 기능 중 하나는 필요한 모듈만 포함한 최소 JRE 를 만들 수 있다는 점입니다.
# 앱에 필요한 모듈만 포함한 커스텀 JRE 생성
jlink \
--module-path $JAVA_HOME/jmods:mods \
--add-modules com.myapp.app \
--output custom-jre \
--strip-debug \
--compress 2 \
--no-header-files \
--no-man-pages
# 생성된 커스텀 JRE로 앱 실행
./custom-jre/bin/java -m com.myapp.app/com.myapp.app.Main
# 일반 JDK: ~300MB+
# jlink 결과: ~30MB (앱에 필요한 모듈만 포함)
# 멀티스테이지 빌드로 초경량 Docker 이미지 생성
FROM eclipse-temurin:21-jdk AS builder
COPY . /app
WORKDIR /app
RUN javac --module-source-path src -d mods $(find src -name "*.java")
RUN jlink --module-path $JAVA_HOME/jmods:mods \
--add-modules com.myapp.app \
--output /custom-jre --strip-debug --compress 2
FROM debian:slim
COPY --from=builder /custom-jre /opt/jre
ENTRYPOINT ["/opt/jre/bin/java", "-m", "com.myapp.app/com.myapp.app.Main"]
# 최종 이미지 크기: ~50MB (일반 JDK 기반 이미지 ~300MB 대비)
10. 모듈 시스템의 장점 정리
| 장점 | 설명 |
|---|---|
| 강한 캡슐화 | exports되지 않은 패키지는 public이어도 외부 접근 불가 |
| 명확한 의존성 | requires로 선언 → 누락 시 시작 단계에서 에러 감지 |
| 경량 런타임 | jlink로 필요한 모듈만 포함한 최소 JRE 생성 가능 |
| 보안 강화 | JDK 내부 API(sun.* 등) 접근 차단 |
| 성능 향상 | 불필요한 클래스 로딩 없이 필요한 모듈만 로드 |
| 병렬 빌드 | 모듈 의존성 그래프 기반으로 병렬 컴파일 가능 |
결론
모듈 시스템은 대규모 라이브러리 개발자나 대규모 엔터프라이즈 시스템 구축에 매우 유리합니다. 일반적인 소규모 웹 프로젝트에서는 Spring Boot 등의 프레임워크가 알아서 관리하는 경우가 많아 직접 모듈을 작성할 일이 적을 수 있지만, 백엔드 플랫폼 생태계를 이해하는 데 필수적인 개념입니다.
- 라이브러리 개발: 내부 구현을 완전히 숨기고 공개 API만 노출할 때
- 마이크로서비스: 경량 컨테이너 이미지가 필요할 때 (
jlink활용) - 보안 민감한 시스템: JDK 내부 API 접근을 엄격히 제한할 때
- 대규모 모놀리식 앱: 큰 코드베이스를 명확한 경계로 분리할 때