실행 시간 효율을 위한 클래스 데이터 공유(CDS)와 Layered Jar

🧐 | 2024-02-21

안녕하세요, 넷마블 QA실 QA시스템팀 이동근입니다.

백엔드 개발에서 사용하는 프로그래밍 언어는 C++, C#, Java, Python, Go, Javascript 등 매우 다양합니다. 제가 담당하는 크래시리포트는 이 중에서 자바를 이용해 백엔드 애플리케이션을 개발했습니다. 자바는 오픈소스 소프트웨어 생태계가 매우 광범위하고 역사도 오래됐습니다. 많은 개발자들의 선택을 받는 이유 중 하나가 아닐까 합니다.) 크래시리포트의 개별 서브 시스템 역시, 다양한 오픈소스 소프트웨어 중에 스프링부트(Spring Boot) 프레임워크를 활용해 개발했습니다.

자바로 개발한 애플리케이션이 다른 언어 대비 좋은 점만 있는 것은 아닙니다. 자바로 만들어졌기 때문에 감내해야 하는 단점을 갖고 있습니다. 본 글에서는 자바 애플리케이션의 단점 중 하나인 초기 구동 시간이 오래 걸리는 문제에 대해서 이야기하고자 합니다. 

실행 시간 효율을 향상하는 3가지 기술

자바는 바이트코드 형태로 패키징돼 JVM을 통해서 실행 환경에 맞는 기계어로 변환되는 과정을 거쳐 실행됩니다. 그렇기에 JVM이 설치된 곳이라면 어디든 동일한 결과가 나오도록 실행할 수 있습니다. 이런 장점은 동시에 단점이 되기도 합니다. 실행 환경에 맞춰 변환하기 위해 많은 시간이 소요됩니다. 일부 라이브러리는 이를 보완하기 위해 필요에 따라 로딩하는 절차가 추가되기도 합니다.

자바를 이용하는 개발자 진영에서는 이런 초기 시작 시간이 오래 걸리는 문제를 해소하기 위해서 여러 접근 및 해소 방법을 제안하고 있습니다.

  • GraalVM Native Image
  • JVM Checkpoint Restore: Project CRaC
  • Project Leyden

 위 3가지 방법의 특징을 간단히 정리해 보겠습니다.

첫 번째인 GraalVM Native Image는 미리 컴파일해서 바로 실행 가능한 상태로 만들어 주는 방법입니다. 바이트 코드를 기계 언어로 바꾸는 작업이 없어짐으로 인해, 초기 실행 시간이 단축되고 성능을 개선할 수 있습니다. 하지만, 네이티브 이미지는 생각보다 제약사항이 많습니다. 컴파일 시간이 오래 걸리고, 컴파일 과정에서 오류도 발생합니다. 컴파일이 된다고 하더라도, 실행 시 많은 오류가 발생합니다. 

특히 Mybatis 프레임워크를 사용한 애플리케이션의 경우, 네이티브 이미지를 만들기가 더욱 어렵습니다. 기존 Mybatis를 사용한 코드를 네이티브 이미지에서 실행할 수 있도록 대대적인 수정을 해야 합니다. 기존 서비스 코드를 수정하면서까지 네이티브 이미지를 적용하는 것은 큰 부담이 됩니다. GraalVM Native Image에 대한 좀더 자세한 내용은 지난번 글 “쿠버네티스가 스프링부트 3.0 네이티브 이미지를 만났네”를 참고해 주세요.

두 번째 방법은 Project CRaC입니다. Project CRaC은 간단하게 설명하면, GraalVM처럼 Azul Systems에서 확장 개발한 JDK 안에 포함된 CRIU 프로그램을 사용해서 실행 중인 인스턴스의 스냅샷(Snapshot) 이미지를 생성하고, 이를 새로운 애플리케이션이 시작할 때 활용하게 해서  실행 시간과 초기 성능을 개선하는 방법입니다. 현재는 리눅스 환경만 지원하며, 스프링부트 3.2부터 지원하기 시작한 기능이라, 아직은  트러블슈팅에서 어려움을 겪을 수 있습니다. 저도 시도하다가 멈춘 상태입니다. 

