모듈 시스템과 ServiceLoader
module-info.java로 모듈이 공개할 패키지와 의존할 모듈을 선언하고, ServiceLoader로 구현 클래스를 직접 참조하지 않고 서비스 구현을 찾아 쓰는 방법을 배웁니다.
게시판 프로그램에 게시글을 JSON으로 내보내는 기능을 붙인다고 해 봅시다. 가장 간단한 방법은 앱 코드에서 new JsonBoardExporter()를 직접 호출하는 것입니다. 그런데 이렇게 하면 앱이 JSON 구현 클래스에 묶여서, CSV 형식을 추가하거나 구현을 바꿀 때마다 앱 코드도 함께 고쳐야 합니다. 클래스 경로(class path)에도 비슷한 문제가 있습니다. 클래스 경로에 올린 JAR에서는 public 클래스라면 누구나 가져다 쓸 수 있으므로, 라이브러리가 내부용으로 만든 클래스를 다른 코드가 쓰기 시작해도 막을 방법이 없습니다.
Java 9에 도입된 모듈 시스템(Java Platform Module System, JPMS)은 이 가운데 두 번째 문제를 풉니다. 모듈은 어떤 패키지를 공개하고 어떤 모듈에 의존하는지를 module-info.java에 선언하고, 공개하지 않은 패키지의 클래스는 public이어도 다른 모듈에서 쓸 수 없습니다. 첫 번째 문제는 java.util.ServiceLoader가 맡습니다. 앱은 인터페이스만 알고 있고, 그 인터페이스의 구현은 실행할 때 찾아서 받습니다. 이 글에서는 두 도구를 함께 써서 게시판의 앱, API, JSON 구현을 세 모듈로 나눠 봅니다.
module-info.java에 적는 다섯 가지 선언
모듈(module)은 서로 관련된 패키지들을 하나로 묶고 이름을 붙인 단위입니다. 모듈의 소스 폴더 맨 위에 module-info.java를 두고 그 안에 모듈 이름과 아래 선언들을 적는데, 이 파일을 모듈 선언(module declaration)이라고 합니다. 컴파일하면 module-info.class가 만들어지고, JVM은 여기에 담긴 정보인 모듈 디스크립터(module descriptor)를 읽어 모듈 사이의 관계를 확인합니다.
| 선언 | 뜻 | 예 |
|---|---|---|
requires | 이 모듈이 읽는(의존하는) 다른 모듈 | requires com.example.board.api; |
exports | 다른 모듈이 컴파일할 때와 실행할 때 쓸 수 있게 공개하는 패키지 | exports com.example.board.api; |
opens | 실행 중에 리플렉션으로 private 멤버까지 다룰 수 있게 여는 패키지 | opens com.example.board.model; |
uses | 이 모듈이 ServiceLoader로 찾아 쓰는 서비스 타입 | uses com.example.board.api.BoardExporter; |
provides … with | 이 모듈이 제공하는 서비스 구현 클래스 | provides com.example.board.api.BoardExporter with com.example.board.json.JsonBoardExporter; |
requires와 exports는 짝을 이룹니다. 모듈 A가 모듈 B의 클래스를 쓰려면 A는 requires B로 B를 읽겠다고 선언하고, B는 그 클래스가 든 패키지를 exports해야 합니다. 둘 중 하나라도 빠지면 컴파일러가 거부하는데, 두 경우의 오류 메시지는 연습 문제 3에서 볼 수 있습니다. 다만 String이나 List가 들어 있는 java.base 모듈은 모든 모듈이 자동으로 읽으므로 requires java.base;는 적지 않아도 됩니다.
exports와 opens는 접근을 허용하는 시점과 깊이가 다릅니다. exports한 패키지의 public 타입은 다른 모듈의 코드가 평소처럼 import해서 쓸 수 있습니다. 반면 opens는 컴파일 시점의 접근은 허용하지 않고, 실행 중에 리플렉션으로 private 필드와 메서드까지 다루는 깊은 리플렉션(deep reflection)을 허용합니다. 객체의 필드를 리플렉션으로 읽고 쓰는 JSON 라이브러리나 ORM 프레임워크가 이 권한을 요구합니다. opens 패키지 to 모듈처럼 열어 줄 대상 모듈을 지정할 수도 있고, 모듈 전체를 open module로 선언해 모든 패키지를 열 수도 있습니다. 하지만 많이 열수록 모듈로 얻은 캡슐화는 그만큼 약해집니다.
uses와 provides는 서비스를 위한 선언입니다. 서비스(service)는 구현을 갈아 끼울 수 있게 정해 둔 인터페이스나 추상 클래스이고, 그 구현을 서비스 제공자(service provider)라고 합니다. 서비스를 쓰는 쪽은 uses로 어떤 서비스를 찾을지 밝히고, 구현을 가진 쪽은 provides로 어떤 클래스가 그 서비스를 구현하는지 밝힙니다. 이 두 선언 덕분에 쓰는 쪽은 구현 클래스의 이름을 전혀 몰라도 됩니다.
더 깊이: 모듈 디스크립터를 코드로 만들어 보기
module-info.class에 담기는 정보는 java.lang.module.ModuleDescriptor 객체로 다룰 수 있습니다. 보통은 JVM이 클래스 파일을 읽어 만들지만, 빌더(builder)로 직접 만들어 보면 선언 하나하나가 어떤 데이터가 되는지 확인할 수 있습니다.
import java.lang.module.ModuleDescriptor;
import java.util.spi.ToolProvider;
public final class ModuleDescriptorContract {
public static void main(String[] args) {
ModuleDescriptor descriptor =
ModuleDescriptor.newModule("com.example.board.app")
.requires("java.base")
.requires("java.sql")
.exports("com.example.board.api")
.uses(ToolProvider.class.getName())
.build();
System.out.println("name=" + descriptor.name());
System.out.println("requires=" + descriptor.requires().size());
System.out.println("exports=" + descriptor.exports());
System.out.println("uses=" + descriptor.uses());
}
}requires(), exports(), uses()는 모두 집합을 돌려줍니다. requires 줄은 그 집합의 크기만 찍고, 나머지 두 줄은 집합을 그대로 찍습니다.
name=com.example.board.app
requires=2
exports=[com.example.board.api]
uses=[java.util.spi.ToolProvider]requires가 2인 것은 java.base와 java.sql을 둘 다 적었기 때문입니다. 그런데 requires("java.base") 줄을 지워도 결과는 2로 같습니다. build()가 java.base 의존을 자동으로 추가하고, 이렇게 추가된 의존에는 직접 적지 않았다는 뜻의 MANDATED 표시를 붙이기 때문입니다. 앞에서 requires java.base;를 적지 않아도 된다고 한 규칙이 디스크립터에서는 이런 모습으로 나타납니다. 이 코드는 메타데이터 객체를 만들 뿐이라서, 모듈을 실제로 불러들이거나 서비스를 등록하지는 않습니다.
서비스 하나를 세 모듈로 나누기
이제 게시판 예를 모듈로 나눠 봅시다. com.example.board.api 모듈에는 서비스 인터페이스 BoardExporter만 두고 그 패키지를 공개합니다. com.example.board.json 모듈은 이 인터페이스의 JSON 구현을 제공하고, com.example.board.app 모듈은 서비스를 찾아 씁니다. 세 모듈의 관계를 그림으로 보면 다음과 같습니다.
구현 대신 공통 서비스 타입에 의존
소비자와 제공자는 같은 서비스 API를 읽는다
app과 json은 모두 api를 requires합니다. app의 uses와 json의 provides는 같은 BoardExporter 타입을 가리킵니다.
소비자와 제공자는 같은 서비스 API를 읽는다
화살표는 두 선언의 requires 관계입니다. 소비자의 uses와 제공자의 provides는 같은 BoardExporter 타입을 가리킵니다. API 패키지 공개와 제공자 해석은 필요 조건이며, 이 선언 예시가 완성된 모듈 배포를 실행한 증거는 아닙니다.
그림의 requires 화살표는 두 개뿐이고, 둘 다 API 모듈을 향합니다. 앱과 JSON 모듈 사이에는 아무 선도 없습니다. 앱의 uses와 JSON 모듈의 provides가 같은 BoardExporter 타입을 가리킬 뿐, 앱은 JSON 구현에 의존하지 않습니다. 그래서 나중에 CSV 구현을 추가하더라도 앱 모듈은 한 줄도 바뀌지 않습니다.
javac로 여러 모듈을 한 번에 컴파일할 때는 모듈 이름과 같은 이름의 폴더 아래에 각 모듈의 소스를 둡니다. 파일 배치는 다음과 같습니다.
src/
├── com.example.board.api/
│ ├── module-info.java
│ └── com/example/board/api/BoardExporter.java
├── com.example.board.json/
│ ├── module-info.java
│ └── com/example/board/json/JsonBoardExporter.java
└── com.example.board.app/
├── module-info.java
└── com/example/board/app/BoardApp.javaAPI 모듈은 인터페이스 하나와, 그 패키지를 공개하는 선언 하나로 끝납니다. 서비스 인터페이스는 앱과 제공자가 모두 알아야 하는 유일한 타입이므로, 이 패키지만큼은 반드시 공개해야 합니다.
module com.example.board.api {
exports com.example.board.api;
}package com.example.board.api;
public interface BoardExporter {
String export(String title);
}JSON 모듈은 API 모듈을 읽고, provides로 구현 클래스를 등록합니다. 구현 클래스가 든 com.example.board.json 패키지를 exports하지 않는다는 점을 눈여겨보세요.
module com.example.board.json {
requires com.example.board.api;
provides com.example.board.api.BoardExporter
with com.example.board.json.JsonBoardExporter;
}package com.example.board.json;
import com.example.board.api.BoardExporter;
public class JsonBoardExporter implements BoardExporter {
@Override
public String export(String title) {
return "{\"title\":\"" + title + "\"}";
}
}앱 모듈은 API 모듈을 읽고, uses로 BoardExporter를 찾아 쓰겠다고 선언합니다. 코드에서는 ServiceLoader.load가 그 서비스의 제공자를 찾는 로더를 만들고, findFirst()가 처음 찾은 제공자를 Optional로 돌려줍니다. 제공자가 하나도 없으면 빈 Optional이 나오므로, 그때 무엇이 빠졌는지 바로 알 수 있는 예외를 던지게 했습니다.
module com.example.board.app {
requires com.example.board.api;
uses com.example.board.api.BoardExporter;
}package com.example.board.app;
import com.example.board.api.BoardExporter;
import java.util.ServiceLoader;
public class BoardApp {
public static void main(String[] args) {
BoardExporter exporter = ServiceLoader.load(BoardExporter.class)
.findFirst()
.orElseThrow(() -> new IllegalStateException("BoardExporter 제공자가 없습니다"));
System.out.println(exporter.getClass().getName());
System.out.println(exporter.export("modules"));
}
}src가 있는 폴더에서 다음 두 명령으로 컴파일하고 실행합니다. 첫 명령은 모듈들을 한 번에 컴파일해 out 아래에 모듈마다 폴더를 만들고, 둘째 명령은 그 out을 모듈 경로로 주어 앱을 실행합니다.
javac -d out --module-source-path src --module com.example.board.app,com.example.board.json
java --module-path out --module com.example.board.app/com.example.board.app.BoardApp앱은 ServiceLoader가 찾은 첫 제공자의 클래스 이름과, 그 제공자로 export를 호출한 결과를 찍습니다. 실행 결과는 다음과 같습니다.
com.example.board.json.JsonBoardExporter
{"title":"modules"}--module-source-path src는 src 아래의 폴더 이름을 모듈 이름으로 보고 소스를 찾게 하고, --module은 컴파일할 모듈을 지정합니다. 여기서 JSON 모듈도 꼭 적어야 합니다. javac는 지정한 모듈과 그 모듈이 requires하는 모듈만 컴파일하는데, 앱은 JSON 모듈을 requires하지 않기 때문입니다. 실제로 앱 모듈만 지정하면 out에는 API와 앱 두 모듈만 생깁니다. 실행할 때는 --module-path로 컴파일된 모듈이 있는 곳을 알려 주고, --module 모듈/메인클래스 형식으로 시작할 모듈과 클래스를 지정합니다.
출력의 첫 줄은 ServiceLoader가 찾아 준 객체의 클래스 이름입니다. 앱 코드 어디에도 JsonBoardExporter라는 이름이 없는데도 실행 중에 그 구현을 받아 썼다는 것을 보여 줍니다. 둘째 줄은 그 객체의 export가 돌려준 문자열입니다.
JSON 모듈이 구현 패키지를 공개하지 않았는데도 ServiceLoader가 객체를 만들 수 있었던 것은, provides 선언이 모듈 시스템에 그 클래스를 직접 알려 주기 때문입니다. ServiceLoader 공식 문서는 오히려 제공자 클래스가 든 패키지를 exports하지 말라고 강하게 권합니다. 공개하지 않아야 다른 모듈이 구현 클래스에 직접 의존할 길이 막히고, 모든 사용자가 서비스 인터페이스를 거치게 되기 때문입니다. 대신 제공자 클래스는 public이어야 하고 내부 클래스(inner class)여서는 안 됩니다. 또 인수 없는 public 생성자가 있거나, 서비스 타입의 객체를 돌려주는 인수 없는 public static 메서드 provider()가 있어야 합니다.
더 깊이: 아무도 requires하지 않은 모듈이 실행에 포함되는 이유
JVM은 프로그램을 시작하기 전에 시작 모듈에서 출발해 requires를 따라가며 필요한 모듈을 모두 찾아 모듈 그래프를 만듭니다. 이 과정을 해석(resolution)이라고 합니다. requires만 따라간다면 JSON 모듈은 그래프에 들어오지 못합니다. 하지만 해석 단계에서는 그래프 안의 모듈이 uses로 선언한 서비스를 모듈 경로의 어느 모듈이 provides하는지도 찾아서 함께 넣는데, 이를 서비스 바인딩(service binding)이라고 합니다.
--show-module-resolution 옵션을 붙여 실행하면 이 과정을 볼 수 있습니다. JDK 모듈에 관한 줄도 많이 나오므로, board가 들어간 줄만 추리고 경로를 줄여서 보이면 다음과 같습니다.
root com.example.board.app file:///…/out/com.example.board.app/
com.example.board.app requires com.example.board.api file:///…/out/com.example.board.api/
com.example.board.app binds com.example.board.json file:///…/out/com.example.board.json/
com.example.board.json requires com.example.board.api file:///…/out/com.example.board.api/셋째 줄의 binds가 서비스 바인딩입니다. 앱 모듈의 uses 때문에 JSON 모듈이 그래프에 들어왔고, 넷째 줄처럼 JSON 모듈이 필요로 하는 모듈도 이어서 해석됩니다. 모듈 시스템이 uses 선언을 요구하는 이유도 여기에 있습니다. 어떤 모듈이 어떤 서비스를 쓰는지 선언에 드러나 있어야 해석 단계에서 그 제공자를 미리 찾아 둘 수 있습니다.
제공자를 찾지 못했을 때
ServiceLoader는 제공자가 하나도 없어도 오류를 내지 않습니다. 아무것도 찾지 못할 뿐이고, findFirst()는 빈 Optional을 돌려줍니다. 그래서 '제공자는 당연히 있다'고 가정한 코드는 엉뚱한 곳에서 실패합니다. 다음 예제는 모듈 없이 클래스 경로에서 실행하는 프로그램으로, 서비스 제공자를 하나도 등록하지 않았습니다.
import java.util.ServiceLoader;
public final class MissingServiceProviderFailure {
private interface BoardExporter {
String export(String title);
}
public static void main(String[] args) {
BoardExporter exporter = ServiceLoader.load(BoardExporter.class).findFirst().orElseThrow();
System.out.println(exporter.export("modules"));
}
}orElseThrow()에 아무 인수도 넘기지 않았으므로, 예외의 종류와 메시지는 Optional이 정한 기본값을 따릅니다. 실행하면 다음 줄로 시작하는 예외가 나고, 그 아래에 호출 위치를 보여 주는 스택 트레이스가 이어집니다.
Exception in thread "main" java.util.NoSuchElementException: No value present인수 없는 orElseThrow()는 Optional이 비어 있으면 NoSuchElementException을 던집니다. 메시지가 'No value present'뿐이라서, 이 출력만 보고는 서비스 제공자가 빠졌다는 사실을 알아내기 어렵습니다.
앞의 모듈 예제는 같은 상황에서 무엇이 문제인지 메시지로 알려 줍니다. 모듈 경로에 API와 앱 모듈만 주고 JSON 모듈은 빼고 실행해 보세요. 경로를 구분하는 문자는 Linux와 macOS에서 :, Windows에서 ;입니다.
java --module-path out/com.example.board.api:out/com.example.board.app --module com.example.board.app/com.example.board.app.BoardApp앱 모듈의 BoardApp은 orElseThrow에 직접 만든 예외를 넘기므로, 제공자가 없으면 그 예외가 던져집니다. 이번에는 예외의 첫 줄이 원인을 그대로 말해 줍니다.
Exception in thread "main" java.lang.IllegalStateException: BoardExporter 제공자가 없습니다제공자가 몇 개여야 하는지는 ServiceLoader가 아니라 애플리케이션이 정할 일입니다. 제공자가 꼭 하나 있어야 한다면, 이 예제처럼 프로그램이 시작할 때 찾아보고 없으면 구성 오류라고 분명히 밝히며 멈추는 편이 낫습니다. 요청을 처리하는 도중에야 알게 되면 원인을 찾기가 훨씬 어렵습니다. 있으면 쓰고 없어도 되는 부가 기능이라면 findFirst().orElse(기본구현)처럼 대신 쓸 동작을 정해 둡니다.
제공자가 여러 개일 수 있다면 어느 것을 쓸지도 정해야 합니다. 한 모듈이 여러 제공자를 선언하면 선언한 순서대로 나오지만, 모듈 경로에 함께 놓인 서로 다른 모듈의 제공자가 어떤 순서로 나올지는 정해져 있지 않습니다. 그러니 findFirst()가 늘 같은 구현을 고르리라고 기대하면 안 됩니다. 프로그램이 시작할 때 제공자 목록을 한 번 만들어 우선순위와 중복 처리 규칙에 따라 고르고, 그 결과를 변경할 수 없는 목록으로 보관하면 요청마다 탐색을 되풀이할 필요도 없습니다.
uses 선언을 빠뜨렸을 때
모듈 안의 코드가 ServiceLoader로 서비스를 찾으려면 그 모듈의 선언에 uses가 있어야 합니다. 앞의 앱 모듈에서 uses 줄을 지우고 다시 컴파일하면 컴파일은 아무 문제 없이 끝납니다. 하지만 실행하면 ServiceLoader.load를 호출하는 순간 다음 줄로 시작하는 오류가 납니다.
Exception in thread "main" java.util.ServiceConfigurationError: com.example.board.api.BoardExporter: module com.example.board.app does not declare `uses`제공자가 없을 때와 달리 이번에는 ServiceConfigurationError가 났습니다. 이름 그대로 서비스 구성이 잘못됐다는 오류이며, 제공자 클래스를 불러오거나 객체를 만들다가 실패했을 때도 같은 오류가 납니다. 빈 결과는 '찾아봤는데 없다'는 정상적인 답이고, 이 오류는 '찾는 과정 자체가 잘못됐다'는 뜻이라서 둘은 따로 다뤄야 합니다.
JDK가 등록해 둔 서비스 찾기
서비스는 직접 만든 모듈에만 있는 것이 아닙니다. JDK도 여러 기능을 서비스로 제공하는데, java.util.spi.ToolProvider가 대표적입니다. javac, jar, javadoc 같은 명령줄 도구가 이 인터페이스의 제공자로 등록되어 있어서, 프로그램 안에서 새 프로세스를 띄우지 않고 도구를 실행할 수 있습니다. 예를 들어 javac가 든 jdk.compiler 모듈의 선언에는 ToolProvider를 구현한 클래스를 등록하는 provides가 들어 있으므로, 우리 쪽에서 따로 등록할 것이 없습니다. 다음 예제는 현재 런타임에서 이름이 javac인 제공자를 찾아 -version 옵션으로 실행합니다.
import java.io.PrintWriter;
import java.util.ServiceLoader;
import java.util.spi.ToolProvider;
public final class JdkToolProviderService {
public static void main(String[] args) {
ToolProvider javac =
ServiceLoader.load(ToolProvider.class).stream()
.map(ServiceLoader.Provider::get)
.filter(tool -> tool.name().equals("javac"))
.findFirst()
.orElseThrow(() -> new IllegalStateException("javac provider missing"));
int exit =
javac.run(
new PrintWriter(System.out, true),
new PrintWriter(System.err, true),
"-version");
System.out.println("tool=" + javac.name() + ", exit=" + exit);
}
}JDK 21.0.12에서 실행한 결과입니다. 첫 줄의 버전은 실행한 JDK에 따라 달라집니다.
javac 21.0.12.1
tool=javac, exit=0첫 줄은 javac가 -version에 답해 출력한 것이고, 둘째 줄은 우리 코드가 출력한 것입니다. run이 돌려준 종료 코드 0은 도구가 정상적으로 끝났다는 뜻입니다.
이 코드에서 ServiceLoader.load를 호출하는 순간 제공자가 전부 만들어지지는 않습니다. 제공자는 필요할 때 하나씩 찾아서 만들어지는데, 이를 지연 생성이라고 합니다. stream()이 내놓는 원소도 아직 제공자 객체가 아니라 ServiceLoader.Provider이고, 그 get()을 호출해야 실제 객체가 생깁니다. 그런데 이 코드는 이름을 확인하려고 get()부터 호출하므로, javac를 찾기 전에 나온 다른 도구의 제공자 객체도 만들어집니다. 도구 이름은 객체를 만들어야 알 수 있는 값이라 이 경우에는 어쩔 수 없습니다. 하지만 클래스 정보만으로 고를 수 있다면 Provider.type()으로 먼저 거른 뒤 get()을 호출하는 편이 좋습니다. 연습 문제 2에서 이 방법을 씁니다.
실패하는 방식도 두 가지로 나뉩니다. 탐색을 끝까지 했는데 javac가 없으면 orElseThrow에 넘긴 IllegalStateException이 납니다. jlink로 만든 실행 전용 런타임처럼 jdk.compiler 모듈을 넣지 않은 이미지에서 이런 일이 생깁니다. 반면 탐색 도중 어떤 제공자를 불러오거나 만들다가 실패하면, 그 자리에서 ServiceConfigurationError가 나며 탐색이 끝까지 가지 못합니다. 그러니 마지막의 빈 결과 처리만으로 모든 구성 문제를 잡을 수 있다고 생각하면 안 됩니다.
참고로 같은 일을 하는 정적 메서드 ToolProvider.findFirst("javac")도 있습니다. 시스템 클래스 로더로 ServiceLoader를 써서 이름이 같은 첫 제공자를 Optional로 돌려주므로, 실무에서는 이쪽이 간단합니다.
실행 중인 모듈 살펴보기
JVM이 시작할 때 해석한 모듈 그래프는 ModuleLayer 객체로 표현됩니다. 모듈 계층(module layer)은 해석된 모듈들과, 각 모듈의 클래스를 어느 클래스 로더가 불러오는지를 함께 담습니다. JVM이 시작하며 만드는 계층을 부트 계층(boot layer)이라고 하며 ModuleLayer.boot()로 얻습니다. 대부분의 애플리케이션은 부트 계층 하나만 쓰지만, 플러그인을 서로 격리해 불러오려고 계층을 따로 만들면 ServiceLoader가 제공자를 찾는 범위도 계층에 따라 달라집니다. 다음 예제는 부트 계층에서 java.base 모듈을 찾아 몇 가지를 확인하고, 계층에 든 모듈 이름을 정렬해 앞의 다섯 개를 출력합니다.
import java.util.Comparator;
public final class BootModuleLayerInventory {
public static void main(String[] args) {
ModuleLayer boot = ModuleLayer.boot();
Module javaBase = boot.findModule("java.base").orElseThrow();
Module current = BootModuleLayerInventory.class.getModule();
System.out.println("java.base named=" + javaBase.isNamed());
System.out.println("current named=" + current.isNamed());
System.out.println("java.base exports java.util=" + javaBase.isExported("java.util"));
boot.modules().stream()
.map(Module::getName)
.sorted(Comparator.naturalOrder())
.limit(5)
.forEach(System.out::println);
}
}모듈 이름은 sorted로 정렬한 뒤 limit(5)로 앞의 다섯 개만 남기므로, 출력의 뒤쪽 다섯 줄은 알파벳 순서로 나옵니다. JDK 21.0.12에서 클래스 경로로 실행한 결과입니다.
java.base named=true
current named=false
java.base exports java.util=true
java.base
java.compiler
java.datatransfer
java.desktop
java.instrumentjava.base는 이름 있는 모듈(named module)이므로 첫 줄이 true입니다. 둘째 줄이 false인 것은 이 클래스를 클래스 경로에서 불러왔기 때문입니다. 클래스 경로의 클래스는 모두 이름 없는 모듈(unnamed module)이라는 특별한 모듈에 속합니다. 이름 없는 모듈은 다른 모든 모듈을 읽을 수 있어서, 모듈 선언이 없는 기존 코드도 그대로 돌아갑니다. 셋째 줄은 java.base가 java.util 패키지를 공개한다는 뜻이고, 나머지 다섯 줄은 부트 계층에 든 모듈의 이름입니다. 어떤 모듈이 들어 있는지는 JDK 배포판과 실행 방법에 따라 달라지므로 이 부분은 결과가 다를 수 있습니다.
기존 프로젝트를 모듈로 옮길 때는 이 차이 때문에 여러 곳을 함께 바꿔야 합니다. 소스 폴더 구조를 모듈 단위로 바꾸고 module-info.java를 만들어야 하고, 모듈 선언이 없는 라이브러리 JAR이 모듈 경로에서 어떤 이름의 자동 모듈(automatic module)로 올라오는지도 확인해야 합니다. 리플렉션을 쓰는 프레임워크가 있다면 거기에 필요한 opens도 정해야 합니다.
exports와 opens는 꼭 필요한 만큼만
모듈의 장점은 공개한 것만 쓸 수 있다는 데서 나옵니다. 프레임워크가 리플렉션 접근 오류를 낸다고 모든 패키지를 열어 버리면 그 장점이 사라지므로, 필요한 패키지를 필요한 대상 모듈에게만 엽니다. 예를 들어 opens com.example.board.model to com.fasterxml.jackson.databind;는 그 패키지를 Jackson 2의 데이터 바인딩 모듈에게만 열어 줍니다. Jackson 3에서는 이 모듈의 이름이 tools.jackson.databind로 바뀌었습니다. 모듈을 나눌 때는 다음 원칙을 기준으로 삼으면 됩니다.
- 내부 구현 패키지는
exports하지 않습니다. - 공개 API의 메서드 시그니처에 내부 구현 타입이 드러나지 않게 합니다. 공개 메서드가 내부 타입을 반환하면, 그 메서드를 쓰는 쪽이 결국 내부 패키지까지 공개해 달라고 요구하게 됩니다.
- 서비스 인터페이스는 API 모듈에, 제공자 구현은 제공자 모듈에 두고, 제공자 패키지는 공개하지 않습니다.
- 서비스를 쓰는 모듈은
uses를, 구현을 가진 모듈은provides … with를 선언합니다. - 모듈 구성이 맞는지는 실제로 배포할 JAR을 모듈 경로에 올려 실행해 확인합니다. IDE에서 돌아간다고 해서 배포 산출물에서도 같은 구성이 되는 것은 아닙니다.
exports가 정한 공개 패키지 목록은 다른 기능에서도 쓰입니다. Java 25에서 정식 기능이 된 모듈 import 선언(import module java.sql;)은 모듈이 공개한 패키지를 한 번에 가져오는 선언이라서, 어떤 패키지가 따라오는지는 결국 각 모듈의 exports가 정합니다. 이 선언의 자세한 규칙은 패키지와 import에서 다룹니다.
클래스 경로에 둘까, 모듈로 옮길까
작은 애플리케이션을 당장 모듈로 바꿔야 하는 것은 아닙니다. 클래스 경로는 지금도 완전히 지원되고, 앞에서 본 것처럼 클래스 경로의 코드는 이름 없는 모듈에서 그대로 돌아갑니다. 모듈은 강한 캡슐화가 필요할 때, jlink로 필요한 모듈만 담은 런타임 이미지를 만들고 싶을 때, 플러그인 구조를 모듈 그래프로 관리하고 싶을 때 단계적으로 도입하면 됩니다. 옮기기로 했다면 먼저 쓰고 있는 프레임워크와 라이브러리가 모듈 선언을 제공하는지, 아니면 자동 모듈의 이름을 정해 두는 JAR 매니페스트 속성 Automatic-Module-Name을 제공하는지 목록을 만들어 확인합니다. 이 속성이 없으면 자동 모듈의 이름은 JAR 파일 이름에서 버전 부분을 뺀 나머지로 만들어지므로, 파일 이름이 바뀌면 모듈 이름도 달라질 수 있습니다.
META-INF/services와 모듈의 provides를 함께 써야 하나요?
배포 방식에 따라 다릅니다. 클래스 경로에 올리는 JAR은 META-INF/services 폴더에 서비스의 전체 이름으로 된 파일을 두고, 그 안에 구현 클래스 이름을 한 줄에 하나씩 적어 제공자를 등록합니다. 모듈 경로에 올리는 이름 있는 모듈은 모듈 선언의 provides로 등록합니다.
클래스 경로와 모듈 경로 어느 쪽에서도 쓰이도록 만든 JAR이라면 두 가지를 모두 넣을 수 있습니다. 이때는 두 목록이 같은 제공자를 가리키는지 확인해야 합니다. 어느 쪽이든 서비스를 쓰는 코드는 똑같이 ServiceLoader를 사용합니다.
정리
- 모듈은
module-info.java에requires(읽을 모듈),exports(공개할 패키지),opens(리플렉션에 열 패키지),uses(찾아 쓸 서비스),provides … with(제공할 구현)를 선언합니다. 공개하지 않은 패키지의 클래스는public이어도 다른 모듈에서 쓸 수 없습니다. ServiceLoader를 쓰면 앱은 서비스 인터페이스만 알고, 구현은 실행할 때 받습니다. 제공자 패키지는 공개하지 않아도 되며, 공개하지 않는 편이 권장됩니다.- 해석 단계의 서비스 바인딩 덕분에, 아무도
requires하지 않은 제공자 모듈도 모듈 경로에 있으면 실행에 포함됩니다. - 제공자가 없으면
ServiceLoader는 오류 없이 빈 결과를 돌려줍니다. 제공자가 필수인지, 선택인지, 여러 개일 때 무엇을 고를지는 애플리케이션이 정하고, 필수라면 시작할 때 원인이 드러나는 메시지와 함께 실패하게 합니다. uses선언이 없거나 제공자를 불러오다 실패하면ServiceConfigurationError가 납니다.
연습 문제
-
ModuleFinder.ofSystem()으로 JDK의 시스템 모듈 가운데java.compiler의 디스크립터를 찾아, 모듈 이름, 공개 패키지 수,uses로 선언한 서비스 수, 자동 모듈 여부를 한 줄씩 출력하세요. 모듈을 찾지 못하면 무엇이 없는지 알 수 있는 예외를 던져야 합니다. -
게시글을
title,제목형식으로 내보내는 두 번째 제공자 모듈com.example.board.csv를 추가하고,BoardApp이 찾은 제공자를 모두 출력하도록 바꾸세요. 제공자가 나오는 순서에 기대지 않도록 클래스 이름순으로 정렬하되, 정렬하려고 제공자 객체를 미리 만들지는 마세요. -
BoardApp에서ServiceLoader대신new JsonBoardExporter()를 직접 쓰면 컴파일이 실패합니다. (가) 앱 모듈 선언을 그대로 두었을 때와 (나) 앱 모듈에requires com.example.board.json;을 추가했을 때, 각각 왜 실패하는지 설명하세요.
해설 보기
1번. ModuleFinder.ofSystem()은 실행 중인 JDK 런타임 이미지에 든 시스템 모듈을 찾는 검색기입니다. find는 Optional을 돌려주므로 비어 있을 때 던질 예외를 직접 정하고, 찾은 결과의 descriptor()에서 필요한 값을 꺼냅니다.
import java.lang.module.ModuleDescriptor;
import java.lang.module.ModuleFinder;
public final class SystemModuleDescriptorSolution {
public static void main(String[] args) {
ModuleDescriptor descriptor =
ModuleFinder.ofSystem()
.find("java.compiler")
.orElseThrow(() -> new IllegalStateException("java.compiler missing"))
.descriptor();
System.out.println("name=" + descriptor.name());
System.out.println("exports=" + descriptor.exports().size());
System.out.println("uses=" + descriptor.uses().size());
System.out.println("automatic=" + descriptor.isAutomatic());
}
}JDK 21.0.12에서 실행한 결과입니다.
name=java.compiler
exports=6
uses=2
automatic=false공개 패키지 여섯 개는 javax.annotation.processing, javax.lang.model과 그 하위 패키지 세 개(element, type, util), 그리고 javax.tools입니다. JDK 25 API 문서에도 같은 여섯 패키지가 나옵니다. uses 두 개는 javax.tools.JavaCompiler와 javax.tools.DocumentationTool입니다. JDK가 직접 정의한 모듈이므로 자동 모듈이 아니어서 false입니다. 이 값들은 설치된 JDK를 설명할 뿐이라는 점도 기억해 두세요. 애플리케이션 JAR을 검사하려면 ModuleFinder.of(경로)처럼 검색할 경로를 받는 검색기를 써야 합니다.
2번. CSV 모듈은 JSON 모듈과 같은 모양입니다. API 모듈을 읽고, provides로 구현을 등록하고, 구현 패키지는 공개하지 않습니다.
module com.example.board.csv {
requires com.example.board.api;
provides com.example.board.api.BoardExporter
with com.example.board.csv.CsvBoardExporter;
}package com.example.board.csv;
import com.example.board.api.BoardExporter;
public class CsvBoardExporter implements BoardExporter {
@Override
public String export(String title) {
return "title," + title;
}
}앱에서는 stream()이 내놓는 ServiceLoader.Provider를 type()의 클래스 이름으로 먼저 정렬하고, 그다음에 get()으로 객체를 만듭니다. type()은 제공자 객체를 만들지 않고도 클래스를 알려 주므로, 정렬하는 동안에는 객체가 생기지 않습니다. 앱 모듈의 module-info.java는 바꿀 필요가 없습니다.
package com.example.board.app;
import com.example.board.api.BoardExporter;
import java.util.Comparator;
import java.util.List;
import java.util.ServiceLoader;
public class BoardApp {
public static void main(String[] args) {
List<BoardExporter> exporters = ServiceLoader.load(BoardExporter.class).stream()
.sorted(Comparator.comparing(provider -> provider.type().getName()))
.map(ServiceLoader.Provider::get)
.toList();
if (exporters.isEmpty()) {
throw new IllegalStateException("BoardExporter 제공자가 없습니다");
}
for (BoardExporter exporter : exporters) {
System.out.println(exporter.getClass().getSimpleName() + " -> " + exporter.export("modules"));
}
}
}컴파일할 때는 새 모듈도 --module에 적어야 합니다.
javac -d out --module-source-path src --module com.example.board.app,com.example.board.json,com.example.board.csv
java --module-path out --module com.example.board.app/com.example.board.app.BoardAppCsvBoardExporter -> title,modules
JsonBoardExporter -> {"title":"modules"}com.example.board.csv.CsvBoardExporter가 com.example.board.json.JsonBoardExporter보다 이름순으로 앞서므로 CSV가 먼저 나옵니다. 정렬하지 않았다면 두 모듈의 제공자가 어떤 순서로 나올지 정해져 있지 않습니다. 이번에도 앱 모듈의 선언과 requires는 그대로이고, 모듈 경로에 CSV 모듈이 놓였다는 사실만으로 새 구현이 쓰였습니다.
3번. (가)에서는 앱 모듈이 JSON 모듈을 읽지 않으므로, import com.example.board.json.JsonBoardExporter; 줄에서 다음 오류가 납니다. 아래 오류 메시지에서는 앞에 붙는 파일 경로와 줄 번호, 소스 줄 표시를 생략했습니다.
error: package com.example.board.json is not visible
(package com.example.board.json is declared in module com.example.board.json, but module com.example.board.app does not read it)(나)처럼 requires를 추가하면 읽기는 해결되지만, 이번에는 JSON 모듈이 그 패키지를 공개하지 않았다는 이유로 같은 줄이 거부됩니다.
error: package com.example.board.json is not visible
(package com.example.board.json is declared in module com.example.board.json, which does not export it)다른 모듈의 클래스를 쓰려면 쓰는 쪽의 requires와 공개하는 쪽의 exports가 모두 필요하기 때문입니다. 이 설계에서는 JSON 모듈이 구현 패키지를 일부러 공개하지 않았으므로, 앱이 구현에 직접 의존할 길이 처음부터 막혀 있습니다. 그래서 앱은 ServiceLoader와 서비스 인터페이스를 거쳐서만 구현을 받게 됩니다.
다음 글
가상 스레드와 ScopedValue에서는 작업마다 가벼운 스레드를 하나씩 쓰는 가상 스레드와, 요청 식별자 같은 문맥 값을 메서드 매개변수로 넘기지 않고 호출 범위 안에서 읽게 해 주는 ScopedValue를 다룹니다.