Graalvm Native Image로 CLI 어플리케이션 만들기
CLI 어플리케이션
CLI 어플리케이션은 여러분이 가장 많이 사용하는 어플리케이션은 아니지만, 많은 개발자들은 CI/CD 파이프라인이나 개발툴로 터미널에서 실행되는 많은 CLI 어플리케이션을 사용하고 있습니다.
클라우드 팀에게 서버스들의 오토 스케일링이나 기타 여러 클라우드에 변화를 주는, 클라우드 설정과 관련된 부분들, 이런 용도로 CLI 어플리케이션을 만들고, 다양한 종류의 툴들을 거의 매일 사용하고 있습니다.
이런 CLI 어플리케이션들은 일반적으로 배치를 실행합니다. 이들 CLI 어플리케이션의 다른 특징은 바로 사용자가 직접 사용하고, 사용자의 여러 요구 사항을 만족시키는 것이 중요하다는 것입니다.
CLI 어플리케이션에 대한 사용자의 요구 사항
사용자들이 일반적으로 CLI 어플리케이션에 대해 요구하는 사항은 다음과 같습니다.
- 독립적인 설치
CLI 어플리케이션을 배포하면서, 함께 설치하거나 변경이 되어야할 설정들이 있다면 사용자 입장에서는 많이 불편할 것입니다.
예를 들면, 파이썬 어플을 사용자에게 제공하면서 특정 버전의 파이썬을 설치하도록 요구하거나, 다른 버전의 JDK를 사용하면 동작이 되지 않는 경우가 발생하면 문제겠죠.
- 빠른 실행
이것은 실제 성능에 대한 것은 아닐 수 있습니다. 어플이 얼마나 빠르게 연산하느냐 보다 더 중요한 것은 사용자의 입력에 대해 얼마나 빠르게 응답하느냐는 것일 것입니다.
만일 어떤 어플이 단순히 HELP 명령에 수 초간이 걸린다면, 사람들은 이 어플이 매우 느리다고 느끼게 될 것이고, 어플을 사용하는데 많은 어려움을 느낄 수 있습니다.
- 작은 메모리 사용
만일 어플이 가질 수 있는, 시스템이 보유한 최대의 메모리를 사용한다면 아마 유저들은 좋아하지 않을 것입니다.
시스템의 리소스를 적게 사용하는 것 또한 좋은 CLI 어플리케이션을 만들기 위해 고려해야할 부분입니다.
Graalvm native image를 이용하여 CLI 어플리케이션 개발하기
Graalvm native image를 이용하면, 위 세가지 요구 사항을 만족시킬 수 있습니다.
Graalvm native image는 아래 그림과 같이 어플리케이션, 라이브러리, jdk의 스탠다드 라이브러리, 그리고 substrate vm이라고 불리는 vm 컴포넌트를 가지고 옵니다.
이들을 초기화하고, 이 과정에서 어떤 클래스들이 사용되고, 어떤 객체가 생성이 되는지, 어떤 코드가 실행이 되고, 패키지에 존재만 하고 실제 사용이 되지 않는지에 대한, 의존성을 체크하여 실행 파일을 만듭니다.

