본문 바로가기

CS/JVM

[JVM] CDS와 AppCDS - JVM이 클래스 로딩을 캐싱하는 방법

JVM 인터널 시리즈

이전 글에서 ClassLoader가 .class 파일을 찾아 InstanceKlass로 변환하는 과정을 살펴봤습니다.
이번 글은 그 연장선입니다. JVM이 매번 같은 작업을 반복하지 않도록 결과를 캐싱하는 방법, Class Data Sharing(CDS) 을 다룹니다.

 


1. 왜 JVM 시작이 느린가?

java -jar app.jar를 실행하는 순간, JVM은 애플리케이션이 첫 번째 요청을 처리하기 전까지 상당한 양의 작업을 수행합니다.

 

전형적인 Spring Boot 애플리케이션은 완전히 초기화되기까지 15,000~25,000개의 클래스를 로드합니다. 각 클래스는 아래의 단계를 거칩니다.

Loading  : .class 파일 탐색 → 바이트코드 읽기/파싱 → InstanceKlass 생성
    ↓
Linking
  ├─ Verification  : 바이트코드 안전성 확인
  ├─ Preparation   : static 필드 기본값 초기화
  └─ Resolution    : 심볼릭 레퍼런스 → 실제 메모리 참조
    ↓
Initialization : static 블록 실행, static 필드 실제 값 대입

이 중 java.lang.*, java.util.* 같은 JDK 핵심 클래스는 어떤 Java 애플리케이션이든 반드시 로드합니다. 내용이 바뀌지도 않습니다. 매 실행마다 동일한 결과를 만들어내는 작업을 반복하는 셈입니다.

 

CDS의 아이디어는 단순합니다.

"어차피 같은 결과가 나올 작업이라면, 결과를 파일에 저장해두고 다음 실행 때 재사용하자."

 


2. CDS 동작 원리: 공유 아카이브와 mmap

CDS의 핵심은 공유 아카이브 파일(.jsa) 입니다. 이 파일에는 dump 시점에 미리 파싱·검증된 클래스들의 내부 표현, 즉 InstanceKlass 메타데이터, 상수 풀, 메서드 정보 등이 HotSpot VM이 직접 사용할 수 있는 형식으로 저장되어 있습니다.

 

JVM이 시작될 때 이 파일을 순차적으로 읽는 게 아니라, OS의 메모리 매핑(mmap) 을 통해 프로세스 주소 공간에 직접 올립니다. 여기서 중요한 점은 이 매핑 영역이 읽기 전용(read-only) 이라는 사실입니다.

 

읽기 전용 페이지는 OS가 여러 프로세스 간에 물리 메모리를 공유할 수 있습니다. 같은 호스트에서 JVM 인스턴스 10개가 실행 중이라면, java.lang.StringInstanceKlass 메타데이터는 물리 메모리에 단 하나만 존재하고 10개의 프로세스가 같은 페이지를 참조합니다. 인스턴스당 메모리 절약이 이루어집니다.

 

기본 아카이브 위치

JDK 12부터 Oracle JDK/OpenJDK 배포본에는 빌드 타임에 미리 생성된 기본 아카이브가 포함되어 있습니다.

플랫폼 경로
Linux / macOS $JAVA_HOME/lib/[arch]/server/classes.jsa
Windows $JAVA_HOME/bin/server/classes.jsa

JVM은 기본적으로 이 파일을 자동으로 사용합니다(-Xshare:auto가 기본값). 별도 설정 없이도 CDS는 이미 동작 중입니다.

 

실제로 CDS가 쓰이는지 확인하기

java -Xlog:class+load=info -version 2>&1 | head -5

출력에서 source: shared objects file이 보이면 공유 아카이브에서 로드된 것입니다.

[0.003s][info][class,load] java.lang.Object source: shared objects file
[0.003s][info][class,load] java.lang.String source: shared objects file
[0.003s][info][class,load] java.io.Serializable source: shared objects file

 


3. CDS → AppCDS → Dynamic CDS: 진화 과정

