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

안동민 개발노트

본문 시작
14장 : 현대 Java

모듈과 JDK 25 기능

Java 모듈 시스템은 패키지 집합에 이름을 붙이고 읽을 의존성과 외부에 내보낼 패키지를 선언합니다.

classpath의 모든 타입이 서로 보이는 구조보다 강한 캡슐화를 제공합니다.

JDK 25에서는 compact source files와 instance main methods, module import declarations, Scoped Values가 정식 기능입니다.

반면 Structured Concurrency, primitive patterns, Stable Values는 Preview이므로 안정 API와 같은 계약으로 사용하면 안 됩니다.

이 절만은 단일 기본 패키지 규칙의 예외로 명시적인 모듈·패키지 디렉터리를 사용합니다.

모듈의 requires는 내 코드가 어느 모듈을 읽는지, exports는 다른 모듈이 내 어느 패키지를 컴파일·호출할 수 있는지 정합니다. public만 붙였다고 모듈 경계를 통과하는 것은 아닙니다. 반대로 exports는 패키지 내부 구현까지 자동 공개하지 않으므로 Java 접근 제어와 모듈 제어가 겹쳐 작동합니다.


module-info가 의존성과 공개 패키지를 선언한다

requires는 읽을 모듈, exports는 다른 모듈에 공개할 패키지를 정합니다.

패키지 내부 public 타입도 exports되지 않으면 모듈 밖에서 보이지 않습니다.


모듈 경로는 클래스 경로와 탐색 방식이 다르다

javac와 java에 --module-path, -m 모듈/메인클래스를 사용합니다.

소스 디렉터리 구조와 패키지 선언을 일치시킵니다.

이 예제는 모듈 코드와 별개로 JDK 25의 ScopedValue 수명 모델을 확인합니다. where(...).run(...)이 만든 동적 범위 안의 하위 호출만 요청 ID를 읽을 수 있습니다.

Jdk25Context.java
public class Jdk25Context {
    private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

    static void service() {
        System.out.println("request=" + REQUEST_ID.get());
    }

    public static void main(String[] args) {
        ScopedValue.where(REQUEST_ID, "req-25").run(Jdk25Context::service);
        System.out.println("bound outside=" + REQUEST_ID.isBound());
        // REQUEST_ID.get(); // 실행 시 NoSuchElementException: 바인딩 범위 밖
    }
}

service가 실행되는 동안에는 req-25가 보이지만 run이 끝난 즉시 이전 바인딩 상태로 복원됩니다. 둘째 줄의 false는 값을 직접 지우는 정리 코드 없이도 범위 밖으로 누출되지 않았음을 뜻합니다.

request=req-25
bound outside=false

ScopedValue 바인딩은 run의 동적 범위 안에서만 보이고 범위를 벗어나면 자동으로 사라집니다.

JDK 25 정식 API와 Preview 상태 구분

requires는 읽을 모듈, exports는 다른 모듈에 공개할 패키지를 정합니다.

필요한 패키지·모듈·플래그로 컴파일

javac와 java에 --module-path, -m 모듈/메인클래스를 사용합니다.

바인딩·작업 수명을 어휘 범위 안에서 종료

JDK 25 정식 API로 값이 바인딩된 동적 범위 안에서 하위 호출이 읽습니다.


ScopedValue는 호출 트리에 불변 문맥을 전달한다

JDK 25 정식 API로 값이 바인딩된 동적 범위 안에서 하위 호출이 읽습니다.

전역 ThreadLocal의 임의 변경보다 수명과 가시성이 분명합니다.

명시적 모듈은 디렉터리 구조, module-info.java, 패키지 선언, 실행 명령이 한 계약으로 맞아야 합니다.

비교 1
명시적 모듈 예제 구조
src/
  com.example.app/
    module-info.java
    com/example/app/Main.java
비교 2
// module-info.java
module com.example.app {
    requires java.logging;
    exports com.example.app;
}
비교 3
// Main.java 첫 줄
package com.example.app;
비교 4
javac -d out --module-source-path src -m com.example.app
java --module-path out -m com.example.app/com.example.app.Main

안정 기능과 Preview를 명령부터 분리한다

Preview 코드는 컴파일과 실행 양쪽에 --enable-preview가 필요하고 --release 25와 함께 빌드합니다.

학습 실험을 운영 계약으로 오해하지 않습니다.


compact source file은 작은 진입점을 단순화한다

JDK 25에서 정식화된 compact source file과 instance main은 입문용·도구용 소스를 짧게 합니다.

큰 애플리케이션의 패키지 구조를 없애는 기능은 아닙니다.


module import는 의존성을 자동 추가하지 않는다

import module M;은 모듈이 내보낸 패키지 타입을 한 번에 가져오지만 named module의 requires M이나 classpath의 모듈 해석 설정을 대신하지 않습니다.


Preview는 릴리스에 묶인 실험 계약이다

Structured Concurrency와 일부 패턴 기능은 JDK 25 Preview입니다.

javac --release 25 --enable-preview와 실행 시 java --enable-preview를 모두 사용하고 다음 JDK 호환을 가정하지 않습니다.

Preview 예제를 실행할 때 먼저 --enable-preview를 컴파일 명령에서만 빼고, 다음에는 실행 명령에서만 빼 보세요. 두 단계 모두 같은 릴리스의 Preview 계약에 참여해야 한다는 오류를 확인할 수 있습니다. 실험 코드는 별도 소스 집합이나 모듈로 격리하면 정식 코드까지 플래그에 묶이는 범위를 줄일 수 있습니다.

모듈화의 목적이 폴더를 더 만드는 데 그치면 비용만 늘어납니다. 의존 방향을 제한하고 내부 패키지를 숨길 경계가 있을 때 적용하세요. 리플렉션 기반 프레임워크가 내부 타입을 조사해야 한다면 무조건 전체를 여는 대신 필요한 패키지와 대상 모듈만 opens ... to ...로 좁히는 방식을 검토합니다.


JDK 25의 정식 기능은 일반 코드에서 사용할 수 있지만 Preview 기능은 플래그, 버전 종속성, 변경 가능성을 함께 기록해야 합니다.