- Published on
자바 Value Objects(JEP 401)가 바꾸는 것 — 아이덴티티를 버리면 얻는 것과 잃는 것
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 7월 31일 00시 45분 UTC, 22만 줄
- 아이덴티티가 없다는 것의 정확한 의미
- 성능은 어디서 나오나 — 플래트닝과 스칼라화
- records, 그리고 primitives와의 관계
- 무엇이 깨지나
- 라이브러리 작성자가 지금 할 일
- 프리뷰라는 단어의 무게 — 일정과 남은 조각
- 마치며 — 객체 모델을 바꾸는 변경은 조용히 오지 않는다
- 참고 자료
들어가며 — 7월 31일 00시 45분 UTC, 22만 줄
2026년 7월 31일 00시 45분 UTC, OpenJDK 메인라인에 커밋 하나가 들어갔습니다. 제목은 두 줄입니다.
8389219: Implement JEP 401: Value Objects (Preview)
8389220: Implement JEP 539: Strict Field Initialization in the JVM (Preview)
해당 커밋의 통계는 1,888개 파일에 208,011줄 추가, 13,161줄 삭제입니다. 원본 풀 리퀘스트 31120은 5월 11일에 David Simms가 열었고, OpenJDK의 Skara 봇이 관례대로 단일 커밋으로 스쿼시해 넣었습니다. 몇 시간 뒤 해커뉴스 스레드가 177포인트까지 올라갔습니다.
JEP 401 자체는 2020년 8월 13일에 만들어졌고, 그 뿌리인 Project Valhalla는 2014년에 시작됐습니다. Effort와 Duration 항목이 나란히 XL로 적혀 있는 JEP는 흔치 않습니다. 이 글을 쓰는 시점의 상태는 Targeted, Release 28이고, 문서 갱신일은 2026년 7월 30일입니다. 함께 들어간 JEP 539는 값 객체의 필드가 초기 생성 단계에서 반드시 초기화되도록 바이트코드 검증 수준에서 강제하는 의존 JEP로, 401 없이는 의미가 없고 401은 이것 없이 성립하지 않습니다.
여기서 흥분을 한 단계 낮춰 두는 편이 좋겠습니다. 이건 프리뷰 기능이고, 기본으로 꺼져 있으며, JDK 28의 GA는 아직 한참 남았습니다. 그럼에도 짚어 볼 가치가 있는 이유는 이것이 자바 객체 모델 자체를 건드리기 때문입니다. == 연산자와 synchronized 키워드의 의미가 바뀌는 변경은 자바 역사에서 손에 꼽습니다.
아이덴티티가 없다는 것의 정확한 의미
JEP가 드는 예가 명료합니다. LocalDate.of(1996, 1, 23)으로 만든 객체와, 거기에 30년을 더했다가 다시 뺀 객체는 같은 년·월·일을 담고 있습니다. equals는 true를 돌려줍니다. 그런데 ==는 false입니다. 두 객체가 서로 다른 아이덴티티를 갖기 때문입니다.
가변 객체에게 아이덴티티는 꼭 필요합니다. 지금은 상태가 같아도 나중에 달라질 수 있는 두 객체를 구별해 줘야 하니까요. 텍스트 편집기의 두 줄이 우연히 같은 문자열을 담고 있다고 해서 하나를 고칠 때 다른 하나가 같이 바뀌면 안 됩니다. 하지만 불변 데이터에서는 사정이 반대입니다. 1996-01-23을 나타내는 두 LocalDate 사이에는 실질적인 차이가 없습니다. 구별할 이유가 없는데 구별을 강제당하고 있는 상태입니다.
그 강제의 대가가 두 가지입니다. 하나는 매번 새 메모리를 할당해야 한다는 것이고, 다른 하나는 그 메모리를 쓸 때마다 포인터를 따라가야 한다는 것입니다. JEP는 LocalDate 배열의 메모리 배치를 그림으로 보여 주는데, 사실상 48비트짜리 데이터 다섯 개를 담기 위해 포인터 다섯 개와 힙 객체 다섯 개(각각 최소 64비트짜리 헤더 포함)를 쓰고 있습니다. 게다가 그 객체들이 서로 멀리 떨어져 있으면 순회할 때마다 캐시 라인이 갈립니다.
JDK 28 프리뷰에서는 플랫폼 API의 30개 클래스가 값 클래스로 선언됩니다. 박싱 타입 전부(Integer, Long, Float, Double, Byte, Short, Character, Boolean)와 Number, Record, Optional 계열 넷, java.time의 12개, java.time.chrono의 역법 날짜 넷입니다. 그래서 프리뷰를 켜면 이런 일이 벌어집니다.
// jshell --enable-preview
Integer x = 1996, y = 1996;
x == y // true (프리뷰 없이는 false — Integer 캐시 범위 밖이므로)
LocalDate d1 = LocalDate.of(1996, 1, 23);
LocalDate d3 = d1.plusYears(30).minusYears(30);
d1 == d3 // true
Objects.hasIdentity(d1) // false (신규 메서드)
String은 값 클래스가 아닙니다. API와 구현이 아이덴티티에 의존하는 지점이 남아 있기 때문이고, 그래서 문자열은 계속 아이덴티티 객체입니다.
==의 새 정의는 이렇습니다. 두 값 객체는 (1) 같은 값 클래스의 인스턴스이고, (2) 원시 타입 필드의 비트 패턴이 같고, (3) 참조 타입 필드가 ==를 재귀적으로 적용했을 때 구별 불가능하면 ==입니다. 아이덴티티 객체에 대한 ==의 동작은 자바 1.0 이후 그대로입니다.
주의할 점은 ==와 equals가 이제 값 객체에서도 다를 수 있다는 것입니다. JEP의 예는 문자열의 부분열을 원본 문자열과 두 좌표로 표현하는 값 클래스입니다. 같은 문자 시퀀스를 나타내지만 내부 상태가 다르므로 equals는 true, ==는 false입니다. 부동소수도 마찬가지입니다 — 서로 다른 비트 패턴의 NaN을 담은 두 값 객체는 equals로는 같고 ==로는 다릅니다. "값 객체니까 ==를 써도 된다"는 결론으로 건너뛰면 안 됩니다. 여전히 equals가 기본입니다.
성능은 어디서 나오나 — 플래트닝과 스칼라화
JEP는 최적화를 두 갈래로 나눠 설명합니다. 이 구분이 실무적으로 꽤 중요합니다.
참조 플래트닝은 힙에 있는 필드나 배열 원소에 적용됩니다. 어떤 객체의 필드가 값 객체를 가리킬 때, 포인터 대신 그 값 객체의 필드를 참조 안에 직접 인코딩합니다. Integer 배열이라면 각 원소가 널 플래그 1비트와 int 32비트를 담은 64비트 워드가 됩니다. 포인터 배열보다 확실히 작고, 무엇보다 추가 메모리 로드가 없습니다.
참조 스칼라화는 메서드 파라미터와 지역 변수에 적용됩니다. JIT가 LocalDate 참조 하나를 널 플래그, int, byte, byte 네 개의 지역 값으로 분해해 다룹니다. JEP의 의사 코드가 이 부분을 잘 보여 주는데, plusYears 메서드가 컴파일되고 나면 LocalDate 포인터를 아예 만지지 않습니다.
두 최적화의 결정적 차이는 크기 제한입니다. 플래트닝된 참조는 항상 원자적으로 읽고 써야 합니다. 그러지 않으면 찢길 수 있습니다 — 한 스레드가 쓴 앞 절반과 다른 스레드가 쓴 뒤 절반이 섞여, 존재한 적 없는 날짜가 관측될 수 있습니다. 흔한 하드웨어에서 원자적 접근이 보장되는 크기가 64비트이므로, 가변 필드에 담기는 플래트닝된 참조는 사실상 64비트로 제한됩니다.
그래서 LocalDateTime은 가변 필드에 플래트닝될 수 없습니다. 내부의 LocalDate와 LocalTime 필드, 각각의 널 플래그, 그리고 자신의 널 플래그까지 합치면 64비트를 넘기 때문입니다. JVM은 이 경우 조용히 포인터 배치를 선택합니다. 반면 값 클래스의 필드에는 이 제한이 없습니다 — 값 객체의 필드는 변경되는 것이 관측될 수 없으므로 찢길 여지 자체가 없기 때문입니다. 같은 LocalDateTime 필드라도, 담고 있는 클래스가 아이덴티티 클래스면 포인터가 되고 값 클래스면 플래트닝될 수 있습니다.
스칼라화에는 이 제한이 없습니다. 스택과 레지스터에 있는 값은 데이터 레이스에 노출되지 않기 때문입니다.
플래트닝과 스칼라화가 언제 되는지도 JEP가 명시합니다. 이건 언어 기능이 아니라 최적화이고 직접 제어할 수 없지만, 가능성을 높이는 방법은 있습니다.
Integer[] ints = { 1996, 2006, 1996, null, null }; // 플래트닝 가능
Object[] objs = { 1996, 2006, 1996, null, null }; // 불가능
record Box<T>(T field) { } // field는 소거되어 Object — 플래트닝 불가
var b = new Box<Integer>(1996); // 힙 포인터를 저장한다
변수가 특정 값 클래스 타입으로 선언돼 있어야 합니다. 상위 타입이나 제네릭 타입 파라미터로 선언된 자리는 소거 때문에 사실상 Object이고, 최적화 대상이 아닙니다. 이 지점이 남은 Valhalla 조각들(JEP 218, 원시 타입에 대한 제네릭)이 필요한 이유입니다.
한 가지 더 있습니다. 클래스를 컴파일할 때 필드·메서드 시그니처에 등장하는 값 클래스 이름이 LoadableDescriptors라는 새 클래스 파일 속성에 기록되고, JVM은 이걸 보고 해당 값 클래스를 충분히 일찍 로드해 플래트닝된 필드와 스칼라화된 파라미터를 준비합니다. 즉 기존 클래스가 값 클래스로 마이그레이션되면, 그것을 참조하는 코드는 재컴파일해야 최적의 성능이 납니다. 재컴파일하지 않아도 동작은 하지만 힙 할당이 남습니다.
records, 그리고 primitives와의 관계
레코드는 이미 final이고 필드도 전부 final이므로 값 클래스의 유력한 후보입니다. 문법은 그냥 겹칩니다.
value record Point(int x, int y) { }
Point p = new Point(17, 3);
Objects.hasIdentity(p); // false
new Point(17, 3) == p; // true
다만 모든 값 클래스가 레코드가 될 수 있는 것은 아닙니다. 레코드는 생성자 인자와 필드가 정확히 대응하는 투명한 클래스여야 하는데, 내부 표현을 다르게 잡아 메모리를 아끼는 클래스가 많기 때문입니다. JEP의 예는 유로와 센트를 long 하나로 저장하는 통화 클래스입니다 — 값 레코드는 될 수 없지만 값 클래스는 됩니다.
원시 타입과의 관계는 JEP의 Non-Goals에 못 박혀 있습니다. 원시 타입의 취급을 바꾸는 것은 목표가 아닙니다. 값 객체는 여러 면에서 원시 타입처럼 동작하지만 별개의 개념입니다. 자바 언어는 계속 두 종류의 데이터만 다룹니다 — 원시 타입과 객체 참조. C나 C#의 struct를 도입하는 것도 명시적 비목표입니다. C#의 값 타입은 인스턴스가 아이덴티티를 갖고 필드 변경이 가능해서, 대입·호출 시 복사 의미론을 세밀하게 규정해야 하고 그만큼 사용자 모델이 복잡해집니다. 자바는 그 저수준 결정을 JVM 구현자에게 맡기는 쪽을 택했습니다.
계층 구조도 정리해 두면 이렇습니다. 값 클래스는 인터페이스를 구현할 수 있고, sealed이 될 수 있으며, abstract으로 선언하면 확장 가능한 "값 호환" 상위 클래스가 됩니다. 값 클래스는 Object나 추상 값 클래스만 확장할 수 있고 아이덴티티 클래스는 확장할 수 없습니다. java.lang.Value 같은 공통 상위 클래스는 없습니다.
해커뉴스 토론에서 자주 언급된 "네 개의 버킷"(일반 객체 / 아이덴티티 없는 객체 / 원자적 값 / 찢김 허용 값) 프레이밍은 Valhalla 설계 노트에서 나온 것이지만, 이번에 들어간 것은 앞의 둘까지입니다. 찢김을 명시적으로 허용해 원자성 제약을 벗어나는 옵트아웃 문법은 이 PR에 포함되지 않았고, JEP도 "미래의 향상"으로 미뤄 두었습니다.
무엇이 깨지나
값 클래스로 바뀌면 아이덴티티에 의존하던 연산들이 동작을 바꾸거나 실패합니다. JEP 원문 기준으로 정리하면 이렇습니다.
| 연산 | 값 객체에서의 결과 |
|---|---|
== | 아이덴티티가 아니라 필드 값을 재귀적으로 비교. Integer 1996 두 개가 이제 == |
synchronized | 정적 타입이 값 클래스면 컴파일 오류, Object 타입을 거치면 런타임에 IdentityException |
wait / notify | 락을 잡을 수 없으므로 항상 IllegalMonitorStateException |
System.identityHashCode | 아이덴티티가 아니라 필드 값에서 해시를 계산 (이름만 레거시) |
finalize | GC가 절대 호출하지 않음. 오버라이드하면 javac가 identity 경고 |
java.lang.ref 계열, WeakHashMap | Reference 생성 시 IdentityException |
| 직렬화 | 값 레코드는 자동 동작. 그 외 값 클래스는 writeReplace/readResolve 없으면 InvalidClassException |
| 깊은 리플렉션으로 final 필드 변경 | 지원하지 않음. --enable-final-field-mutation을 줘도 불가 |
clone | 원본과 구별 불가능한 값 객체를 반환. x.clone() != x 기대는 무의미해짐 |
이 표에서 실무적으로 가장 아플 자리는 세 곳입니다.
첫째, 약한 참조 계열입니다 — java.lang.ref의 Reference 하위 클래스들과 WeakHashMap. 값 객체를 키로 쓰는 약한 캐시가 있다면 그대로 예외입니다. 다만 JDK 25부터 value-based 클래스에 대해 javac가 identity 경고를 내 왔고, JDK 28부터는 값 클래스에 대해서도 같은 경고를 냅니다. 경고를 보고 있었다면 이미 목록을 갖고 있는 셈입니다.
둘째, 직렬화입니다. 값 클래스는 엄격 초기화(strictly-initialized) 필드로 컴파일되고, 역직렬화는 생성자를 거치지 않고 필드를 채우는 방식이라 안전하게 초기화할 수 없습니다. 그래서 Serializable을 구현하는 비레코드 값 클래스는 대체 객체를 쓰도록 writeReplace와 readResolve를 직접 구현해야 합니다. 안 하면 런타임 InvalidClassException이고, javac가 serial 경고를 냅니다.
셋째, 깊은 리플렉션으로 final 필드를 건드리는 라이브러리입니다. 직렬화 프레임워크, ORM, 목 라이브러리, DI 컨테이너 중 일부가 이 방식을 씁니다. 값 객체에 대해서는 예외 없이 막힙니다 — 생성자를 통해야만 합니다. final 필드 변경 경로 자체가 좁아지는 흐름은 JDK 26의 final 필드 변경 경고 — JEP 500 편에서 이미 시작된 이야기이고, JEP 401은 그 끝을 보여 줍니다.
그리고 JEP의 Risks and Assumptions에 조용히 적힌 것 중 두 가지는 라이브러리 설계자가 기억할 만합니다. ==가 값 객체 트리를 재귀적으로 비교하므로 깊은 중첩에서는 시간이 오래 걸리거나 StackOverflowError가 날 수 있습니다. 그리고 ==와 identityHashCode가 비공개 필드 값을 간접적으로 노출할 수 있으므로, 민감한 데이터를 담는 클래스는 값 클래스로 만들지 않는 편이 안전합니다.
라이브러리 작성자가 지금 할 일
JDK 28은 아직 멀었지만, 지금 해 두면 나중에 공짜로 얻는 것들이 있습니다.
1. 후보를 골라 둡니다. 판단 기준은 두 개입니다 — 인스턴스가 불변인가(모든 인스턴스 필드가 final이고 표현하는 값이 시간에 따라 변하지 않는가), 그리고 서로 교체 가능한가(같은 값을 나타내는 두 인스턴스를 구별할 필요가 없는가). 필드가 없는 추상 클래스도 좋은 후보입니다 — 아이덴티티 요구사항을 하위 클래스에 강요할 이유가 없기 때문입니다. Number가 정확히 그렇게 추상 값 클래스가 됐습니다.
2. equals와 hashCode를 먼저 오버라이드합니다. 마이그레이션 전에 이 둘이 아이덴티티에 의존하지 않도록 만들어 두면, 값 클래스로 바뀔 때 동작이 변하지 않습니다. 오버라이드하지 않은 상태로 마이그레이션하면 상속된 구현의 의미가 바뀝니다.
3. public 생성자를 정리합니다. 클라이언트가 new로 "다른 모든 객체와 구별되는 인스턴스"를 만들어 락으로 쓰거나 == 비교의 표식으로 쓰고 있을 수 있습니다. JEP가 권하는 경로는 자바 9에서 Integer 생성자에 했던 것과 같습니다 — 생성자를 deprecate하고 팩토리 메서드로 유도합니다.
4. 동기화 대상을 감사합니다. 자신의 코드가 값 후보 클래스의 인스턴스를 락으로 쓰고 있지 않은지, 그리고 문서에서 클라이언트에게 그렇게 하도록 유도하고 있지 않은지 확인합니다. 자바 16부터 value-based 클래스에 대한 동기화 경고가 있었으므로, 빌드 경고를 켜 두었다면 목록이 이미 나와 있습니다.
5. 이진 호환성은 걱정 없습니다. 클래스가 final이거나 abstract이고 필드가 모두 final이라면, value 키워드를 붙이거나 떼는 것은 이진 호환 변경입니다. 소스·동작 호환성 리스크는 위의 목록이 전부입니다.
지금 실제로 만져 보려면 JDK 28 EA 빌드를 받아 프리뷰를 켜면 됩니다.
javac --release 28 --enable-preview Main.java
java --enable-preview Main
# 단일 파일 실행기
java --enable-preview Main.java
# jshell
jshell --enable-preview
프리뷰의 규칙 하나를 짚어 둘 필요가 있습니다. 플랫폼 API의 그 30개 클래스는 프리뷰를 켰을 때만 값 클래스입니다. 프리뷰 없이 컴파일하면 컴파일러가 기존 아이덴티티 버전의 LocalDate를 쓰고, 프리뷰를 켜면 값 버전을 씁니다. 프리뷰를 켠 상태에서 아이덴티티 버전을 쓸 방법은 없습니다. 즉 이 기능은 "일부만 켜기"가 불가능합니다.
프리뷰라는 단어의 무게 — 일정과 남은 조각
날짜를 정확히 해 두겠습니다. JDK 28 프로젝트 페이지에서 JEP 401의 targeting 리뷰는 2026년 7월 30일에 끝났고, JEP 문서의 상태는 그날 자로 Targeted / Release 28입니다. 병합은 그다음 날 새벽이었습니다. 같은 페이지에 JEP 539(리뷰 종료 7월 30일)와 JEP 535(Shenandoah 세대별 모드 기본화, 리뷰 종료 8월 3일)가 함께 올라와 있습니다.
한편 JDK 27은 이미 Rampdown Phase Two에 들어가 GA가 2026년 9월 15일로 잡혀 있습니다. 자바의 6개월 주기를 감안하면 JDK 28 GA는 2027년 3월입니다. 그리고 프리뷰 기능은 관례적으로 최소 한 번 이상의 릴리스를 프리뷰로 더 보내므로, --enable-preview 없이 값 클래스를 쓸 수 있는 시점은 그보다 뒤입니다. 몇 번의 프리뷰를 거칠지는 아직 정해지지 않았습니다 — 이 부분은 확인되지 않은 사항입니다.
그리고 이번에 들어간 것이 Valhalla의 전부가 아닙니다. JEP가 Future Work로 명시한 것만 두 개입니다.
- JEP 402, Enhanced Primitive Boxing — 박싱이 값 객체로 가벼워진 것을 언어 차원에서 활용합니다.
- JEP 218, Generics over Primitive Types(개정판) — 제네릭 클래스가 값 클래스로 파라미터화됐을 때 필드·배열·지역 변수 레이아웃을 특수화할 수 있게 합니다. 앞 절에서 본 "제네릭 필드는 플래트닝 안 됨" 제약을 푸는 조각입니다.
여기에 앞서 말한 찢김 옵트아웃, 널을 배제하는 레이아웃, 값 클래스의 자동 직렬화까지가 남아 있습니다. 그러니 "Valhalla가 끝났다"는 표현은 정확하지 않습니다. 12년 만에 첫 조각이 메인라인에 들어갔습니다.
마치며 — 객체 모델을 바꾸는 변경은 조용히 오지 않는다
정리하면 이렇습니다.
- 값 클래스는 아이덴티티를 포기하는 대가로 JVM에게 메모리 레이아웃의 자유를 줍니다.
==는 필드 값 비교가 되고,synchronized는 불가능해집니다. - 성능은 플래트닝(힙의 필드·배열)과 스칼라화(스택의 지역 변수·파라미터)에서 나옵니다. 앞의 것은 원자성 때문에 64비트라는 실질적 벽이 있고, 뒤의 것은 없습니다.
- 최적화를 받으려면 변수를 구체적인 값 클래스 타입으로 선언해야 합니다.
Object나 제네릭 타입 파라미터 자리는 대상이 아닙니다. 그리고 마이그레이션된 값 클래스를 쓰는 코드는 재컴파일해야 합니다. - 깨지는 것은
java.lang.ref계열,WeakHashMap, 비레코드 값 클래스의 직렬화, 깊은 리플렉션에 의한 final 필드 변경, 그리고 클라이언트의 동기화입니다. 자바 16 이후 누적된 identity 경고가 그 목록입니다. - 상태는 Targeted / JDK 28 프리뷰이고, JDK 28 GA는 2027년 3월 예정입니다. 기본으로 켜지는 시점은 그 이후입니다.
당장 프로덕션에 영향은 없습니다. 하지만 WeakHashMap에 LocalDate를 키로 넣은 코드가 어딘가에 있다면, 그 코드는 지금 이미 경고를 내고 있을 것입니다. 지금이 그 경고를 읽을 때입니다.
참고 자료
- JEP 401: Value Objects (Preview) — 원문
- 8389219: Implement JEP 401 — 병합 커밋 cc278db (2026-07-31)
- openjdk/jdk PR #31120 — 구현 풀 리퀘스트
- 해커뉴스 토론 (item 49119063, 2026-07-31)
- JDK 28 프로젝트 페이지 — targeting 리뷰 일정
- JDK 27 프로젝트 페이지 — GA 2026-09-15
- Project Valhalla — Value Classes and Objects
- Valhalla 설계 노트 — State of Valhalla
- Try Out JEP 401 Value Classes and Objects — inside.java
- JDK 26의 final 필드 변경 경고 — JEP 500 (관련 글)