기본 CDS는 JDK 핵심 클래스만 아카이브에 포함합니다. Spring Boot나 Hibernate 같은 프레임워크 클래스는 포함되지 않으므로, 실제 애플리케이션에서의 효과는 제한적입니다. 이 한계를 넘기 위해 AppCDS와 Dynamic CDS가 순차적으로 도입되었습니다.

 

기술 도입 버전 핵심 변화
CDS Java 5 JDK 핵심 클래스 아카이브
AppCDS Java 9 (상용) / Java 10 (오픈소스화) 앱/라이브러리 클래스 포함 가능
Dynamic CDS Java 13 종료 시 자동 아카이브 생성
AutoCreate Java 21 아카이브 없으면 자동 생성

 

캐싱 범위: CDS와 AppCDS는 dump 시점에 Loading과 Linking의 Verification까지 완료된 결과, 즉 Klass 메타데이터(상수 풀, 메서드 정보, 타입 정보 등)를 아카이브에 저장합니다. JVM 시작 시 이 메타데이터는 mmap으로 바로 프로세스 주소 공간에 매핑되므로, Klass를 새로 생성하는 과정이 생략됩니다. 다만 Linking의 마지막 단계인 Resolution(심볼릭 레퍼런스 → 실제 메모리 참조)은 여전히 런타임에 수행됩니다. 이 Resolution 단계까지 캐싱하는 것이 2편에서 다룰 AOT Cache(JEP 483)입니다.

 


4. AppCDS 적용하기: Static dump vs Dynamic dump

AppCDS를 사용하면 애플리케이션 클래스와 라이브러리 클래스까지 아카이브에 포함할 수 있습니다. 아카이브를 생성하는 방식은 두 가지입니다.

 

Static dump (JDK 10+)

구 방식으로 3단계가 필요하지만, JDK 기본 CDS 아카이브에 종속되지 않는다는 장점이 있습니다.

# 1단계: 어떤 클래스가 로드되는지 목록 추출
java -Xshare:off \
     -XX:DumpLoadedClassList=classes.lst \
     -jar app.jar

# 2단계: 목록을 기반으로 아카이브 생성 (앱을 실행하는 게 아니라 아카이브만 생성)
java -Xshare:dump \
     -XX:SharedClassListFile=classes.lst \
     -XX:SharedArchiveFile=app.jsa \
     -cp app.jar

# 3단계: 아카이브 적용해서 실행
java -Xshare:on \
     -XX:SharedArchiveFile=app.jsa \
     -jar app.jar

 

Dynamic dump (JDK 13+)

애플리케이션이 종료될 때 자동으로 아카이브를 생성합니다. 클래스 목록 추출 단계가 사라져 훨씬 간단합니다.

# 실행하면서 종료 시 자동으로 아카이브 생성
java -XX:ArchiveClassesAtExit=app.jsa \
     -jar app.jar

# 아카이브 적용해서 실행
java -XX:SharedArchiveFile=app.jsa \
     -jar app.jar

JDK 21부터는 AutoCreateSharedArchive 옵션으로 더 단순하게 쓸 수 있습니다. 아카이브가 없으면 생성하고, 있으면 재사용합니다.

java -XX:+AutoCreateSharedArchive \
     -XX:SharedArchiveFile=app.jsa \
     -jar app.jar

Dynamic dump의 중요한 제약: Dynamic dump로 생성된 아카이브는 JDK 기본 CDS 아카이브(classes.jsa)의 체크섬을 내부에 포함합니다. 런타임에 기본 아카이브가 없거나 JDK 버전이 바뀌어 체크섬이 달라지면 Dynamic dump 아카이브도 무효화됩니다. Static dump는 이 종속성이 없습니다.

 


5. Spring Boot에서 CDS 적용하기

Spring Boot 3.3부터 CDS 적용을 공식적으로 지원합니다. JVM 플래그는 "저장 메커니즘"을 제공하고, Spring 지원은 "언제 저장할지"를 제어합니다. 두 역할이 다릅니다.

 

왜 Spring 지원이 필요한가?

-XX:ArchiveClassesAtExit는 "JVM이 종료될 때 지금까지 로드된 클래스의 Klass 메타데이터를 파일에 저장하라"는 트리거입니다. 종료 시점이 와야 아카이브가 생성됩니다.

 