이렇게 만든 실행 파일은 매우 빠르게 시작하고, 사용자가 원하는 다양한 종류의 요구들을 빠르게 실행합니다.
Graalvm native image는 앞서 말한 요구 사항을 잘 충족하기 때문에, CLI 어플리케이션 제작에 적합합니다.
게다가 CLI 어플리케이션 개발자들에게는 익숙한 언어와 API를 사용하고, 기존 개발 에코 시스템을 지원하기 때문에, 개발자가 좀 더 비지니스 로직 구현에 집중할 수 있도록 만들어 줍니다.
개발자들은 자신이 원하는 언어 및 패키지를 이용해서 바이트 코드를 만들고, 이를 네이티브로 쉽게 변환이 가능합니다.
이 좋은 조합 중의 하나가 바로 PicoCLI 와 Micronaut의 조합입니다.
PicoCLI & Micronaut을 이용해 CLI 어플 만들기
PicoCLI는 Java를 기반으로 하는 CLI 라이브러리 및 프레임워크입니다.
명령의 결과를 컬러로 나타낼 수 있고, POSIX, GNU, MS-DOS 같은 스타일을 지원하고, 여러 가지 CLI 관련 옵션들을 제공하고 있습니다.
우선 micronaut.io 에서 micronaut을 다운로드 받습니다. bin 디렉토리를 확인해 보면, mn이라는 CLI 어플을 확인해 볼 수 있는데, 이것은 Graalvm native image로 만든 CLI 어플리케이션입니다.
mn을 이용하여 간단한 데모 앱을 만들어 봅니다.
mn create-cli-app primes; cd primes
이렇게 생성된 primes는 PicoCLI를 이용한 간단한 CLI 어플이고, PrimesCommand.java가 생성된 것을 볼 수 있습니다.
해당 어플을 아래와 같이 빌드하고 -v 옵션을 주고 실행해 보면, “Hi”라는 결과를 볼 수 있습니다.
Copied to Clipboard
Error: Could not Copy
./gradlew build java -jar build/libs/primes-0.1-all.jar -v
여기에 소수, 즉 prime number를 계산하는 아래와 같은 PrimeComputer.java를 만들어 줍니다.
이 코드는 uppperbound, 즉 최대값을 하나 입력으로 받아, 그 보다 작은 2 수 사이의 소수를 구하는 클래스입니다.
이것은 PrimeCommand.java에서 사용하도록 아래와 같이 수정할 수 있습니다.
이렇게 수정된 CLI 어플은 소수를 출력하기 위해, -n 으로 반복할 수를 받고, -l 로 앞서 언급한 최대값을 입력으로 받는다.
필요 없는 PrimesCommandTest.java는 아래와 같이 제거해 보자.
rm src/test/java/primes/PrimesCommandTest.java
아래 명령으로 빌드하고 실행해 보면, 아주 짧은 시간 안에 실행이 되는 것을 볼 수 있다.
./gradlew build
최대값을 100으로 주고, time 명령을 통해, CPU 사용량, 시간, 메모리 사용량을 보면 다음과 같습니다.
$ /usr/bin/time -v java -jar build/libs/primes-0.1-all.jar -n 1 -l 100
15:55:41.787 [main] INFO i.m.context.env.DefaultEnvironment - Established active environments: [cli]
[53, 59, 61, 67, 71, 73]
Command being timed: "java -jar build/libs/primes-0.1-all.jar -n 1 -l 100"
User time (seconds): 4.18
System time (seconds): 0.49
Percent of CPU this job got: 236%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:01.98
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 301028
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 51757
Voluntary context switches: 3103
Involuntary context switches: 29
Swaps: 0
File system inputs: 0
File system outputs: 64
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0
정리하면 다음과 같습니다.
- CPU: 234%
- 시간: 1.12초
- 메모리: 293MB
그럼, 아래 명령어를 이용하여 native image로 변환하여 실행해 보겠습니다.
$ ./gradlew nativeImage
...
$ /usr/bin/time -v build/native-image/application -n 1 -l 100
16:07:26.972 [main] INFO i.m.context.env.DefaultEnvironment - Established active environments: [cli]
[53, 59, 61, 67, 71, 73]
Command being timed: "build/native-image/application -n 1 -l 100"
User time (seconds): 0.03
System time (seconds): 0.01
Percent of CPU this job got: 36%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:00.13
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 43500
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 2060
Voluntary context switches: 88
Involuntary context switches: 1
Swaps: 0
File system inputs: 0
File system outputs: 0
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0
정리하면 다음과 같습니다.
- CPU: 7% (234%에서)
- 시간: 0.23초 (1.12에서)
- 메모리: 40MB (293MB에서)
CPU, 시간, 메모리 모두 큰 폭으로 줄어든 것을 볼 수 있습니다.
이번에는 해당 연산을 십만번 반복하도록 -n 옵션은 100000으로 변경하여, native image를 실행해 봅니다.
$ /usr/bin/time -v build/native-image/application -n 100000 -l 100
...
Command being timed: "build/native-image/application -n 100000 -l 100"
User time (seconds): 3.32
System time (seconds): 6.05
Percent of CPU this job got: 98%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:09.48
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 300412
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 67343
Voluntary context switches: 88
Involuntary context switches: 13
Swaps: 0
File system inputs: 0
File system outputs: 0
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0
정리하면
- CPU: 74%
- 시간: 4.21
- 메모리: 305MB
305MB의 경우, CLI 어플리케이션에게는 부담이 될 수 있습니다.
이것은 다음과 같이 Java에서처럼 -Xmx, -Xmn 옵션을 주어 런타임에 조정할 수 있습니다.
/usr/bin/time -v build/native-image/application -Xmx64m -Xmn16m -n 100000 -l 100
그 결과는 다음과 같다.
- CPU: 78%
- 시간: 4.03
- 메모리: 57MB
또한, 런타임이 아닌 빌드 타임에 옵션을 추가해서 바이너리를 만들 수 있습니다.
아래와 같이 build.gradle에 native image와 관련된 옵션들을 추가하여 이미 설정된 바이너리를 만들 수 있습니다.
nativeImage {
args("-R:MaxHeapSize=64m") args("-R:MaxNewSize=16m")
}
결론
Graalvm native image는 CLI 어플리케이션 개발에서 요구하는 사용자 및 개발자의 요구 사항을 충족시키고 있고, 따라서 다음과 같은 프로젝트들에서 Graalvm native image 기술을 사용하고 있습니다.