본문으로 건너뛰기

© 2026 Molayo

HN분석2026. 06. 19. 18:39

Project Valhalla 설명: 10년간의 노력이 JDK 28에 도입되는 방식

요약

JDK 28에 도입될 예정인 Project Valhalla의 핵심인 JEP 401(값 클래스 및 객체)에 대해 심층 분석합니다. 클래스처럼 코딩하면서도 기본 타입(primitive)처럼 효율적으로 동작하는 JVM의 성능 최적화 방향을 다룹니다.

핵심 포인트

  • JEP 401이 JDK 28을 목표로 OpenJDK 메인 저장소에 통합 예정
  • 클래스의 추상화와 기본 타입의 메모리 효율성을 동시에 제공하는 것이 목표
  • 포인터 간접 참조를 줄여 대규모 객체 처리 시 성능 향상 기대
  • Valhalla는 단일 기능이 아닌 장기적인 JVM 진화의 첫 단계

Project Valhalla 설명: 10년간의 노력이 JDK 28에 도입되는 방식 - JVM Weekly vol. 180

새로운 JVM Weekly가 도착했습니다... 그리고 드디어 JDK에 Valhalla가 포함됨에 따라 라그나로크(Ragnarok)가 다가오는 듯합니다. 하지만 상황은 다소... 미묘합니다.

6월 15일, Oracle 엔지니어인 Lois Foltan은 업계의 상당수가 믿음을 그만두었던 사실을 **확인(confirmed)**했습니다: **JEP 401: Value Classes and Objects (값 클래스 및 객체)**가 메인 OpenJDK 저장소에 통합될 예정이며, JDK 28을 목표로 하고 있습니다.

이 변화는 매우 방대하여, 남은 커미터(committers)들에게 통합 기간 동안 더 큰 커밋(commit)을 자제해 달라고 요청했을 정도입니다. 풀 리퀘스트 (pull request) 하나만으로도 1,816개 파일에 걸쳐 19만 7천 줄 이상의 코드가 추가됩니다.

하지만 샴페인을 터뜨리기 전에 알아두어야 할 점이 있습니다: 이것은 프리뷰 (preview) 기능이며, 기본적으로 비활성화되어 있고, Brian Goetz가 서둘러 모두를 진정시키며 말했듯 "Valhalla의 첫 번째 부분일 뿐"입니다. Goetz는 "그들은 절대 출시하지 않을 거야"라고 말하던 사람들이 이제는 "하지만 가장 중요한 부분은 출시하지 않았어"라고 매끄럽게 말을 바꿀 것이라는 훌륭한 관찰을 덧붙였습니다 (그리고 커뮤니티에서는 이 프로젝트가 출시되기보다 우리가 북유럽 신화의 사후세계인 Valhalla에 먼저 가게 될 것이라는 농담이 수년 동안 돌고 있습니다).

당신은 스스로의 안티 팬을 만들어낼 자격이 있군요.

따라서 지금이 전체 이야기를 들려주기에 좋은 시점입니다. 이번 호는 여러분이 이전에 Valhalla에 관한 작업을 전혀 팔로우하지 않았다는 가정하에 작성된 하나의 거대한 심층 분석(deep-dive)입니다: 2014년의 문제부터, (상당수가 쓰레기통으로 들어간) 아이디어의 진화 과정을 거쳐, JDK 28에서 우리가 정확히 무엇을 손에 넣게 될지에 이르기까지 말이죠. 커피 한 잔을 준비하세요. 저는 정확히 이 순간을 위해 이 에디션을 오랫동안 아껴두었습니다.

1. 서론 - 이것이 도대체 무엇에 관한 것인가

Valhalla가 시작부터 내걸어온 슬로건은 다음과 같습니다: “클래스처럼 코딩하고, int처럼 동작한다(codes like a class, works like an int).” 이 한 문장은 프로젝트의 핵심을 관통합니다. 우리는 메서드, 생성자 검증, 합리적인 필드 이름을 갖춘 일반적이고 읽기 쉬운 클래스를 작성하고 싶지만, 동시에 JVM이 이를 기본 타입 (primitive)만큼 효율적으로 처리할 수 있기를 원합니다.