그런데 Spring Boot 앱을 그냥 실행하면 Tomcat이 포트를 바인딩하고 무한정 대기합니다. 종료 시점이 오지 않으니 아카이브도 생성되지 않고, CI/CD 파이프라인 자동화도 불가능합니다.

 

-Dspring.context.exit=onRefresh가 이 문제를 해결합니다. refresh()가 완료된 직후, 즉 ComponentScan → 조건 평가 → 빈 생성 → 의존성 주입까지 모두 끝난 시점에 Spring이 자동으로 JVM 종료를 트리거합니다.

JVM 시작
  → ① 클래스 로딩
  → ② @ComponentScan (리플렉션으로 클래스 탐색)
  → ③ @Conditional 평가 + BeanDefinition 등록
  → ④ 빈 인스턴스 생성 + 의존성 주입
  ↑ refresh() 완료 = onRefresh 시점
  → -XX:ArchiveClassesAtExit 발동 → Klass 메타데이터를 .jsa에 저장
  → 프로세스 종료
  - - - - - - - - - - - - - - - - (이 아래는 실행되지 않음)
  → SmartLifecycle 시작 (Tomcat 포트 바인딩, Kafka 컨슈머 등)
  → ApplicationReadyEvent 발행

 

여기서 중요한 점이 있습니다. .jsa에 저장되는 것은 Klass 메타데이터(클래스 구조, 메서드, 상수 풀)뿐입니다. 빈 인스턴스, BeanDefinition, ComponentScan 결과, @Conditional 평가 결과는 저장되지 않습니다.

.jsa에 저장되는 것:    ✓ Klass 메타데이터
저장되지 않는 것:      ✗ 빈 인스턴스
                       ✗ BeanDefinition (빈 등록 정보)
                       ✗ @ComponentScan 결과
                       ✗ @Conditional 평가 결과

따라서 다음 기동에서는 클래스 로딩(①)만 아카이브에서 재사용하고, ②③④는 처음부터 다시 수행합니다. ④까지 실행하고 .jsa를 만드는 이유는 빈 생성 과정에서도 클래스가 추가로 로드되기 때문입니다. 최대한 많은 클래스를 아카이브에 담기 위한 것이지, ②③④의 결과를 저장하기 위한 게 아닙니다.

 

onRefresh 이후 단계(Tomcat 포트 바인딩, DB 커넥션 풀 연결 등)에서 로드되는 클래스는 극히 일부입니다. 외부 리소스를 점유하는 부작용 없이 충분한 클래스를 확보할 수 있는 절충점입니다.

 

적용 방법

Fat JAR에 직접 적용하는 것은 권장하지 않습니다. Fat JAR는 Spring Boot 전용 클래스 로더를 통해 JAR 안에 중첩된 JAR에서 클래스를 로드하는 구조입니다. 이 중첩 JAR 방식은 실행 시점에 따라 클래스패스 일관성이 깨질 수 있습니다. Spring은 레이어를 파일 시스템에 직접 펼쳐놓는 Exploded JAR 방식을 권장합니다.

# 1단계: Fat JAR 빌드 후 레이어별로 추출 (Docker 캐시 효율을 위해 --layers 사용)
mvn -Dmaven.test.skip=true clean package
java -Djarmode=tools -jar target/app.jar extract --layers --destination app

# 2단계: 아카이브 생성 (refresh() 완료 후 자동 종료, 로드된 Klass를 .jsa에 저장)
java -XX:ArchiveClassesAtExit=./application.jsa \
     -Dspring.context.exit=onRefresh \
     -jar app/app.jar

# 3단계: 아카이브 적용해서 실행
java -XX:SharedArchiveFile=application.jsa \
     -jar app/app.jar

 

로그로 아카이브 적용 확인하기

java -XX:SharedArchiveFile=application.jsa \
     -Xlog:class+load=info:file=class-load.log \
     -jar app/app.jar

# 총 로드된 클래스 수 (로그 마지막 줄 통계 라인 포함될 수 있으므로 대략적인 수치)
wc -l class-load.log

