JDK-8381924 기여기: 한 줄짜리 include guard 수정으로 만난 CDS 람다 레거시
JDK-8381924를 고쳤다. 변경사항 자체는 특별할 것이 없다. 다만 수정하면서 “LambdaProxyClassDictionary는 대체 뭘 하는 클래스인가” 하는 궁금증이 생겼고, 파고들어 보니 이 클래스 자체가 언젠가 사라질 운명이라는 사실까지 알게 되었다.
이 글에는 이 클래스가 왜 사라질 예정인지 알아본 내용을 정리해 보겠다.
발단: include guard 이름이 파일명과 달랐다
문제의 파일은 src/hotspot/share/cds/lambdaProxyClassDictionary.hpp였다. 헤더 상단의 include guard는 다음과 같이 적혀 있었다.
#ifndef SHARE_CDS_LAMBDAPROXYCLASSINFO_HPP
#define SHARE_CDS_LAMBDAPROXYCLASSINFO_HPP
그런데 파일명은 lambdaProxyClassInfo.hpp가 아니라 lambdaProxyClassDictionary.hpp다.
수정은 다음과 같이, 이름을 파일 경로에 맞게 바꾸는 것이 전부였다.
#ifndef SHARE_CDS_LAMBDAPROXYCLASSDICTIONARY_HPP
#define SHARE_CDS_LAMBDAPROXYCLASSDICTIONARY_HPP
참고로 include guard는 헤더가 여러 번 #include 되었을 때 내용이 중복 선언되는 것을 막는 장치다. HotSpot은 가드 이름을 <디렉터리>_<파일명>_HPP 형태로 파일 경로와 일치시키는 관례를 따르고 있는데, 이 관례가 지켜지지 않으면 다른 헤더가 우연히 같은 이름의 가드를 쓸 때 충돌이 일어날 수 있다. 실제로 현재 충돌이 발생한 건 아니었지만, 규약에 어긋나 있었다. 참고로 이 파일이 최초 생성된 21년도 부터 이렇게 작성이 되어있었다.
변경 자체는 쉬웠다. 빌드를 돌려 확인하고, 패치를 올리면 끝나는 수준이다.
조금 더 나아가기
쉬운 변경사항이지만, 나는 이 기회에 내부 구조를 공부해 두는 데 의의를 두고 있다.
오늘 수정한 LambdaProxyClassDictionary 는 대체 무엇을 하는 클래스일까?
이 파일을 분석하다보니 헤더 상단 주석 한 줄이 눈에 들어왔다.
legacy optimization for lambdas before JEP 483. May be removed in the future.
JEP 483 은 무엇이며, 왜 이 클래스는 곧 사라질 예정인걸까?
먼저 이 클래스에 대해서 더 자세히 정리해보고 JEP 483 이 무엇인지 정리해보겠다.
LambdaProxyClassDictionary는 무엇을 하는가
LambdaProxyClassDictionary는 JEP 483 이전의 CDS 아카이브에서 lambda proxy class를 저장·조회하기 위한 레거시 최적화 자료구조다.
여기서 CDS는 Class Data Sharing의 약자로, JVM이 클래스 메타데이터를 아카이브 파일에 미리 저장해 두고, 이후 JVM 시작 시 해당 아카이브를 메모리 매핑 방식으로 읽어 여러 프로세스가 공유할 수 있게 해주는 기능이다. 덕분에 startup 시간과 메모리 사용량을 줄일 수 있다. 이후 AppCDS로 확장되면서 애플리케이션 클래스까지 아카이빙 대상에 포함되었고, 람다 프록시 클래스 역시 이 범주에 들어오게 되었다. CDS와 AppCDS에 대한 보다 자세한 소개는 dev.java의 CDS & AppCDS 문서를 참고하자.
배경: lambda proxy class와 LambdaMetafactory
Java의 람다 표현식은 컴파일 시 invokedynamic 명령으로 남는다. 이 명령은 실행될 때마다 호출되지만, 각 호출 지점에 처음 도달했을 때 한 번만 bootstrap 단계에서 java.lang.invoke.LambdaMetafactory.metafactory가 호출되어 java.util.ResourceBundle$Control$$Lambda/0x80000001d 같은 proxy class가 런타임에 생성되고, 그 결과가 CallSite에 연결된다. 이후 같은 지점에 다시 도달하면 이미 연결된 CallSite로 곧장 dispatch되므로 metafactory는 재호출되지 않는다. 문제는 바로 이 첫 도달 시점의 비용 이다. proxy class의 바이트코드를 만들고, 검증(verification)하고, 클래스를 로드하고, CallSite를 연결하는 작업이 한꺼번에 일어난다. 람다가 수천 개 쓰이는 최신 프레임워크에서는 이런 첫 호출 지연이 집단적으로 누적되어 startup 시간을 크게 늘린다.
레거시 최적화의 아이디어
레거시 최적화의 전략은 단순하다. CDS 덤프 시점에 생성된 proxy class들을 아카이브에 함께 저장해 두고, 프로덕션 실행 시 같은 키가 나오면 아카이브에서 꺼내 재사용하는 것이다. 일종의 디스크 캐시인 셈이다. 단, 캐시에서 클래스 바이트코드는 돌려받지만 call site resolution(람다 호출 지점과 실제 실행될 proxy class를 연결하는 작업)의 상당 부분은 여전히 런타임에 일어난다. 이 잔여 비용이 JEP 483 이후 사라지는 포인트가 된다.
JEP 483 소개
JEP 483 (Ahead-of-Time Class Loading & Linking)은 JDK 24에 포함된 기능으로, HotSpot JVM 시작 시 애플리케이션 클래스를 이미 로드·링크된 상태로 즉시 사용 가능하게 만들어 startup 시간을 줄이는 것을 목표로 한다.
기본 아이디어는 간단하다. JVM이 시작할 때 수행하는 JAR 스캔, 클래스 파싱, 로딩, 링크, 심볼릭 레퍼런스 resolution 같은 작업은 애플리케이션이 실행될 때마다 거의 똑같이 반복된다. 그렇다면 이 작업의 결과를 한 번 기록해 두고 이후 실행에서 재사용하면 되지 않을까? JEP 483은 이 결과를 AOT 캐시(AOT cache) 에 저장하여 재사용 할 수 있도록 하였다.
사실 JEP 483이 완전히 새로 등장한 기능은 아니다. CDS는 클래스 메타데이터를 read-only 아카이브로 제공하였고, 여기서 로딩/링크 단계까지 미리 처리할 수 있도록 확장된 것이다.
효과는 어떨까? JEP 문서에 따르면 Stream API를 쓰는 작은 HelloStream 프로그램이 JDK 23의 0.031초에서 JDK 24 + AOT 캐시로 0.018초(약 42% 개선)로 줄었고, Spring PetClinic은 약 21,000개의 클래스를 로드하는데 4.486초에서 2.604초로, 역시 약 42% 개선되었다고 한다.
레거시 최적화 방식과 JEP 483 방식의 비교
람다 관점에서 보면, JEP 483은 람다 처리 방식을 “조회” 에서 “직접 로딩” 으로 바꾼 것이다.
레거시 방식에서는 프록시 클래스 바이트코드만 아카이브에 있고, 호출 지점과 그 프록시 클래스를 잇는 연결은 런타임에 다시 해야 했다. JEP 483는 이 연결(call site) 자체를 AOT cache assembly 단계에서 완전히 resolve한다. 이미 연결까지 끝난 상태로 아카이브에 들어가므로, 런타임에는 “이 키에 맞는 클래스가 있는지 사전(Dictionary)에서 찾는” 동작 자체가 필요 없다. 바로 이 지점이 LambdaProxyClassDictionary 를 레거시로 밀어낸 결정적인 차이다.
한계: 왜 아직 남아 있는가
그렇다면 이 클래스는 왜 아직 소스 트리에 남아 있는가. 삭제하고 기본적으로 AOT 캐시를 사용하도록 하면 되지 않을까?
아쉽게도 JEP 483은 아직 기본 활성화 기능이 아니다. JEP 483의 AOT 캐시는 JVM이 알아서 켜주는 기능이 아니라, 사용자가 직접 training run을 돌려 캐시를 만들고 실행 시 캐시 파일을 지정해야 동작한다.
이러한 과정이 필요하기 때문에 아직 대부분의 실행 환경은 여전히 AOT 캐시 없이 사용하고 있다.
Java의 AOT 기능은 점점 개선되어가고 있는 단계이기 때문에, 추후에 이러한 과정이 더 간략화되면 LambdaProxyClassDictionary도 언젠가는 소스 트리에서 삭제될 것이다.
덧붙임: CDS도 사실 자동이 아니다
글을 쓰면서 한 가지 생각이 들었다. CDS도 결국 학습 데이터를 모아두는건데, 요즘과 같은 컨테이너 환경에서는 이 효과를 받을 수 있는걸까?
확인 해보니 컨테이너 환경에서 CDS는 자동으로 적용되지 않는다고 한다.
CDS 아카이브든, AOT Cache 든 사용하려면 먼저 한 번 “Training Run” — 즉, 애플리케이션을 실제로 실행해서 어떤 클래스들이 로드되는지 기록하는 과정 — 을 거쳐야 한다. 그런데 컨테이너는 기본적으로 휘발성이라, 런타임에 아카이브를 만들어봐야 컨테이너가 재시작되면 사라진다.
따라서 컨테이너를 만든다면 사전 학습된 .jsa 파일을 컨테이너에 추가될 수 있도록 구성을 해줘야 한다고 한다.
주의할 점은 CDS 아카이브가 생성 시점의 JDK 버전, OS, 클래스패스와 정확히 일치하는 환경에서만 동작한다 고 한다. 따라서 로컬(Mac/Windows)에서 만든 아카이브를 리눅스 컨테이너에 복사해 넣는 식은 통하지 않고, 최종 이미지와 동일한 환경 (보통은 Dockerfile의 멀티스테이지 빌드 안) 에서 생성해야 한다고 한다.
마무리
이번 기여 자체는 한 줄 수정이고, 빌드 관례에 맞게 이름을 정리한 것 이상도 이하도 아니다. 하지만 이 기회에 수정한 파일이 어떤 역할을 하는지, 그리고 앞으로 어떤 변화가 일어나게 될 지 알아 볼 수 있었다.
이 글을 정리하면서 개인적으로 운영 중인 서비스에 한 번 적용해봐야겠다는 생각이 들었다.
최근 자바는 AOT 를 위해 많은 시도를 하는 것으로 알고 있다. 이러한 자바의 변화를 응원한다.