10년의 기다림: Project Valhalla가 JDK 28에서 완성하는 것
10년의 기다림: Project Valhalla가 JDK 28에서 완성하는 것
Java는 왜 10년이 지나서야 “int처럼 작동하는 객체”를 만들 수 있게 됐는가—그리고 그 답이 JVM의 근본 설계를 어떻게 바꾸는가.
도입
Java의 강점은 오랫동안 역설이었다. 객체지향 추상화 덕분에 복잡한 도메인 모델을 명확하게 표현할 수 있지만, 그 표현력에는 언제나 성능 대가가 따랐다. 정수 하나를 int로 선언하면 JVM은 4바이트를 스택에 직접 적재한다. 그런데 Integer 객체로 감싸는 순간 헤더 메타데이터가 붙고, 힙 어딘가에 배치되며, GC가 추적해야 할 참조 하나가 더 생긴다. 금융 계산, 좌표 처리, 데이터 분석처럼 수천만 개의 소형 데이터를 다루는 도메인에서 이 간극은 성능 병목으로 직결됐다.
이른바 “포인터 전쟁”이다. 수억 개의 객체가 힙 위에 흩어진 채 서로를 가리키는 구조에서는 CPU 캐시가 무력화된다. 메모리 접근 패턴이 불연속적이니 하드웨어 프리페처가 예측을 실패하고, GC는 살아있는 객체 그래프를 순회하느라 멈춘다. C나 Rust에서는 구조체 배열이 메모리에 연속적으로 배치되는 것이 기본값인데, Java에서는 그것이 불가능했다.
Project Valhalla는 바로 이 근본적 비효율을 언어 레벨에서 해소하려는 시도다. 2014년 Brian Goetz가 공개 메일링 리스트에 초안 문서를 올린 이래 약 12년, 그리고 JDK 28에서 프리뷰 기능으로 처음 선적됐다. Hacker News에 올라온 「Project Valhalla, Explained」 포스트는 게시 당일 656점을 기록하며 Java 커뮤니티의 뜨거운 반응을 끌어냈다. “드디어”라는 한 단어가 댓글에서 반복됐다.
Value Classes의 해부: 프리미티브처럼 작동하는 객체
기존 Java 객체 모델의 구조적 한계
Java의 객체는 모두 참조 타입이다. 배열 Color[] colors를 선언하면 실제로 힙 위에 저장되는 것은 Color 인스턴스들이 아니라 그것을 가리키는 포인터들의 배열이다. 각 Color 인스턴스는 힙의 임의 위치에 분산 배치되며, 그 앞에는 1216바이트의 객체 헤더가 붙는다. RGBA 색상 하나를 표현하는 데 실제로 필요한 데이터는 4바이트임에도, 객체로 감싸는 순간 실제 메모리 사용량은 45배로 불어난다.
캐시 미스는 여기서 온다. CPU는 64바이트 단위의 캐시 라인을 가져온다. 배열 순회 시 CPU는 “다음 원소도 근처에 있겠지”라고 예측하고 미리 가져오는 프리페칭을 한다. 그런데 배열에 포인터만 있다면, 포인터를 읽고 나서야 실제 데이터 위치를 알 수 있다. 수백만 번의 무작위 메모리 접근이 발생하고, CPU는 데이터를 기다리며 사이클을 낭비한다.
JVM의 탈출 분석(Escape Analysis)은 이 문제를 부분적으로 우회할 수 있다. 객체가 메서드 밖으로 “탈출”하지 않는다고 JIT이 확신할 수 있을 때는 힙 할당을 스킵하고 스택에 스칼라 분해한다. 그러나 이 최적화는 본질적으로 불안정하다. 코드 변경 하나, 라이브러리 업데이트 하나가 JIT의 판단을 바꿀 수 있고, 개발자는 어느 코드가 최적화됐는지 알 수 없다. 성능이 JIT 블랙박스에 달려 있다는 불확실성은 고성능 시스템 개발의 본질적 장벽이었다.
Value Classes: 언어 레벨 해법
JDK 28의 Value Classes는 value 수정자를 클래스 선언에 추가하는 것으로 시작한다.
value class Color {
private final int red;
private final int green;
private final int blue;
private final int alpha;
}
이 선언 하나로 여러 가지가 달라진다.
동일성(identity) 없음. 일반 객체는 == 연산이 참조 동일성을 검사한다. 같은 내용을 가진 두 객체라도 힙 위 다른 주소에 있다면 ==는 false다. Value Class에서 ==는 치환 가능성(substitutability)을 검사한다. 모든 필드가 같다면 ==가 true를 반환한다. 이는 int나 double 같은 프리미티브의 동작과 동일하다.
필드 묵시적 final. Value Class의 필드는 선언하지 않아도 final이다. 인스턴스 생성 이후 상태를 변경할 수 없다. 불변성이 언어 레벨에서 보장된다.
클래스 자체가 final. Value Class는 상속될 수 없다. 서브클래스가 추가 필드를 가지거나 동작을 재정의하면 “동일한 값이면 동일하다”는 치환 가능성 보장이 깨지기 때문이다.
동기화 불가. synchronized(valueObject)는 컴파일 오류다. 객체 헤더에 담긴 모니터 정보가 없고, 같은 값을 가진 두 인스턴스가 논리적으로 동일하기 때문에 특정 인스턴스에 락을 거는 것 자체가 의미를 잃는다.
JDK 28에서는 여전히 null 허용. 현재 버전에서 Value Class 참조는 null이 될 수 있다. null 제한 타입(null-restricted types)은 후속 JDK에서 도입될 예정이다.
스칼라화와 힙 평탄화: 두 가지 메모리 최적화
Value Class가 성능을 끌어내는 방법은 두 가지다.
스칼라화(Scalarization). Value 객체를 필드들로 분해해 힙 할당 자체를 제거한다. Color(255, 0, 0, 255)를 처리할 때 JVM은 힙에 객체를 생성하지 않고, 네 개의 int 값을 직접 레지스터나 스택에 올려 메서드 호출을 통해 전달한다. 탈출 분석과 달리 이 스칼라화는 선언에 의해 보장되므로 예측 가능하다.
힙 평탄화(Heap Flattening). 배열 Color[]에 Value Class 인스턴스들이 담길 때, JVM은 각 원소를 별도 힙 객체로 할당하지 않고 배열 내부에 직접 데이터를 순차적으로 배치한다. RGBA 각각 1바이트씩 32비트로 압축 인코딩된 색상 값들이 메모리 위에 연속적으로 늘어선다. 순회 시 CPU 프리페처가 완벽하게 작동하고, 캐시 미스가 대폭 줄어든다. 포인터 간접 참조(pointer indirection)가 사라지는 것이다.
이 두 최적화가 결합되면 데이터 처리 집약적 워크로드에서 이론적으로 C 수준에 근접하는 메모리 효율을 달성할 수 있다. 실제 성능 벤치마크는 JDK 28 채택 이후 커뮤니티에서 본격적으로 나오겠지만, JEP 문서가 제시하는 기준 시나리오들은 의미 있는 개선을 예상한다.
Q World에서 L World까지: 10년 여정의 핵심 전환점
2014~2018: Q World 프로토타입의 시대
Project Valhalla가 공식화됐을 때, 첫 번째 접근법은 값 타입을 위한 별도 타입 시스템을 만드는 것이었다. 내부적으로 “Q World”라 불린 이 설계에서 값 타입은 Q 디스크립터로 표시되는 완전히 별개의 종류였다. 참조 타입과 값 타입은 서로 다른 규칙을 따르고, JVM 바이트코드 레벨에서도 구분됐다.
Q World 프로토타입은 상당히 진전됐다. Valhalla 전용 JDK 빌드에서 직접 성능을 측정할 수 있었고, 수치는 인상적이었다. 그러나 근본적인 문제가 있었다. 기존 Java 코드와의 호환성이 극도로 복잡했다. 제네릭 컬렉션인 List<Point>처럼 참조 타입을 가정하는 코드에 값 타입을 넣으려면 새로운 브릿지 메커니즘이 필요했다. 제네릭 전문화(generic specialization)와 엮이며 복잡도가 폭발했다. 라이브러리 에코시스템 전체가 두 세계 중 하나를 선택해야 하는 상황이었다.
2019~2022: L World 돌파구
“L World”는 근본적 관점 전환이었다. 이름은 Java 객체 참조 디스크립터 L에서 왔다. 핵심 통찰은 이것이다: 값 타입을 참조 타입과 별개로 취급하는 대신, 값 타입도 동일한 참조 타입 캐리어(carrier)를 통해 흐를 수 있게 하면 어떨까?
이 설계에서 Value Class는 여전히 참조 타입의 일종이다. null이 될 수 있고, 기존 List<Color> 같은 제네릭 컨테이너에 박싱 없이 담길 수 있도록 설계됐다. JVM은 해당 타입이 값 의미론(value semantics)을 가진다는 것을 알고 있으므로, 내부 표현을 최적화(스칼라화, 힙 평탄화)할 수 있다. 외부 인터페이스는 기존 코드와 호환되는 참조 타입이지만, 내부 동작은 프리미티브처럼 효율적이다.
L World 전환은 구현 복잡도를 급격히 낮췄다. 기존 바이트코드 생성 규칙이 대부분 유지됐고, 제네릭 시스템을 처음부터 다시 설계할 필요가 없었다. 에코시스템 호환성이 훨씬 현실적인 범위 내로 들어왔다.
이름의 변천사: 언어 설계가 얼마나 어려운가
기능의 이름 자체도 여러 번 바뀌었다. “value types” → “inline classes” → “value classes”로 진화했다. 각 이름 변경은 단순한 마케팅이 아니었다. 기능의 의미론이 구체화되면서 이전 이름이 잘못된 기대를 만들어낼 수 있다는 판단에서였다. “inline”은 너무 구현 세부사항을 암시했고, 최종 “value”는 사용자가 신경 쓸 핵심 속성인 “값 의미론”을 직접 표현한다.
프리뷰로 선적하는 이유
JDK 28에서 Value Classes는 --enable-preview 플래그 없이는 활성화되지 않는다. 커뮤니티에서 “또 기다려야 하나”라는 반응이 나오는 이유다. 그러나 이 선택에는 명확한 근거가 있다.
Java는 하위 호환성을 신성한 계약으로 취급한다. “Write Once, Run Anywhere”의 현실적 의미는 1990년대 코드가 JDK 28에서도 돌아간다는 것이다. 이런 제약 하에서 파괴적 변경이 될 수 있는 기능을 일단 프리뷰로 내놓고, 실제 생태계 피드백을 반영해 확정하는 것이 JEP 12에서 정의한 표준 절차다. 1,816개 파일에 걸친 197,000줄의 변경이 실제로 광범위한 라이브러리 생태계와 어떻게 상호작용하는지를 프리뷰 단계에서 검증하는 것이다.
안정화 일정으로는 JDK 29가 LTS(Long-Term Support) 릴리스가 되는 2027년 9월이 현실적 목표로 거론된다. 기업 환경에서 대부분의 의사결정이 LTS 기준으로 이루어지는 점을 감안하면, 실질적 도입 시점은 그 시점이 될 가능성이 높다.
실무 코드는 무엇이 달라지는가
성능이 바뀌는 코드 패턴
Value Classes의 가장 직접적 수혜는 소형 데이터 집약적 코드다. 2D/3D 게임 엔진의 Vector2, Vector3, Point 같은 좌표 클래스들, 금융 계산의 Money, Rate, Quantity 같은 도메인 값 객체들, 데이터 처리 파이프라인의 중간 표현들이 해당한다. 수백만 개의 인스턴스를 생성하고 순회하는 워크로드에서 힙 평탄화의 효과가 두드러진다.
JDK 자체의 Integer, Long, Double 같은 박싱 타입들도 Value Class로 마이그레이션되는 것이 목표다. 이 변경이 완료되면 기존 코드의 오토박싱/언박싱 오버헤드가 자동으로 줄어드는 효과가 있다.
파괴적 변경: 무엇이 깨지는가
Value Classes는 기존 관습 중 일부를 무효화한다.
동기화. synchronized(someValueObject) 패턴은 컴파일 오류다. Value 객체는 모니터를 가지지 않기 때문이다. 이 패턴에 의존하는 코드는 ReentrantLock이나 다른 동기화 메커니즘으로 이전해야 한다.
객체 동일성 검사. ==로 참조 동일성을 검사하던 코드가 예상치 못한 동작을 할 수 있다. Value Class에서 ==는 내용을 비교하므로, 동일성(identity)을 의도한 코드는 재검토가 필요하다.
리플렉션과 직렬화. 동일성이 없는 객체를 다루는 일부 리플렉션 기반 프레임워크나 직렬화 라이브러리는 Value Class 인스턴스를 예상대로 처리하지 못할 수 있다.
Kotlin data class, Scala case class, C# struct과의 비교
기술 커뮤니티의 흔한 질문은 “Kotlin의 data class가 이미 이걸 하지 않나?”다. 핵심 차이는 메모리 레이아웃에 있다. Kotlin data class는 여전히 JVM 참조 타입으로 컴파일되므로 힙 할당과 포인터 간접 참조가 사라지지 않는다. data class는 equals()/hashCode()/copy() 등을 자동 생성하는 언어 편의 기능이지, JVM 수준의 메모리 최적화가 아니다.
C#의 struct는 기능적으로 Value Classes에 더 가깝다. 스택 할당과 값 의미론을 지원하고, 배열에서 연속 메모리 레이아웃을 가진다. 다만 C#의 struct는 인터페이스 구현 시 박싱이 발생하는 오래된 제약이 있으며, Rust의 소유권 모델처럼 복사 비용이 명시적이지 않다는 트레이드오프가 있다.
Java Value Classes는 기존 참조 타입 캐리어와의 호환성을 유지하면서 메모리 효율을 얻는다는 점에서 독자적인 접근법이다. null이 허용된다는 점도 C# struct와 구별된다(null 제한 타입은 후속 JDK에서 추가될 예정).
JDK 28에 없는 것들
현재 프리뷰에서 빠진 기능들도 명확히 인식해야 한다. null 제한 타입(Color! 같은 표기법으로 논의 중)은 미포함이다. 제네릭 전문화(specialized generics, List<int> 같은 프리미티브 제네릭)도 없다. 128비트 인코딩 최적화나 벡터화 힌트도 아직이다. JDK 28은 Value Class 선언 문법과 기본 스칼라화/힙 평탄화를 확립하는 기반 릴리스다.
결론
Project Valhalla가 해결하려는 문제는 Java 언어 설계의 30년 된 긴장이다. 표현력 있는 객체지향과 프리미티브 수준의 성능 효율 사이에서 선택해야 했던 상황을, Value Classes는 “둘 다”의 방향으로 해소한다. 10년이 걸린 것은 단순히 구현이 어려웠기 때문이 아니라, L World 전환 같은 근본적 재사고가 필요했기 때문이다.
JDK 28 프리뷰는 완성이 아니라 시작점이다. null 제한 타입, 제네릭 전문화, 성능 검증까지 포함한 전체 그림은 2027년 LTS 사이클을 통해 기업 환경에 도달할 것이다. 그러나 방향은 확정됐다. 코드를 프리미티브처럼 동작하게 만들겠다는 약속이, 12년 만에 빌드 가능한 형태로 존재한다.
금융, 게임, 데이터 파이프라인, 머신러닝 추론 등 고성능 연산을 Java로 처리하는 팀에게 이 변화는 단순한 언어 기능 추가가 아니다. Java를 계속 사용하는 이유를 재정의하는 이정표다.
출처
- JVM Weekly, “Project Valhalla, Explained: How a Decade of Work Arrives in JDK 28” (2026-06-19): https://www.jvm-weekly.com/p/project-valhalla-explained-how-a
- Hacker News 토론 스레드, 656점 (2026-06-19): https://news.ycombinator.com/
- OpenJDK Project Valhalla 공식 페이지: https://openjdk.org/projects/valhalla/
- JEP 401 (Value Classes and Objects): https://openjdk.org/jeps/401
- Brian Goetz, “State of Valhalla” 시리즈 문서: https://openjdk.org/projects/valhalla/design-notes/