안동민 개발노트

본문 시작

모듈 경계와 ServiceLoader

JPMS 디스크립터와 ServiceLoader를 실행하고 exports·requires·uses·provides 규칙을 익힙니다.

Java 플랫폼 모듈 시스템은 Java 9에서 정식 도입돼 Java 25에서 안정적으로 사용됩니다.

모듈은 패키지 묶음, 의존 모듈, 외부 공개 패키지, 서비스 사용·제공을 디스크립터에 기록합니다.

클래스 경로의 “어디서나 public 접근”보다 강한 경계를 만들며 ServiceLoader는 소비자가 제공자 구현 클래스에 직접 의존하지 않게 합니다.


제공자가 없는데 반드시 있다는 가정

ServiceLoader는 제공자가 없으면 빈 반복자를 반환합니다.

아래 예제를 클래스 경로에서 실행하고 별도 제공자를 등록하지 않으면 findFirst()가 빈 결과여서 orElseThrow()가 NoSuchElementException을 던집니다.

lab/MissingServiceProviderFailure.java
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"));
    }
}

원칙은 제공자 수를 애플리케이션 규칙으로 정하는 것입니다.

제공자 하나가 필수라면 시작 시 검증에서 명확한 구성 오류를 내고, 선택 기능이라면 제공자가 없을 때 비활성 경로를 제공합니다.

여러 제공자가 가능하다면 우선순위와 중복 정책을 정의합니다.


모듈 디스크립터의 다섯 규칙

module-info.java는 모듈의 이름과 의존성을 선언합니다.

exports는 다른 모듈이 컴파일·실행 시점에 접근할 패키지를, opens는 리플렉션 접근을 허용할 패키지를 뜻합니다.

무조건 open module로 만들면 캡슐화 이점이 줄어듭니다.

src/ModuleDescriptorContract.java
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());
    }
}

위 코드는 디스크립터 메타데이터 객체를 만들 뿐 실제 모듈을 해석하거나 서비스를 등록하지 않습니다. 다음 선언은 소비자와 제공자의 관계를 보여 주는 일부 예시이며, 배포하려면 API 모듈 선언과 서비스·제공자 클래스도 필요합니다.

module com.example.board.app {
    requires com.example.board.api;
    uses com.example.board.api.BoardExporter;
}

module com.example.board.json {
    requires com.example.board.api;
    provides com.example.board.api.BoardExporter
        with com.example.board.json.JsonBoardExporter;
}
소비자와 제공자는 같은 서비스 API를 읽는다

소비자와 제공자는 같은 서비스 API를 읽는다

소비자와 제공자는 같은 서비스 API를 읽는다app과 json의 requires는 모두 api를 향합니다. app은 BoardExporter를 사용하고 json은 같은 서비스의 구현을 제공합니다.소비자 모듈 apprequires apiuses BoardExporter서비스 모듈 apiBoardExporter공개 서비스 타입제공자 모듈 jsonrequires apiprovides BoardExporter
소비자와 제공자는 같은 서비스 API를 읽는다app과 json의 requires는 모두 api를 향합니다. app은 BoardExporter를 사용하고 json은 같은 서비스의 구현을 제공합니다.소비자 모듈 apprequires apiuses BoardExporter서비스 모듈 apiBoardExporter공개 서비스 타입제공자 모듈 jsonrequires apiprovides BoardExporter

화살표는 두 선언의 requires 관계입니다. 소비자의 uses와 제공자의 provides는 같은 BoardExporter 타입을 가리킵니다. API 패키지 공개와 제공자 해석은 필요 조건이며, 이 선언 예시가 완성된 모듈 배포를 실행한 증거는 아닙니다.


JDK 서비스 제공자 탐색

java.util.spi.ToolProvider는 JDK 도구를 서비스로 노출합니다.

다음 예제는 현재 런타임에서 javac 제공자를 찾아 버전 출력을 요청합니다. javac을 포함하지 않는 런타임 이미지에서는 검색 결과가 없을 수 있습니다.

이 예제는 별도의 등록 파일 없이 JDK 모듈 디스크립터가 제공한 서비스를 사용합니다.

src/JdkToolProviderService.java
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);
    }
}

load는 로더를 만들고 탐색 과정에서 제공자가 지연 생성됩니다. 이 코드의 Provider::get은 이름 필터보다 앞에 있으므로 javac 이외의 제공자도 먼저 생성될 수 있으며, 생성 과정은 실패할 수 있습니다.

요청마다 전체 로더를 탐색하지 말고 애플리케이션 시작 시 검증해 불변 목록을 만들 수 있습니다.


ModuleLayer의 패키지 소유권

ModuleLayer는 해석된 모듈 그래프와 클래스 로더 대응 관계를 표현합니다.

대부분의 애플리케이션은 부트 계층만 사용하지만 플러그인 격리처럼 별도 계층을 만들면 서비스 로딩 범위가 달라집니다.

src/BootModuleLayerInventory.java
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);
    }
}

클래스 경로에서 실행한 current 코드는 이름 없는 모듈에 속합니다.

모듈 마이그레이션에서는 소스 폴더 구조, module-info.java, 의존성의 자동 모듈 이름, 리플렉션 프레임워크 설정을 함께 바꿉니다.


exports·opens 최소화

exports api는 다른 모듈의 컴파일·실행 시점 public 접근을 허용하고, opens model to framework.module은 지정한 모듈의 깊은 리플렉션 접근을 허용합니다.

프레임워크 오류를 없애려고 모든 패키지를 열기보다 필요한 패키지와 대상 모듈만 지정합니다.

  • 내부 구현 패키지는 exports하지 않습니다.
  • API 시그니처에 내부 구현 타입을 노출하지 않습니다.
  • 서비스 인터페이스는 API 모듈에, 제공자 구현은 제공자 모듈에 둡니다.
  • 소비자는 uses, 제공자는 provides ... with를 선언합니다.
  • 모듈 경로 통합을 실제 패키징 산출물로 확인합니다.

클래스 경로·모듈 경로 선택

작은 애플리케이션을 즉시 모듈로 옮기는 것이 항상 우선순위는 아닙니다.

강한 캡슐화, 사용자 정의 실행 시점 이미지, 플러그인 그래프, 의존성 가시성이 가치가 있을 때 단계적으로 적용합니다.

프레임워크와 라이브러리가 JPMS 메타데이터를 제공하는지 먼저 목록화합니다.

META-INF/services와 모듈의 provides를 함께 써야 하나요?

클래스 경로 배포는 META-INF/services, 이름 있는 모듈 배포는 모듈 디스크립터의 provides를 사용합니다.

다중 릴리스나 클래스 경로·모듈 경로 겸용 JAR은 둘 다 포함할 수 있지만 중복 제공자 노출과 패키징을 확인해야 합니다.

소비자 코드는 두 방식 모두 ServiceLoader API를 사용합니다.


연습 문제

시스템 모듈 검색기로 java.compiler 디스크립터를 읽고 exports와 uses의 수를 출력하세요.

모듈이 없을 때는 명확한 예외를 내야 합니다.

해설 보기
exercise/SystemModuleDescriptorSolution.java
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());
    }
}

종료 기준은 name이 java.compiler이고 자동 모듈 여부가 false인 것입니다.

이 풀이의 ModuleFinder.ofSystem()은 JDK 시스템 모듈을 찾습니다. 애플리케이션 JAR을 검사할 때는 해당 경로를 받는 ModuleFinder.of(...) 등으로 검색 대상을 바꿔야 합니다.