# 공유 아카이브에서 로드된 클래스 수
grep -c 'source: shared' class-load.log

Spring Petclinic 기준으로 16,242개 중 약 14,391개(88%)가 아카이브에서 로드됩니다. 평균 시작 시간은 3.3초 → 1.9초로 42% 단축됩니다.

 

나머지 12%는 아카이브에 포함될 수 없는 클래스들입니다. 주요 원인은 다음과 같습니다.

제외 원인 설명
Hidden class JDK 15(JEP 371) 도입. 다른 클래스가 직접 참조할 수 없는 클래스. 람다 관련 내부 클래스가 대표적입니다.
Old class 구버전 Java를 대상으로 한 레거시 라이브러리의 클래스입니다.
제외된 클래스의 하위 클래스 부모 클래스가 제외되면 하위 클래스도 자동으로 제외됩니다.

 

Spring AOT와 함께 사용하기

CDS + onRefresh만으로도 의미 있는 개선이 됩니다. 하지만 CDS는 다음 기동에서 ①클래스 로딩 비용만 줄여줍니다. ②③④는 다음 기동에도 그대로 수행됩니다.

CDS + onRefresh 적용 후 기동:
  ① 클래스 로딩     → 아카이브에서 재사용 (Klass 생성 생략)
  ② ComponentScan   → 매번 새로 수행
  ③ 조건 평가/등록  → 매번 새로 수행
  ④ 빈 생성/DI      → 매번 새로 수행

 

Spring AOT는 ②③을 공략합니다. 리플렉션으로 하던 탐색·평가 작업을 빌드 타임(process-aot goal)에 미리 수행하고, 빈을 직접 등록하는 Java 코드를 생성합니다. 런타임에는 생성된 코드가 바로 실행되므로 ②③이 사라집니다.

CDS + onRefresh + Spring AOT 적용 후 기동:
  ① 클래스 로딩     → 아카이브에서 재사용 (Klass 생성 생략)
  ②③ 생략           → 빌드타임에 완료됨
  ④ 빈 생성/DI      → 매번 새로 수행

 

부가적으로, Spring AOT를 쓰면 ②③이 빌드타임으로 옮겨지면서 런타임에 로드되는 클래스 세트가 줄고 고정됩니다. 아카이브 생성 시점에 로드되는 클래스가 달라지므로, AOT 모드 전용 아카이브를 별도로 만들어야 합니다. 이것이 AOT 활성화 시 아카이브 생성 명령어에도 -Dspring.aot.enabled=true를 함께 지정하는 이유입니다.

pom.xmlprocess-aot goal을 추가합니다.

<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <executions>
    <execution>
      <id>process-aot</id>
      <goals><goal>process-aot</goal></goals>
    </execution>
  </executions>
</plugin>
# 아카이브 생성 (AOT 모드로 실행해서 AOT 전용 아카이브 생성)
java -Dspring.aot.enabled=true \
     -XX:ArchiveClassesAtExit=./application.jsa \
     -Dspring.context.exit=onRefresh \
     -jar app/app.jar

# 실행 (AOT 모드 + 아카이브 적용)
java -Dspring.aot.enabled=true \
     -XX:SharedArchiveFile=application.jsa \
     -jar app/app.jar

CDS + Spring AOT 조합 시 시작 시간이 약 54% 단축됩니다.

 

process-aot는 빌드 타임에 Spring 컨텍스트를 실제로 구성해보는 과정이라 빌드 시간이 수십 초 늘어납니다. 개발 환경에서는 비활성화하고, CI/CD 파이프라인의 이미지 빌드 단계에서만 활성화하는 것이 일반적입니다. 빌드 비용은 한 번이지만 이 이미지를 기반으로 뜨는 모든 인스턴스가 혜택을 받습니다.

주의: Spring AOT는 Spring 프로파일을 빌드 타임에 고정(freeze)합니다. 런타임에 프로파일을 동적으로 바꾸는 구성이 있다면 사전에 검토가 필요합니다.

 


6. Docker 환경에서 적용하기

