2. 모듈의 도입을 사용한 패키지 관리의 진화

김리원·2021년 9월 1일
0

Java 17로의 전환

목록 보기
2/6

이번에는 종속성과 공개범위를 정의하는 방법과 비호환성에 대한 대응하는 방법에 대해서 소개합니다.

Java의 새로운 관리 구분 "모듈"

이번에는 java 9에서 도입된 Project Jigsaw를 소개합니다.

모듈 : 패키지를 한번에 관리하기

Java에서는 패키지라는 일부 클래스와 인터페이스를 하나의 묶음으로 하는 방법이 있습니다. 소스코드 앞에 package문장을 아래와 같이 선언하면 그 파일에 포함된 클래스나 메소드맴버(이하 요소라고 표현함)은 com.example패키지의 일부로 정의할 수 있습니다.

package com.example;

Java 8까지는 이 방법을 이용하여 기능별로 구분하여 개발해 왔습니다. 그러나, 이 방법은 오랫동안 문제가 있었기 때문에, 모듈로 불리는 여러가지 패키지를 한번에 관리할 수 있는 시스템이 도입되었습니다.

왜 모듈을 도입했을까?

Java 애플리케이션의 다양화 및 대규모화에 따라 몇가지 이슈가 있습니다.

첫번째, 의존관계를 정의할 수 없는 이슈입니다. 현재 있는 Java 애플리케이션이 의존하는 여러가지 패키지들로 라이브러리가 다른 라이브러리에 의존하여 그것을 다른 라이브러리에 의존한다는 것이 일단 복잡하게 의존상태가 섞이는 상테에 빠지면, 솔루션 구현이 어려워지고, 유지보수성이 심각하게 회손되어 버릴 수 있습니다.
두번째, 공개범위를 정의할 수 없는 이슈입니다. 외부에 이용되는 것을 상정하지 않은 내부용 API의 경우, 사용자에게 미치는 영향을 생각하지 않고, 유지보수를 실시합니다. 그러나, 실제로는 컴파일할때나 실행시 경고없이 접근제한이 걸려도 리플렉션등을 이용하게 되어 버려서 유지보수가 힘들어지는 경우가 많습니다.

이런 과제를 해결하기 위해 모듈 의존 관계 공개범위(어떤 모듈 종속성이 어떤 패키지를 외부에서 볼 수 있도록 할 것인지)의 정의를 할 수 있게 됩니다.

모듈 사용법

실제로 모듈을 이용하여 의존성과 공개범위를 정의해 봅시다.

모듈 정의하기

모듈은 디렉토리 바로 아래에 module-info.java라는 전용파일을 제공하고 아래와 같이 작성하여 그 디렉토리에 포함된 패키지모듈 module.a에 속한다고 정의할 수 있습니다.

module module.a { }

또한, { } 안에 닫힌 부분에 아래 표에 나타내는 주요 지시문을 이용하여 모듈 종속성과 공개범위를 정의할 수 있습니다.

종속성 정의하기

Java는 컴파일할 경우나 실행 시, 각 모듈의 정의(module-info.java) 및 지정된 Java옵션에 따라 모듈간의 의존관계를 분석하고 모듈 그래프를 구축합니다. 이것을 모듈의 해결(Module Resolution)이라고 합니다.
종속성을 정의하려면 이 모듈 그래프가 어떻게 구축되는지를 이해할 필요가 있습니다. 실제로 어떤 모듈을 로드하여 해결을 하기 위해 --show-modulerresolution옵션을 지정하여 실행하여 확인할 수 있습니다.

$ java --show-module-resolution -p mods/ -m module.a/ com.example.a.Main
root module.a file:///workspace/mods/module.a/
java.base binds java.desktop jrt:/java.desktop
java.base binds java.management jrt:/java.management
(생략)

루트모듈

모듈 그래프를 구축하는 기점이 되는 모듈을 지정해야 합니다. 이 루트 모듈이라고 컴파일시, 컴파일된 모듈 런타임은 --module 옵션 및 --add-modules옵션으로 지정하는 것이 루트 모듈입니다.