세 번째 방법인 Project Leyden은 정적 이미지를 활용해 초기 시작 시간과 성능을 개선한 방법입니다. 핫스팟 JVM(HotSpot JVM), C2 컴파일러(C2 compiler), 애플리케이션 클래스 데이터 공유(Application Class-Data Sharing), 제이링크 코드 도구(jlink code tool), JDK의 기본 구성요소를 활용할 것이라고만 알려져 있습니다.

위와 같은 방법들이 지속해서 제안되고 개발되는 것은, 최근 서비스 환경이 클라우드로 전환되고 순간 급증하는 트래픽에 대응하기 위함일지도 모릅니다. 오토스케일링으로 신규 서버 자원을 추가했을 때, 신규 인스턴스의 서비스 준비 시간을 단축해야 클라우드 자원을 효율적으로 사용할 수 있고, 더불어 비용도 절감할 수 있다고 생각합니다. (실제로 Project CRaC는 AWS Lambda와 IBM OpenLiberty의 지원을 받습니다.)

위 방법들은 예측하기 어려운 대량 인입 트래픽에 대응할 접근 방법들입니다. 지난번 글 “쿠버네티스가 스프링부트 3.0 네이티브 이미지를 만났네”에서 적용했던 네이티브 이미지는 지금도 잘 동작하며 늘어난 트래픽에 잘 대응하고 있습니다. 

위에서 언급한 네이티브 이미지와 Project CRaC은 효과는 강력하지만, 적용 과정이 복잡하고 제약 사항도 많습니다. 그래서 Project Leyden이 시작된 것 같습니다. 좀더 범용적이고 단순하게 문제를 해결하고 싶었을 것 같습니다. 가상 스레드(Virtual Thread)를 만든 Project Loom처럼요. 가상 스레드는 아직 가능성에 비해 제한 요소가 많습니다. 하지만, 지속해서 생태계가 대응한다면 큰 변화를 줄 것이라고 생각합니다. (나중에 기회가 되면 가상 스레드를 적용했던 실험 과정과 결과를 공유하겠습니다.)

Project Leyden의 CDS

이번 글에서는 Project Leyden에서 활용하는 애플리케이션 클래스 데이터 공유(Application Class-Data Sharing)를 활용해, Mybatis가 적용된 시스템의 초기 실행 시간 개선을 해보고자 합니다.

클래스 데이터 공유의 적용 및 결과

애플리케이션 클래스 데이터 공유(application class-data sharing) 기능은 OpenJDK 12버전부터 제공된 기능입니다. 최근 스프링 프레임워크(Spring Framework) 6.1.3 버전에서 정식 지원을 시작해서 좀 더 쉽게 사용할 수 있게 됐습니다. 기존 스프 링부트 애플리케이션에서 어떻게 적용하는지 살펴보겠습니다. 

저의 개발 환경은 JDK 21, 스프링부트 3.2.2, Mybatis 3.0.3 그리고 기타 등등의 라이브러리로 구성돼 있습니다. 위에서 언급했던 것처럼 Mybatis는 GraalVM을 이용해 네이티브 이미지로 만들기가 상당히 어렵습니다. 해내신 분들은 정말 고수이십니다. 저는 어려운 길보다 효과는 떨어지지만 안정적이고 간단한 방법으로 시도해 보겠습니다.

java -XX:ArchiveClassesAtExit=application.jsa -jar -Dspring.context.exit=onRefresh -Dserver.port=8081 demo.jar

위 명령어를 실행하면, 프로그램 초기 실행이 끝나면서 바로 종료됩니다. 그리고 application.jsa라는 파일이 생성됩니다. 이렇게 생성한 파일과 다음 명령어를 이용해 실제 애플리케이션을 실행합니다. 

java -Xlog:cds:file=dynamic-cds.log -Xlog:class+load:file=cds.log -XX:SharedArchiveFile=application.jsa -jar -Dserver.port=8081 demo.jar

위 명령어를 실행하면, 실행 결과가 두 종류의 로그 파일로 남습니다. 둘 파일 중, CDS Log Parser를 활용해서 cds.log 파일을 분석해 보겠습니다. 아래는 로그 내용입니다.

Class Loading Report:
     18034 classes and JDK proxies loaded
     14653 (81.25%) from cache
      3381 (18.75%) from classpath