이것이 왜 문제인지 이해하려면 Java의 근간으로 돌아가야 합니다. 이 언어에서는 8개의 기본 타입 (int, long, double, boolean 등)을 제외하면 **모든 것이 참조 타입 (reference type)**입니다. Point p = new Point(1, 2)라고 작성할 때, 변수 p는 점(point) 그 자체가 아닙니다. 변수 p는 포인터(pointer), 즉 물품 보관증과 같습니다. 힙 (heap) 어딘가에 객체가 놓여 있고, 당신은 그 주소가 적힌 종이 조각을 들고 있는 것입니다. 필드를 읽으려고 할 때마다 JVM은 “물품 보관소로 가서” 포인터를 통한 점프(포인터 간접 참조, pointer indirection)를 수행해야 합니다.

단일 객체라면 아무런 문제가 되지 않습니다. 문제는 규모가 커질 때 발생합니다. 힙에 있는 모든 객체는 자신만의 **헤더 (header)**를 가집니다 (JVM이 해당 객체의 타입을 알고 있는지, 누군가 이를 동기화하고 있는지 등을 알 수 있게 해주는 십여 바이트의 메타데이터입니다). 참고로, 이것이 바로 최근 Project Lilliput이 객체 헤더 크기를 줄이기 위해 다루고 있는 문제입니다. 하지만 헤더 크기가 전부가 아닙니다. 모든 객체는 **할당 (allocated)**되어야 하며, 나중에 **가비지 컬렉션 (garbage collected)**되어야 합니다. 그리고 객체들이 힙 전체에 흩어져 있기 때문에, 백만 개의 Point로 구성된 배열은 실제로는 창고 전체에 흩어진 백만 개의 상자를 가리키는 백만 장의 보관증과 같습니다.

Brian Goetz는 그의 “State of Valhalla” 문서에서 이러한 메모리 레이아웃을 “fluffy”(부풀어 오른, 비대해진)라고 부릅니다. 우리가 꿈꾸는 것은 데이터가 나란히 놓여 있는 조밀한 (dense) 레이아웃입니다.

왜 밀도(density)가 중요할까요? 그것은 하드웨어가 Java보다 더 빠르게 변했기 때문입니다. 1995년에는 메모리 접근 비용이 CPU 연산 비용과 거의 비슷했습니다. 오늘날 CPU는 메인 메모리보다 두 자릿수(two orders of magnitude)나 더 빠르며, 이 전체 격차는 캐시(cache)에 의해 메워집니다. 프로세서는 캐시 라인 (cache lines)(보통 64바이트)라고 불리는 덩어리 단위로 메모리를 읽습니다. 데이터가 조밀하고 순서대로 놓여 있다면, 이러한 하나의 덩어리가 한 번에 엄청난 양의 유용한 값들을 가져옵니다. 만약 우리가 포인터(pointer)를 따라 여기저기 건너뛴다면, 매 접근마다 *캐시 미스 (cache miss)*가 발생할 위험이 있으며, 이는 캐시 히트(hit)보다 백 배 더 느려질 수 있습니다. 이것이 바로 *참조 국부성 (locality of reference)*이며, 이 모든 게임의 진정한 핵심입니다.

“하지만 JVM에는 탈출 분석 (escape analysis)이 있잖아요,”라고 누군가 날카롭게 말할 수도 있습니다. 맞습니다. 가상 머신은 어떤 객체가 코드의 로컬 파편을 벗어나 결코 “탈출(escape)”하지 않는다는 것을 인식할 수 있으며, 그럴 경우 객체를 아예 할당하지 않을 수도 있습니다. 프로그래머의 관점에서는 객체가 존재하는 것처럼 보이지만, 실제로는 그 필드들이 일반 변수나 CPU 레지스터로 분산됩니다. 최선의 경우, 할당 비용과 이후 가비지 컬렉터 (garbage collector)에 의한 정리 비용은 사실상 제로로 떨어집니다.

문제는 이 최적화가 예측 불가능하고 취약하다는 점입니다. 이 방식은 JIT 컴파일러가 객체의 전체 흐름을 높은 신뢰도로 추적할 수 있을 때만 작동합니다. 하지만 객체가 다른 클래스의 필드에 담기거나, 배열에 저장되거나, 더 복잡한 메서드로 전달되거나, 혹은 JIT가 분석할 수 있는 코드의 경계를 벗어나는 순간, 이 모든 트릭은 작동을 멈춥니다. 소스 코드는 동일하게 유지되지만, 성능 동작은 극적으로 변할 수 있습니다.

