본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
36장 : Java 25 안정 기능

모듈 경계와 ServiceLoader

Java 25 안정 기능인 JPMS 디스크립터·ModuleLayer·ServiceLoader를 실행하고 `exports`·requires·uses·provides 규칙과 제공자 부재를 다룹니다.

Java 플랫폼 모듈 시스템은 Java 9에서 정식 도입돼 Java 25에서 안정적으로 사용됩니다. 모듈은 패키지 묶음, 의존 모듈, 외부 공개 패키지, 서비스 사용·제공을 디스크립터에 기록합니다. 클래스 경로의 “어디서나 public 접근”보다 강한 경계를 만들며 ServiceLoader는 소비자가 제공자 구현 클래스에 직접 의존하지 않게 합니다.

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

ServiceLoader는 제공자가 없으면 빈 반복자를 반환합니다. 아래 사용자 정의 서비스는 등록된 제공자가 없어 orElseThrow()에서 실제 NoSuchElementException이 발생합니다.

lab/MissingServiceProviderFailure.java
import java.util.ServiceLoader;

public final class MissingServiceProviderFailure {
    private interface StudyExporter {
        String export(String title);
    }

    public static void main(String[] args) {
        StudyExporter exporter = ServiceLoader.load(StudyExporter.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.study.app")
                        .requires("java.base")
                        .requires("java.sql")
                        .exports("com.example.study.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());
    }
}

실제 디스크립터 문법은 다음 관계를 표현합니다.

module com.example.study.app {
    requires com.example.study.api;
    uses com.example.study.api.StudyExporter;
}

module com.example.study.json {
    requires com.example.study.api;
    provides com.example.study.api.StudyExporter
        with com.example.study.json.JsonStudyExporter;
}

소비자 모듈은 제공자 모듈의 이름이나 구현 패키지를 알 필요가 없습니다. 런타임 이미지나 모듈 경로에 제공자가 포함되어 의존성이 올바르게 해석되는지는 배포 구성이 책임집니다.

JDK 서비스 제공자 탐색

java.util.spi.ToolProvider는 JDK 도구를 서비스로 노출합니다. 현재 런타임에 있는 제공자를 ServiceLoader로 찾고 이름을 출력할 수 있습니다. 이 예제는 별도의 등록 파일 없이 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);
    }
}

ServiceLoader가 제공자를 생성하는 과정은 실패할 수 있고 생성자 호출 비용도 발생합니다. 요청마다 전체 로더를 탐색하지 말고 애플리케이션 시작 시 검증해 불변 목록을 만들 수 있습니다.

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 디스크립터를 읽고 exportsuses의 수를 출력하세요. 모듈이 없을 때는 명확한 예외를 내야 합니다.

해설 보기
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());
    }
}

종료 기준은 namejava.compiler이고 자동 모듈 여부가 false인 것입니다. 실제 애플리케이션 모듈은 컴파일한 JAR을 모듈 경로에 올려 같은 방식으로 디스크립터를 검사합니다.