module-info.java에 의한 종속성 정의

루트모듈을 기점으로 각 모듈의 정의를 바탕으로 모듈이 의존하는 모듈인지 확인합니다. 이 모듈의 정의는 앞에서의 module-info.java의 requires 지시어로 합니다.
예를 들어, 아래 목록과 같은 4개의 모듈의 의존관계를 생각해 봅시다. 우선 module.a모듈은 requires지시문 module.b에 의존합니다. 마찬가지도 module.b는 module.c과 module.d에 따라 달라집니다.
여기서 module.b는 module.d에 requires transitive를 설정하고 있기 때문에, module.b에 의존하는 module.a는 module.d와 전이종속성을 가집니다. 그러면 module.a는 module.d에 포함된 패키지를 requires지시문에서 종속성을 정의하지 않지만 사용할 수 있습니다. module.c에 종속성이 없기 때문에 사용할 수 없습니다.

주요 모듈 설정목록

예제1 모듈 종속성

module module.a {
	requires module.b;
}
module module.b {
	requires module.c;
	retuires transitive module.d;
}
module module.c { }
module module.d { }

또한, 모든 모듈은 Java를 실행하는데 필요한 기본 패키지 모음과 Java표준 API를 java.base모듈에 암시적으로 의존하고 있습니다. 아래그림과 같은 모듈 그래프가 있습니다.
그리고 requires에서 지정한 모듈은 아래 모듈 중 하나이어야만 합니다.

  • --module-source-path로 지정된 경로에 있는 모듈
  • --upgrade-module-path로 지정된 경로에 있는 모듈
  • 시스템 모듈: 실행하고 있는 Jav런타임에 포함된 모듈
  • 애플리케이션 모듈: 모듈 경로위에 존재하는 모듈과 --addmodules로 지정하여 만든 모듈등

이러한 모듈을 참조가능한 모듈(Observable modules)이라며 참조 불가능한 모듈을 지정하면 오류가 발생합니다.

도구를 이용한 종속성 확인 방법

모듈화를 하여 종속성과 공개범위를 정의할 수 있겠지만, 알맞게 이들을 정의하려면 어떻게 확인해야할까요? 기존 라이브러리 및 클래스파일의 종속성과 공개범위의 확인수단으로 jdeps 도구가 있습니다. 이 도구의 주요명령옵션은 아래와 같습니다. 예를 들어 dependencies.jar라이브러리에 의존하는 sample.jar라이브러리에 대한 종속성을 확인하려면 아래와 같은 명령을 실행합니다.

$ jdeps -summary -cp libs/dependencies.jar sample.jar
sample.jar -> java.base
sample.jar -> java.management
sample.jar -> dependencies.jar

이렇게 지정된 라이브러리가 의존하는 시스템 모듈이나 라이브러리임을 알 수 있습니다. 이번의 경우에는 requires지시어의 모듈 시스템 모듈 java.management임을 할 수 있습니다. requires지시어는 패키지가 아닌 모듈이기 때문에 dependencies.jar내용은 모듈화가 끝나고 의존하는 패키지가 포함된 모듈을 식별한 후, 지정해야 합니다.
실제로 모듈화 시, 라이브러리의 패키지를 여러 개 모듈로 나눌수도 있습니다. 이 경우에는 jdeps도구 -verbose:package옵션을 사용하여 패키지간의 종속성을 확인하면서 모듈화하는 것이 좋습니다.

공개범위 정의하기

공개범위는 모듈정의(module-info.java)에서 해당 모듈에 포함된 패키지를 어떤 모듈에 공개할지 여부를 정의합니다.

module-info.java에 의한 공개범위 정의

구체적으로는 opens 및 exports지시문을 사용하여 다음과 같이 정의합니다.

module module.a {
	// 모든 모듈에 공개
    exports com.example.a;
    // 지정한 모듈만 공개
    exports com.example.b to module.b;
    // 리플렉션에 의한 참조 허용
    opens com.example.c;
}