Categories:
   Lambdas  2064 (11.45%): 9.84% from cache
   Proxies   198 ( 1.10%): 51.52% from cache
   Classes 15774 (87.47%): 90.97% from cache


Top 10 locations from classpath:
       755 /home/appuser/unpack/BOOT-INF/lib/byte-buddy-1.14.11.jar
       435 __JVM_LookupDefineClass__
        95 __dynamic_proxy__
        84 jrt:/java.management
        69 org.springframework.boot.autoconfigure.web.embedded.TomcatWebServerFactoryCustomizer
        56 org.mariadb.jdbc.client.DataType
        53 jrt:/jdk.jfr
        47 jrt:/java.base
        31 org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryCustomizer
        31 org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesMapper


Top 10 packages:
      5755 org.springframework (78.11% from cache)
      2742 org.hibernate (91.72% from cache)
       935 java.lang (54.12% from cache)
       805 net.bytebuddy (4.97% from cache)
       790 sun.security (99.62% from cache)
       778 org.apache (93.96% from cache)
       751 java.util (98.00% from cache)
       635 com.fasterxml (96.54% from cache)
       417 ch.qos (83.21% from cache)
       372 jdk.internal (93.55% from cache)

81.25% 가 캐시를 통해서 로딩됐습니다. Top 10 package에 org.springframework가 보입니다. 실제 소요된 실행시간을 아래 차트로 확인해 보겠습니다.

위 차트를 보면 Layered-jar가 보입니다. 기본 Jar는 압축 상태의 파일입니다. 이 Jar 파일의 압축을 풀어서 계층 형태로 분배하고 도커(Docker)로 복사하면, 도커 내 개별 레이어를 갖습니다. 이렇게 되면 라이브러리 영역과 사용자 코드 영역 등으로 분리돼, 신규 도커 이미지를 추가할 때 중복이 제거되므로 관리되는 도커 이미지의 크기를 줄일 수 있습니다. 즉, 전체가 101MB인 도커 파일에서 라이브러리는 100MB고 실제 코드는 1MB라면, 레이어로 분리된 애플리케이션을 신규 도커 이미지로 만들 때 변동되는 부분은 1MB가 됩니다. 그래서 관리 측면 이외에 추가로 실행 시간도 개선됩니다. Layered-jar는 스프링부트 2.3 이후 버전부터 정식 지원이 됩니다.

Layered jar를 생성하고 실행하는 방법은 다음과 같습니다. 

RUN java -Djarmode=layertools -jar /build/target/demo.jar extract


COPY --from=builder /build/dependencies/ ./unpack/
COPY --from=builder /build/spring-boot-loader/ ./unpack/
COPY --from=builder /build/snapshot-dependencies/ ./unpack/
COPY --from=builder /build/application/ ./unpack/

WORKDIR /home/appuser/unpack
CMD ["java", "org.springframework.boot.loader.launch.JarLauncher"]

멀티 스테이지로 도커파일(Dockerfile)을 구성하면, 빌더(builder) 스테이지에서 생성한 파일을 실행 스테이지에 위와 같이 복사해서 실행할 수 있습니다.

고려해볼만한 선택지

위 적용 결과를 보면 최초 83초에서 56초로 실행 시 소요되는 시간을 30% 정도 단축했습니다. GraalVM을 이용했을 때보다는 단축 시간 개선율이 떨어지지만, 간단한 노력(적은 리소스 투입)으로도 30% 정도 향상할 방법이 있다는 의미가 됩니다. 있었습니다. 오토스케일링이 빈번해 초기 실행 시간을 단축하고 싶지만, GraalVM 네이티브 이미지를 사용할 수 없는 환경에서는 한 번쯤 고려해볼만한 선택지라고 생각합니다. 

Project Leyden도 클래스 데이터 공유 기능을 활용하기 때문에, 향후 정식 공개될 때는 아마도 유사한 절차로 사용할 수 있을 것이라 예상합니다. 자바가 세상에 나온 지 20년이 넘었습니다. 짧지 않은 시간 동안 쌓인 문제를 해결하기 위한 사용자들의 노력이 계속 유지되는 덕분에, 저를 포함한 많은 개발자가 꾸준히 자바를 개발 언어로 선택하는 것 같습니다. 

여러분의 서비스 환경 개선에 이번 글도 도움이 되길 바랍니다.

<참고자료>