컨테이너 환경에서는 이미지 빌드 단계에서 아카이브를 생성하고, 컨테이너 실행 시 재사용하는 패턴을 사용합니다. 훈련 오버헤드는 이미지 빌드 시 한 번만 지불하고, 이 이미지를 기반으로 하는 모든 파드가 혜택을 받습니다.

# 1스테이지: 빌드
# (.dockerignore로 .git, target/ 등 불필요한 파일을 제외하는 것을 권장합니다)
FROM bellsoft/liberica-runtime-container:jdk-21-cds-musl as builder
WORKDIR /home/app
COPY . /home/app
RUN ./mvnw -Dmaven.test.skip=true clean package

# 2스테이지: 레이어 추출
FROM bellsoft/liberica-runtime-container:jdk-21-cds-slim-musl as optimizer
WORKDIR /app
COPY --from=builder /home/app/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# 3스테이지: 최종 이미지 + 아카이브 생성
FROM bellsoft/liberica-runtime-container:jdk-21-cds-slim-musl
WORKDIR /app

# 레이어 순서: 자주 안 바뀌는 것 → 자주 바뀌는 것 (Docker 캐시 효율)
COPY --from=optimizer /app/extracted/dependencies/ ./
COPY --from=optimizer /app/extracted/spring-boot-loader/ ./
COPY --from=optimizer /app/extracted/snapshot-dependencies/ ./
COPY --from=optimizer /app/extracted/application/ ./

# 이미지 빌드 시점에 아카이브 생성
RUN java -Dspring.aot.enabled=true \
         -XX:ArchiveClassesAtExit=./application.jsa \
         -Dspring.context.exit=onRefresh \
         -jar /app/app.jar

# 실행 시 아카이브 사용
ENTRYPOINT ["java", "-Dspring.aot.enabled=true", \
            "-XX:SharedArchiveFile=application.jsa", \
            "-jar", "/app/app.jar"]

표준 컨테이너(5.3초) 대비 CDS + Spring AOT 컨테이너는 약 60% 빠르게 시작합니다.

 

Buildpack을 사용하는 경우

Paketo buildpack을 사용한다면 환경변수 두 개만 추가하면 됩니다.

pack build my-app \
  --env BP_JVM_VERSION=21 \
  --env BP_SPRING_AOT_ENABLED=true \
  --env BP_JVM_CDS_ENABLED=true

build.gradle.kts에서도 설정할 수 있습니다.

tasks.named<BootBuildImage>("bootBuildImage") {
    environment.putAll(mapOf(
        "BP_SPRING_AOT_ENABLED" to "true",
        "BP_JVM_CDS_ENABLED" to "true"
    ))
}

 

멀티 아키텍처 빌드 시 주의사항

.jsa는 특정 JDK 배포본에 묶여 있습니다. JDK 자체가 C++로 작성된 네이티브 코드이므로 아키텍처(x86_64, ARM64)마다 별도 바이너리로 배포되고, 아카이브 내부에는 해당 JDK 빌드 식별자와 체크섬이 포함됩니다. x86_64에서 생성한 .jsa를 ARM64에서 사용하면 체크섬 불일치로 아카이브가 무효화됩니다.

 

docker buildx로 멀티 플랫폼 이미지를 빌드할 때는 각 플랫폼에서 RUN 명령이 독립적으로 실행되므로, 아카이브도 플랫폼별로 자동으로 생성됩니다. 별도 처리가 필요하지 않습니다.

# x86_64와 ARM64 이미지를 동시에 빌드 — 각 플랫폼에서 .jsa가 별도 생성됨
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t my-app:latest .

 


7. 핵심 제약 조건과 디버깅

AppCDS를 적용할 때 가장 많이 실수하는 부분들입니다.