이렇게 하면, com.example.a 패키지에 속하는 public 또는 protected가 설정된 요소는 모든 모듈에 공개되어 com.example.b는 to로 지정한 module.b에서만 공개됩니다. 반대로 exports지시어를 사용한 패키지는 다른 모듈에서 호출할 수 없습니다.
마지막으로 정의한 opens 지시어는 실행시 리플렉션에 의한 참조를 허용하는 패키지를 정의합니다. 또한, exports지시어로 지정한 패키지의 private 요소는 리플렉션을 사용하여 접근할 수 없습니다. 따라서, private인 요소를 포함하여 게시하려는 경우, exports와 같이 opens지시문에서 패키지를 공개해야 합니다.

도구를 이용한 공개범위 확인방법

공개범위를 정의하여 다른 모듈에서 내부 API가 포함된 패키지를 볼 수 없게 할 수 있습니다.만약, 의도하지 않는 참조가 있을 경우, 오류가 발생해 버리기 떄문에 다른 라이브러리 및 패키지 내부 API를 참조하지 않는지 확인하고 싶은 경우, jdeps도구를 사용하여 확인할 수 있습니다. 예를 들어, sample.jar라이브러리 내부 API를 포함한 com.example.internal패키지가 의도하지 않게 참조되고 있는지 확인합시다.