이것이 바로 숙련된 JVM 프로그래머들이 탈출 분석 (escape analysis)을 프로젝트의 기반이 아닌, 하나의 유용한 보너스로 취급하는 정확한 이유입니다. 만약 애플리케이션의 성능이 특정 JIT 버전이 이 최적화를 적용할 수 있는지 여부에 달려 있다면, 예측하기 어려운 성능 저하 (regression)의 함정에 빠지기 매우 쉽습니다. 사소한 리팩터링 (refactor), JDK 업데이트, 또는 코드 구조의 변경만으로도 객체들이 다시 힙 (heap)으로 돌아갈 수 있으며, 할당 (allocation) 및 가비지 컬렉터 (garbage-collector) 작업 비용이 온전히 다시 발생하게 됩니다.

그렇다면 남은 선택지는 무차별 대입 (brute-force) 방식뿐입니다. 즉, 객체를 포기하고 데이터를 수동으로 인코딩하는 것입니다. Color 클래스 대신, 세 개의 바이트 r, g, b를 보유하는 식입니다. 이는 단순히 학술적인 예시가 아닙니다. 이 접근 방식은 메모리의 모든 바이트와 모든 할당이 중요한 게임 엔진, 그래픽 라이브러리, 이미지 처리 시스템, 데이터베이스, 분석 엔진, 그리고 HPC (고성능 컴퓨팅) 코드에서 수년 동안 사용되어 왔습니다. 문제는 속도를 얻는 대신 안전성과 가독성을 희생해야 한다는 점입니다. 우리는 이름, 프라이빗 상태 (private state), 검증 (validation), 그리고 메서드 (methods)를 잃게 됩니다. JEP 401은 간단한 예를 보여줍니다. "원시" (raw) 컬러 바이트를 다루는 개발자가 이를 RGB 대신 BGR로 잘못 해석하여 빨간색과 파란색을 뒤바꿈으로써 전체 이미지를 조용히 손상시킬 수 있습니다. 클래스였다면 이를 허용하지 않았을 것입니다. 하지만 단순한 int라면? 물론 허용될 것입니다.

그리고 바로 이 이분법, 즉 편리한 클래스냐, 아니면 빠른 원시 타입 (primitives)이냐를 없애려고 노력하는 것이 바로 Valhalla입니다.

2. 시작 - 2014년, "여섯 명의 박사", 그리고 다섯 개의 프로토타입

공식적으로, Project Valhalla는 2014년에 시작되었습니다. James Gosling은 당시 이를 "하나의 매듭으로 묶인 여섯 명의 박사"라고 묘사했는데, 이는 결코 과장이 아니었습니다. 흥미롭게도 이 아이디어는 프로젝트 자체보다 더 오래되었습니다. Java의 창시자들은 언어의 첫 번째 버전만큼이나 일찍 값 타입 (value types)을 원했지만, 1995년 당시에는 문제가 너무 어려웠기 때문에 포기했습니다.

목표는 야심 차게 설정되었습니다. 바로 **프로그래밍 모델과 현대 하드웨어의 성능 특성 사이의 정렬 (alignment)**을 복구하는 것이었습니다. 다시 말해, 프로그래머가 기본 타입 (primitives)처럼 메모리 상에서 평탄하고 조밀하게(flat and dense) 배치되면서도, 일반 클래스처럼 보이고 동작하는 자신만의 타입을 선언할 수 있도록 하는 것입니다.

말은 쉽지만 실행은 어려웠습니다. 이후 몇 년 동안 팀은 문제의 서로 다른 측면을 탐구하는 **다섯 가지의 서로 다른 프로토타입 (prototypes)**을 구축했습니다. 그리고 여기서 이야기의 가장 흥미로운 부분이 시작됩니다. 왜냐하면 Valhalla의 현재 형태를 제대로 이해하려면, 그 과정에서 얼마나 많은 아이디어가 사라졌는지를 보아야 하기 때문입니다.

초기 프로토타입들은 현재 우리가 **“Q World”**라고 부르는 방향으로 진행되었습니다. 이는 새로운 값 타입 (value types)이 객체 (objects)와는 근본적으로 다른 존재이며, 기본 타입 (primitives)과 정확히 마찬가지로 별도의 타입 디스크립터 (type descriptors), 별도의 바이트코드 (bytecodes), 그리고 별도의 최상위 타입 (top types)을 가져야 한다고 가정했습니다. 논리적으로 들립니다. 만약 int처럼 동작해야 한다면, int처럼 표현되게 하면 되니까요. 문제는 이러한 분리가 JVM 타입 시스템 전체에 과도한 복잡성을 유발했다는 점입니다. 모든 것을 두 가지 변형으로 처리해야만 했습니다.