제약 내용
JDK 버전 일치 아카이브 생성(dump)과 실행(run)에 사용하는 JDK가 완전히 동일해야 합니다. 패치 버전(21.0.1 → 21.0.2)만 달라도 아카이브가 무효화됩니다.
OS 아키텍처 일치
JDK는 아키텍처별로 별도 바이너리로 배포됩니다. x86_64에서 생성한 아카이브는 ARM64에서 사용할 수 없습니다. 멀티 아키텍처 Docker 빌드 시 플랫폼별로 아카이브가 각각 생성되어야 합니다.
클래스패스 일치 아카이브 생성 시와 동일한 JAR 파일이 동일한 순서로 있어야 합니다.
JVM 옵션 일치 -XX:+UseCompressedOops, -XX:+UseCompressedClassPointers 등 메모리 레이아웃에 영향을 주는 옵션이 dump 시와 run 시에 일치해야 합니다.
Dynamic dump 종속성 Dynamic dump 아카이브는 JDK 기본 CDS 아카이브에 종속됩니다. JDK 버전이 바뀌면 기본 아카이브의 체크섬도 바뀌어 Dynamic dump 아카이브가 함께 무효화됩니다.
커스텀 클래스 로더 Quarkus fast-jar나 OSGi처럼 커스텀 클래스 로더를 사용하는 경우 AppCDS 적용에 제약이 있습니다.

 

아카이브 적용 실패 디버깅

# CDS 상태와 아카이브 매핑 정보 확인
java -Xlog:cds=info -jar app.jar

# 아카이브 매핑 실패 원인 상세 출력 (반드시 -Xshare:on과 함께)
java -XX:+PrintSharedArchiveAndExit -Xshare:on -jar app.jar

-Xshare:auto(기본값)는 아카이브 매핑 실패 시 경고만 출력하고 CDS 없이 계속 실행합니다. 프로덕션에서 CDS가 실제로 적용되고 있는지 검증하려면 -Xshare:on으로 명시적으로 실패를 잡아야 합니다. 단, -Xshare:on은 테스트 목적으로만 사용해야 합니다. 아카이브를 사용할 수 없는 상황에서 JVM 자체가 종료되므로 프로덕션에는 적용하지 않아야 합니다.

 


8. 성능 개선 수치 요약

아래 수치는 실측 데이터입니다. 애플리케이션 특성과 환경에 따라 결과는 달라질 수 있습니다. Kubernetes 행은 Quarkus 앱 기반 Red Hat 측정값이며, 로컬/Docker 행은 Spring Petclinic 기반 BellSoft 측정값으로 측정 환경이 서로 다릅니다.

환경 방법 시작 시간 단축
Kubernetes (2코어/256MB) JDK 기본 CDS만 7%
Kubernetes (2코어/256MB) AppCDS 47%
로컬 머신 AppCDS만 42%
로컬 머신 AppCDS + Spring AOT 54%
Docker 컨테이너 AppCDS + Spring AOT 60%

 

리소스가 제약된 환경일수록 효과가 두드러집니다. CPU가 제한된 환경에서 클래스 로딩이 전체 시작 시간에서 차지하는 비중이 더 크기 때문입니다.

 


9. 정리

CDS는 dump 시점에 미리 파싱·검증된 클래스의 내부 표현(Klass 메타데이터)을 공유 아카이브에 저장하고, JVM 시작 시 mmap으로 프로세스 주소 공간에 매핑해 재사용하는 기술입니다. 읽기 전용 메모리 매핑 덕분에 여러 JVM 프로세스가 같은 물리 페이지를 공유할 수 있어 메모리 절약 효과도 있습니다.

 

AppCDS는 이를 애플리케이션과 라이브러리 클래스까지 확장하고, Dynamic CDS는 클래스 목록 생성 단계를 제거해 사용성을 높였습니다. JDK 12부터는 기본 아카이브가 배포본에 포함되어 별도 설정 없이도 JDK 핵심 클래스에 대한 CDS가 자동으로 동작합니다.

 

그러나 CDS와 AppCDS가 처리하는 범위는 Loading과 Linking의 Verification까지입니다. Linking의 마지막 단계인 Resolution(심볼릭 레퍼런스 → 실제 메모리 참조)은 여전히 JVM 시작 시 수행됩니다. 이 Resolution 단계까지 캐싱하는 기술이 Java 24에서 도입된 AOT Cache(JEP 483) 이며, 2편에서 자세히 다룹니다.


참고 자료


다음 글: Project Leyden와 AOT Cache — Java가 콜드 스타트 문제를 해결하는 방법 (JEP 483 / 514 / 515 / 516)