$ jdeps -p com.example.internal -cp libs/* sample.jar
sample.jar -> sample.jar
com.example.a -> com.example.internal sample.jar
com.example.b -> com.example.internal sample.jar
(생략)

만약, 의도하지 않는 종속성이 있다면, 다음과 같이 구체적으로 어떤 클래스에서 참조되고 있는지를 확인할 수 있습니다.

$ jdeps -verbose:class -e "com.example.internal.*" -cp libs/* sample.jar(실제 1행)
sample.jar -> sample.jar
com.example.a.AClass -> com.example.internal.InternalClass sample.jar
com.example.b.BClass -> com.example.internal.InternalClass sample.jar
(생략)

이와 같이 조사하여 수정이 필요한 클래스를 신속하게 파악할 수 있습니다. 라이브러리와 모듈들이 설계대로 참조가 이루어지고 있는지 확인하면서 알맞게 공개범위를 정의합시다.

모듈 시스템에 라이브러리 지원

여기까지의 설명은 모두 모듈화된 라이브러리를 사용한다는 전제로 진행하고 있습니다. Java는 하위 호환성을 중요하게 생각하는 언어이지만, Java 8이전에 개발된 것들은 모듈화되지 않은 라이브러리를 포함하여 컴파일 및 실행이 가능할까요?

클래스 경로에서 로드되는 라이브러리 사용

Java 8 이전의 클래스 경로는 각 패키지가 어떤 라이브러리에 포함되어 있는지를 구별할 방법이 없고, 클래스 검색은 클래스 경로에서 로드된 모든 라이브러리에서 이루어지고 있습니다. 따라서, 컴파일 및 실행하기 전에 부족한 라이브러리가 있는지를 감지하지 못하거나, 다른 라이브러리에 동일한 패키지에 포함되어 있어도 감지하는 방법이 없었습니다.
모듈이 문제는 해소되었지만, 하위 호환성을 유지하기 위해 클래스 경로가 남아 있었습니다.클래스 경로 이슈가 그대로 남이 있으면 이런 문제가 남아 버립니다. 이에 대한 클래스 경로에서 로드된 패키지와 라이브러리도 모듈로 실행하도록 하여 호환성을 유지하면서 문해결을 도모하였습니다.

이름없는 모듈: Unnamed module

클래스 경로에서 참조된 패키지와 클래스는 이름없는 모듈(Unnamed module)이라는 특수 모듈로 인식됩니다. 이름없는 모듈은 모듈 정의가 없기(가질 수 없는) 때문에 종속성과 공개범위를 자동으로 아래와 같이 처리됩니다.

  • 로드된 모든 모듈을 requires로 지정하고 있습니다.
  • 이름이 없는 모듈에 포함된 모든 패키지를 exports에서 지정합니다.
    즉, 다른 모든 모듈에 의존하고 있는 모든 패키지를 공개하여 처리됩니다.

자동 모듈: Automatic module

모듈화되지 않은 (모듈정의 module-info.class가 포함되지 않은) 라이브러리 모듈 경로에 넣어 사용하면 라이브러리는 자동 모듈(Automatic module)로 인식됩니다.
자동모듈의 모듈명은 다음 우선순위에 의해 자동으로 결정됩니다. 모듈에서 자동 모듈에 종속되게 하려면, requires지시어는 여기에서 결정된 모듈 결정된 모듈명을 지정해야 합니다.

  • manifest.mf의 AUtomatic-Module-Name으로 지정된 이름입니다.
  • JAR파일명에서 자동으로 생성된 이름입니다.
    자동모듈 종속성과 공개 범위는 모듈 정의를 가지지 않기 때문에 이름없는 모듈과 동일하게 취급됩니다.

기존 애플리케이션의 모듈 사용시 주의사항

기존 Java애플리케이션 모듈을 이용하는 경우, 이름있는 모듈과 이름없는 모듈을 동시에 이용할 수 있습니다. 앞서 말한 것처럼 클래스 경로부터 읽혀진 것은 특수 모듈로 취급되기 때문에 몇가지 주의사항이 있습니다.

루트 모듈의 결정 방법이 변경됩니다

이름없는 모듈이 컴파일 대상이 되는 경우나 기본 메소드를 가진 클래스를 포함하는 모듈이 있는 경우, 시스템 모듈의 거의 모든 모듈이 자동으로 루트 모듈입니다. 더 정확하게 말하면, --upgrade-module-path에 모듈과 시스템 모듈 중 적어도 하나의 패키지를 전체 모듈에 공개하는 모듈입니다. 이것은 하위 호환성 유지를 위해, 이름없는 모듈은 거의 모든 모듈을 사용할 수 있도록 되어져 결과적으로 지금까지의 클래스 경로와 같은 동작이 유지됩니다.

이름있는 모듈과 이름없는 모듈을 찾을 수 없습니다

이름없는 모듈은 전체 패키지를 공개하고 취급하지만, 이름이 있는 모듈에서 이들을 볼 수 없습니다. 참조하거나 종속성이 관리할 수 없게 클래스 경로가 안고 있는 문제가 재발될 수 있기 때문입니다.

같은 패키지 내에 있으면 이름있는 모듈이 우선입니다

같은 패키지가 클래스 경로와 모듈 경로에 전부 있을 경우, 이름이 있는 모듈의 패키지가 우선됩니다. 이런 패키지를 분할 패키지라고 합니다.
이 상태에서 이름이 없는 패키지 밖에 없는 클래스를 참조하려면 java.lang.NoClassDefFoundError가 발생합니다. 또한 분할 패키지의 단점은 실행 시 경고가 출력되지 않는다는 것입니다. 클래스 경로에 포함되어 있는 패키지의 클래스를 이용하고 있다고 판단하게 되면, 모듈 경로에 포함된 클래스가 실수로 실행되는 경우도 있습니다.
분할패키지가 있는지 판단하는 여부는 jdeps명령으로 확인할 수 있습니다. 예를 들어, 모듈경로에 "Hello from module path"를 출력하고 클래스 경로에 "Hello from class path"를 출력하는 com.example.a.Main클래스를 각각 포함하고 실행합니다.

// 경고가 나오지 않고 종료함
$ java -p mods/ -cp libs/ -m module.a/com.example.a.Main(1행)
Hello from module path
// jdeps의 -p옵션은 모듈 경로가 아님에 주의
$ jdeps --module-path mods/ -cp libs/ libs/com/example/a/Main.class(1행)
경고: 분할패키지: com.example.a file:///workspace/mods/module.a/ libs
(생략)

위와 같이 경로: 분할패키지: <분할패키지명 > <클래스경로> 형식으로 경고합니다. 만약, 클래스경로에 지정된 패키지를 우선하고 싶은 경우 --patch-module <모듈명 = 분할패키지 경로 >를 지정하여 명명된 모듈에 추가할 수 있습니다.

$ java --patch-module -p mods/ -cp libs/ -m module.a/ com.example.a.Main(1행)
Hello from class path

JDK 모듈화에 의한 Java의 비호환성 지원

그외 JDK모듈화하여 여러가지 Java호환성 문제가 발생하기 떄문에 그에 대한 대응이 필요합니다.

JDK 내부 패키지의 은닉화에 의한 비호환성

JDK내부 패키지의 은닉화로 일부 JDK내부 API를 사용하는 경우, 컴파일이 실패합니다. 자신의 라이브러리나 클래스가 JDK내부 API를 사용하고 있는지에 대한 여부를 아래와 같이, jdeps 도구를 사용하여 확인을 할 수 있습니다.

jdeps -jdkinternals <Jar파일  클래스파일 >

이를 이용하는 경우, 해결책이 출력되므로 그 내용에 따라 수정해야 합니다.

리플렉션을 통해 비공개 패키지에 접근하는 경우 대응

원칙적으로는 리플렉션을 통해 접근하는 것은 없습니다. 단기적인 해결방법은 -add-exports 또는 --add-opens옵션에 따라 모듈의 공개범위를 추가적으로 정의할 수 있습니다. 이는 장기적인 해결책은 없다는 점에 주의해야 합니다. 예를 들어, 모듈 A에 비공개 모듈 B의 패키지 B에 접근하고자 하는 경우 --add-exports "모듈B/패키지B = 모듈 A"로 지정합니다. 이름이 없는 모듈에 공개하려면, ALL-UNNAMED로 지정합니다. 특정 이름이 없는 모듈뿐만 아니라, 모든 이름이 없는 모듈이 공개되어 버리는 것에 주의해야 합니다.
또한, public이 아닌 요소에 setAccessible(true)를 사용하여 접근하려고 하면, java.lang.reflect.InaccessibleObjectException이 발생합니다. 이런 경우에는 --add-exports대신 --add-opnes를 사용합니다.

삭제된 JDK가 포함된 JAR파일을 참조하는 경우 대응

Java 8이전에는 JRE(Java Runtime Environment)와 JDK에서 lib/rt.jar, lib/tools.jar와 같은 시스템 클래스파일이 포함한 내부에 Jar파일이 있었지만 이는 JEP 220기준으로 삭제되었습니다. 아래와 같이 하면 실행할 때 시스템 클래스 파일에 접근할 필요가 있는 경우 아래와 같이 jrt파일 시스템을 통해 접근해야 합니다.

jshell> FileSystem fs = FileSystems.getFileSystem(URI.create("jrt:/"));(1행)
fs ==> jrt:/
jshell> Path classFilePath = fs.getPath("modules", "java.base", "java/lang/Class.class");(1행)
classFilePath ==> modules/java.base/java/lang/Class.class

tools.jar가 삭제된 것에 대한 더 자세한 대처방법은 나중에 설명하겠습니다.

삭제된 추가 클래스 경로 매커니즘을 이용하는 경우 대응

삭제된 추가클래스 경로 매키니즘(lib/ext)이 삭제되었습니다. 앞으로 명시적으로 클래스경로(-cp)에 추가하거나 모듈화하여 애플리케이션 모듈에서 로드하도록 수정합니다. 클래스 경로에 추가하는 경우, 앞에서 이야기한 것처럼 자동 모듈된다는 점에 주의합시다.

profile
개발자, IT강사, sage.riwon.kim@gmail.com

1개의 댓글

comment-user-thumbnail
2022년 6월 11일

본인이 작성한 글이 아닌데 왜 출처는 작성 안하시나요?
번역은 직접 하신건가요?

답글 달기