돌파구는 **“L World”**라고 명명된 프로토타입(대략 2019년경)과 함께 찾아왔습니다. 이 이름은 값 타입이 객체 참조와 동일한 “L 캐리어 (L carrier)”(JVM이 일반 참조에 사용하는 것과 동일한 L 디스크립터)를 공유하기 시작했다는 사실에서 유래되었습니다. 팀은 이러한 통합이 너무 어려울 것이라고 예상했으나, 놀랍게도 큰 타협 없이 성공했으며 부수적으로 이전 단계의 수많은 문제들을 해결했습니다.

L World는 이후의 모든 것을 형성한 또 하나의 근본적인 “아하 (aha)” 모멘트를 만들어냈습니다. 그것은 바로 언어 모델 (language model)과 JVM 모델 (JVM model)이 100% 일치할 필요는 없다는 사실입니다. L World는 가상 머신 (virtual machine)을 위한 올바른 모델이지만, 이를 *번역 대상 (translation target)*으로 취급하여 프로그래머에게는 언어 차원에서 더 편리한 것을 제공할 수 있습니다. 이러한 계층의 분리가 프로젝트 나머지 부분의 핵심임이 밝혀졌습니다.

그 시기에 작업을 **두 단계 (two phases)**로 나누려는 계획이 구체화되었습니다. 첫 번째는 값 클래스 (value classes, 당시에는 다른 이름으로 불렸으며 이에 대해서는 곧 자세히 설명하겠습니다)이고, 그 다음에 *특수화된 제네릭 (specialized generics)*을 도입하는 것입니다. 제네릭에 대해서는 별도의 더 긴 논문이 될 것이므로 섹션 6에서 다시 다루겠습니다.

3. 아이디어의 진화 - 명칭의 롤러코스터

만약 Valhalla에 대해 읽으려다 모순되는 용어들의 벽에 부딪힌 적이 있다면, 그것은 여러분의 잘못이 아닙니다. 여기서 명칭은 여러 번 변경되었으며, 이는 단순히 겉모습만 바뀐 것이 아니었습니다. 각 명칭의 변경 뒤에는 모델의 변화가 있었습니다. 이 과정이 이 기능이 어떻게 설계되었는지를 보여주는 가장 좋은 예시이므로, 그 과정을 따라가 보겠습니다.

1단계: 값 타입 (value types): 가장 초기 용어입니다. 이것들이 정확히 무엇이어야 하는지 아직 명확하지 않았기 때문에 모호했습니다.

2단계: 인라인 클래스 (inline classes): 2019~2020년경에 오늘날까지 그 본질이 유지되고 있는 구분이 정립되었습니다. 즉, 클래스를 식별 클래스 (identity classes) (식별성을 가진 것, 즉 우리가 지금까지 알고 있었던 모든 것)와 새로운 인라인 클래스 (inline classes) (식별성이 없는 것)로 나누는 것입니다. 이때 “클래스처럼 코딩하고, int처럼 동작한다”라는 슬로건이 만들어졌으며, 기본적인 제약 조건이 설정되었습니다. 인라인 클래스는 기본적으로 final이며, 필드 또한 final이고, 해당 객체에 대해 synchronize를 할 수 없습니다.

3단계: “원시 클래스 (primitive classes)”와 이중 투영 모델 (two-projection model). 여기서부터 흥미로워지는데, 왜냐하면 이것이 바로 상당 부분 축소된 아이디어이기 때문입니다. 2021년 “State of Valhalla” 문서에서 Valhalla는 세 가지를 약속했습니다: 값 객체 (value objects), 원시 클래스 (primitive classes), 그리고 *특수화된 제네릭 (specialized generics)*입니다. “원시 클래스”의 아이디어는 단일 타입이 **두 가지 투영 (two projections)**을 갖는 것이었습니다: 값 변형 (value variant, 평탄하고(flat), 절대 null이 아니며, 원시 타입처럼 동작함)과 참조 변형 (reference variant, null을 허용하는 박스(box) 형태)입니다. 여러 반복 과정을 거치며 이는 Point.val/Point.ref로 작성되었고, 나중에는 Point! 및 Point? 구문을 실험하기도 했습니다.

그 모델은 강력했지만, 동시에 정신적으로 부담스러웠습니다 (mentally heavy). 프로그래머는 매일 동일한 타입의 두 가지 형태를 동시에 다뤄야 했고, 그 사이에서 변환이 언제 발생하는지를 이해해야 했습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 HN AI Posts의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0