이번 포스팅에서는 JEP 401 Value Objects에 대해서 정리하고자 한다. 자바에서 a == b는 지난 30년간 한 가지 뜻이었다. “두 변수가 같은 객체를 가리키는가”, 즉 참조가 같은가. 그런데 10년 넘게 이어진 프로젝트 발할라(Project Valhalla)의 첫 결과물인 JEP 401이 JDK 28에 프리뷰로 들어오면서, 이 규칙에 예외가 생겼다. 정체성(identity)을 버린 새로운 종류의 객체, 곧 value object에서는 ==가 참조가 아니라 필드 값이 같은지를 비교한다. 발할라가 무려 197,000줄의 코드를 들여 노린 건 결국 성능인데, 그 성능을 얻기 위해 객체의 정체성을 포기했고 그 대가로 ==의 의미가 바뀐 것이다. 이 글에서는 value class가 무엇인지, ==가 어떻게 달라지는지, value class가 지켜야 하는 규칙과 성능 이점, 그리고 지금 직접 돌려보는 법까지 JDK 28 프리뷰를 기준으로 짚는다.
Value Object가 뭐길래 — 정체성이 없는 객체
Value object는 정체성(identity)이 없는 객체다. value 제어자를 붙여 선언한 클래스가 value class이고, 그 인스턴스가 value object다. 일반(identity) 객체가 “메모리 어딘가에 있는 바로 그 하나”로 구별된다면, value object는 그런 개별 정체성 없이 오직 자신이 담은 필드 값으로만 정의된다. 같은 값을 담으면 사실상 같은 것으로 취급된다.
문법은 간단하다. 클래스나 레코드 앞에 value를 붙인다.
// value record — record이면서 value class
value record Point(int x, int y) {}
// 일반 value class로도 선언 가능
value class Money {
private final String currency;
private final long amount;
Money(String currency, long amount) {
this.currency = currency;
this.amount = amount;
}
}Javavalue record Point(int x, int y)처럼 레코드에 value를 붙이면 record의 간결함과 value의 정체성 제거를 함께 얻는다. 핵심은 value class의 인스턴스 필드가 암묵적으로 final이 된다는 점이다. 한 번 만들어진 value object는 내부 상태를 바꿀 수 없고, 그래서 “이 객체가 어디에 있는 그 객체인지”를 따질 이유 자체가 사라진다. 정체성을 버렸기 때문에 JVM은 이 객체를 훨씬 자유롭게 다룰 수 있게 되는데, 그게 뒤에서 볼 성능 이점의 출발점이다.
왜 하필 지금 — 10년 프로젝트가 JDK 28에 착륙
지금 이 이야기가 나오는 이유는 명확하다. JEP 401이 2026년 8월 JDK 28에 정식 프리뷰로 통합됐기 때문이다. 프로젝트 발할라는 2014년경 시작돼 “코드는 클래스처럼, 동작은 int처럼”이라는 목표를 10년 넘게 다듬어 왔고, JEP 401은 그 첫 조각이 실제 JDK에 들어온 사건이다.
발할라가 오래 걸린 건 이 변화가 자바의 객체 모델 자체를 건드리기 때문이다. InfoQ의 정리에 따르면 JEP 401은 “객체 모델에 상당한 변화를 준다”고 스스로 인정하면서도, 그로 인한 혼란은 “드물고 관리 가능한 수준”일 것이라 본다. 실제로 이번 프리뷰에는 자바 표준 라이브러리의 상당 부분이 함께 딸려 온다. Integer 같은 원시 타입 래퍼와 LocalDate 같은 값 기반 클래스들이 프리뷰에서 value class로 마이그레이션된다. 오래전부터 “이 클래스들은 ==로 비교하지 말라”고 권고돼 온 바로 그 클래스들이, 이제 언어 차원에서 정체성을 내려놓는 것이다.
프리뷰인 만큼 기본은 꺼져 있다. 컴파일과 실행 양쪽에서 --enable-preview를 켜야 하고, 프리뷰로 컴파일한 코드는 프리뷰를 켠 런타임에서만 돈다. 아직 실험 단계지만, 발할라가 자바 언어에 남길 흔적의 방향을 처음으로 코드로 확인할 수 있게 된 셈이다.
==의 의미가 바뀐다 — 참조에서 상태로
가장 눈에 띄는 변화가 이것이다. value object끼리 ==로 비교하면, 참조가 아니라 두 객체의 필드 값이 모두 같은지를 검사한다. 정체성이 없으니 “같은 객체냐”를 물을 수 없고, JVM이 물을 수 있는 건 “같은 값을 담았냐”뿐이며, 그게 정확히 ==가 하는 일이 된다.
jshell로 직접 확인하면 이렇다.
jshell> value record Point(int x, int y) {}
| created record Point
jshell> Point p = new Point(17, 3)
p ==> Point[x=17, y=3]
jshell> new Point(17, 3) == p
$12 ==> trueJava
새로 만든 Point(17, 3)은 p와 다른 인스턴스지만, 필드 값이 같으므로 ==가 true를 돌려준다. 일반 클래스였다면 서로 다른 객체이므로 false가 나왔을 자리다. 이것이 value object에서 ==가 “상태 동등성(statewise equivalence)”으로 바뀐다는 말의 실체다.
다만 여기서 오해하면 안 되는 지점이 있다. JEP 401은 ==를 equals의 대체품으로 만들지 않는다. value object의 ==가 필드를 비교하게 됐다고 해서 equals를 버려도 된다는 뜻이 아니다. 객체의 내부 상태가 그 객체가 “의미하는 값”과 항상 일치하는 건 아니기 때문이다. 예컨대 캐시된 파생 필드를 품은 value class나 -0.0·NaN 같은 부동소수점의 특수 값이 얽히면, 필드가 비트 단위로 같은지와 논리적으로 같은지가 갈릴 수 있다. 그래서 공식 문서와 발할라 팀은 여전히 “비교는 equals로 하라”는 기존 조언을 유지한다. ==의 변화는 “정체성이 사라졌다”는 사실의 자연스러운 귀결이지, equals를 몰아내려는 기능이 아니다.
value class가 지켜야 하는 규칙
정체성을 버린 대가로 value class에는 몇 가지 제약이 붙는다. 인스턴스 필드는 암묵적으로 final이고, 인스턴스 메서드는 synchronized일 수 없으며, 생성 중인 인스턴스는 모든 필드가 채워지기 전엔 외부에 노출될 수 없다. 정체성에 기대던 관행들이 value object에서는 성립하지 않기 때문이다.
하나씩 보면 이렇다. 첫째, 모든 인스턴스 필드가 자동으로 final이라 value object는 불변이다. 값을 바꾸려면 새 객체를 만들어야 한다. 둘째, 동기화가 막힌다. value object는 개별 정체성이 없어 “이 객체를 잠금으로 쓴다”는 개념이 성립하지 않으므로, value class의 인스턴스 메서드에 synchronized를 붙일 수 없고 value object를 모니터(lock)로 사용할 수도 없다. 이는 Virtual Threads 실전 패턴에서 다룬 “락을 줄이고 불변으로 설계하라”는 흐름과도 맞닿는다. 셋째, 생성자는 부분적으로 만들어진 value object를 밖으로 새어 나가게 할 수 없다. 모든 필드가 확정된 뒤에야 인스턴스가 관찰 가능해진다.
이 제약들은 불편이 아니라 정체성 제거의 조건이다. 필드가 고정돼 있고 잠금 대상이 아니며 항상 완성된 상태로만 보이기 때문에, JVM은 이 객체를 “값”처럼 마음대로 복제하고 펼칠 수 있다.
일반 클래스와 value class의 차이를 한 표로 정리하면 이렇다.
| 구분 | identity class (일반 클래스) | value class |
|---|---|---|
| 정체성 | 있음 (개별 객체로 구별) | 없음 (필드 값으로만 정의) |
| 인스턴스 필드 | 가변 가능 | 암묵적 final (불변) |
== 비교 | 참조 — 같은 객체인가 | 상태 — 필드 값이 같은가 |
| 동기화·락 | 모니터로 사용 가능 | synchronized·락 대상 불가 |
| JVM 표현 | 힙 할당 + 참조 | 평탄화·스칼라화 가능 |
| 대표 예 | 대부분의 클래스, 일반 record | value record, 프리뷰의 Integer·LocalDate |
표의 마지막 줄이 이번 프리뷰의 실무적 함의를 압축한다. 늘 쓰던 Integer·LocalDate가 value class로 바뀌면서, 이들을 ==로 비교하던 코드가 프리뷰 모드에서는 다르게 동작할 수 있다.
성능은 왜 좋아지나 — 정체성을 버리면 JVM이 평탄화한다
발할라가 정체성을 버린 진짜 이유는 성능이다. 정체성이 없으면 JVM은 value object를 힙의 개별 객체로 박스(box)에 담지 않아도 된다. 필드를 감싼 컨테이너 안에 그대로 펼쳐(flatten) 넣거나, 레지스터에 흩어(scalarize) 담을 수 있다. 참조 추적과 별도 할당이 사라지면 캐시 지역성이 좋아지고 GC 부담도 준다.

지금까지 자바에서 작은 값을 클래스로 감싸면 그 대가가 컸다. Point 하나가 힙에 별도로 할당되고, 배열이나 다른 객체는 그 Point를 참조로만 들고 있어 실제 데이터는 메모리 여기저기 흩어졌다. value object는 정체성이 없어 “바로 그 객체”를 가리킬 필요가 없으므로, JVM이 데이터를 담는 쪽에 직접 인라인할 자유를 얻는다. 이는 객체 헤더를 줄여 메모리를 아낀 Java 25 Compact Object Header와 방향이 같다. 한쪽은 헤더를 줄이고, 다른 한쪽은 헤더가 필요한 정체성 자체를 없앤다. 발할라의 표현을 빌리면 목표는 “코드는 클래스처럼 짜되 런타임은 원시 타입처럼 도는 것”이고, JEP 401은 그 평탄화를 언어가 안전하게 허용하도록 정체성이라는 걸림돌을 치운 첫걸음이다. 다만 이번 프리뷰는 문법·의미 변화가 중심이고, 실제 힙 평탄화의 성능 이득은 후속 JEP에서 더 본격화된다.
지금 직접 돌려보기 — EA JDK와 –enable-preview
프리뷰인 만큼 정식 JDK에는 아직 없다. 대신 발할라 전용 얼리액세스(EA) 빌드를 jdk.java.net/valhalla에서 받아, 컴파일과 실행 양쪽에 --enable-preview를 붙이면 지금 바로 확인할 수 있다. jshell로 몇 줄 쳐 보는 게 가장 빠르다.
# 발할라 EA JDK로 프리뷰 기능을 켜서 실행
$ ./jdk-valhalla/bin/java --enable-preview PointTest.java
# 소스 파일 컴파일 시에도 동일하게
$ ./jdk-valhalla/bin/javac --release 28 --enable-preview PointTest.javaShellScript위처럼 --enable-preview를 컴파일·실행에 모두 넣어야 한다. 한쪽만 켜면 거부되고, 프리뷰로 컴파일한 클래스 파일은 반드시 프리뷰를 켠 런타임에서만 로드된다. 이는 프리뷰 기능이 다음 릴리스에서 바뀔 수 있어 프로덕션 유입을 막으려는 안전장치다. 표준 라이브러리도 프리뷰에서 함께 바뀌므로, Integer나 LocalDate 같은 값 기반 클래스를 ==로 비교하던 코드가 있다면 이 모드에서 동작이 어떻게 달라지는지 확인해 두면 좋다. 프로덕션에 올리는 건 정식화 이후의 일이지만, 발할라가 그리는 자바의 방향을 지금 손으로 만져 볼 수 있다는 점만으로도 시도할 가치가 있다.
직접 만들어 돌려본 결과 — value와 record는 뭐가 다른가
정체성이 있고 없고의 차이는 말보다 코드로 보는 게 빠르다. value record와 같은 필드를 가진 일반 record를 나란히 두고, JDK 28 EA에서 --enable-preview로 실제로 돌려 봤다.
value record Point(int x, int y) {} // value class — 정체성 없음
record Pixel(int x, int y) {} // 일반 record — 정체성 있음
static void lockOn(Object o) { synchronized (o) {} } // 라이브러리가 Object를 락으로 쓴다고 가정
var p = new Point(17, 3);
var q = new Point(17, 3);
var a = new Pixel(17, 3);
var b = new Pixel(17, 3);
System.out.println(p == q); // value: 같은 값 → ?
System.out.println(a == b); // record: 다른 인스턴스 → ?
System.out.println(a.equals(b)); // record: 값 비교
lockOn(p); // value object를 락으로 넘기면 ?JavaPoint는 value record, Pixel은 같은 필드를 가진 일반 record다. 둘 다 (17, 3)을 두 번 만들어 ==로 비교하고, 마지막엔 value object를 Object로 받는 lockOn에 넘겨 락으로 잠가 봤다. javac·java 양쪽에 --enable-preview를 붙여 실행한 결과는 아래와 같다.

결과가 세 가지 차이를 한 번에 보여준다. value record인 Point는 두 인스턴스의 필드가 같으니 ==가 true, 반면 일반 record인 Pixel은 서로 다른 객체라 ==가 false이고 equals만 true다. 정체성 유무가 ==의 답을 가른 것이다. 그리고 마지막 줄, value object를 락으로 넘기자 java.lang.IdentityException이 터졌다. 정체성이 없는 객체는 모니터 락의 대상이 될 수 없기 때문이다.
이 마지막 함정은 마이그레이션에서 특히 조심해야 한다. value 타입 변수를 직접 synchronized로 잠그면 아예 컴파일이 막히지만(required: a type with identity), 위처럼 정적 타입이 Object라 컴파일을 통과하면 런타임에 IdentityException으로 터진다. 프리뷰에서는 Integer 같은 래퍼가 value class로 바뀌므로, 무심코 값 기반 객체를 락으로 쓰던 코드가 있다면 바로 이 지점에서 깨진다.
FAQ
JEP 401 Value Objects는 어느 자바 버전부터 쓸 수 있나요?
JDK 28에 프리뷰 기능으로 통합됐다. 프리뷰이므로 기본은 꺼져 있고, 컴파일과 실행 양쪽에서 --enable-preview를 켜야 한다. 정식 JDK에는 아직 포함되지 않았고, 발할라 얼리액세스(EA) 빌드는 jdk.java.net/valhalla에서 받을 수 있다. 프리뷰 기능은 이후 릴리스에서 세부가 바뀔 수 있으므로 프로덕션 사용은 정식화 이후로 미루는 게 안전하다.
value class와 record는 뭐가 다른가요?
record는 데이터를 담는 불변 클래스를 간결하게 선언하는 문법이고, value는 정체성을 제거하는 제어자다. 둘은 배타적이지 않아 value record Point(int x, int y) {}처럼 함께 쓸 수 있다. 일반 record는 여전히 정체성을 가진 identity 객체지만, value record는 정체성이 없어 ==가 상태를 비교한다. record가 “간결한 데이터 클래스”라면 value는 “정체성 없는 객체”라는, 서로 다른 축의 기능이다. record 자체가 궁금하다면 Java Pattern Matching 가이드의 record 분해 부분이 참고가 된다.
그럼 이제 value object는 ==로 비교해도 되나요?
권장하지 않는다. value object에서 ==가 필드 값을 비교하도록 바뀐 건 맞지만, JEP 401은 ==를 equals의 대체품으로 만들려는 게 아니다. 객체의 내부 필드가 그 객체가 의미하는 값과 항상 일치하는 건 아니어서(캐시 필드, -0.0·NaN 등), 상태 동등성이 곧 논리적 동등성은 아니다. 그래서 “비교는 equals로”라는 기존 원칙은 그대로 유효하다.
기존 코드가 깨질 위험은 없나요?
프리뷰를 켜지 않으면 기존 코드는 그대로 돈다. 다만 프리뷰에서는 Integer 같은 래퍼와 LocalDate 등이 value class로 바뀌므로, 이들을 ==로 비교하던 코드의 결과가 달라질 수 있다. 원래도 이런 값 기반 클래스는 == 비교가 권장되지 않았으니, 프리뷰를 계기로 equals로 정리해 두면 정식화 이후에도 안전하다.
value class에서 왜 synchronized를 못 쓰나요?
value object는 개별 정체성이 없기 때문이다. synchronized나 모니터 락은 “바로 그 객체”를 잠금 대상으로 지목하는 것을 전제하는데, 정체성이 없는 value object는 그 대상이 될 수 없다. 그래서 value class의 인스턴스 메서드에는 synchronized를 붙일 수 없고, value object를 락으로 사용할 수도 없다. 동기화가 필요하면 별도의 identity 객체를 락으로 두어야 한다.
value object도 null이 될 수 있나요?
될 수 있다. JEP 401의 value object는 정체성만 없앤 것이지 여전히 참조 타입이라, Point p = null;이 정상 동작하고 배열의 기본값도 그대로 null이다. 실제로 JDK 28 EA에서 Point p = null은 아무 문제 없이 컴파일·실행된다. “int처럼 납작하게 박히는 비-null 값”은 이번 프리뷰의 범위가 아니라, null을 금지하고 메모리에 평탄화하는 별도의 후속 JEP(널 제한 value 타입)에서 다뤄진다. 지금 단계의 value class는 “정체성을 뺀 참조 객체”까지이고, 그 위에 평탄화가 얹히는 건 다음 계단이다.
마치며
지금까지 JEP 401 Value Objects를 JDK 28 프리뷰 기준으로 짚어 보았다. 개인적으로 이 변화에서 가장 흥미로운 건 성능 기능인데 정작 문법이 아니라 ==라는 가장 기초적인 연산의 의미가 흔들린다는 점이다. 자바를 오래 다룬 사람일수록 “==는 참조 비교, equals는 값 비교”를 거의 반사적으로 외우고 있는데, value object는 그 경계에 처음으로 예외를 낸다. 실무에서 당장 쓸 일은 없어도, 면접이나 코드 리뷰에서 “이건 왜 true가 나오죠?” 하는 순간이 머지않아 올 거라는 생각이 들었다.
발할라를 처음 들여다본다면 성능 수치보다 “정체성을 왜 버리려 하는가”라는 질문에서 시작하길 권한다. 자바가 10년을 들여 지우려는 건 결국 “작은 값 하나에도 힙 객체와 참조가 따라붙는” 오래된 비용이고, value object는 그 비용을 언어 차원에서 걷어 내는 첫 도구다. Compact Object Header로 헤더를 줄이고, AOT Cache로 시작 시간을 줄이는 최근의 흐름과 나란히 놓고 보면, 자바가 어느 방향으로 몸을 가볍게 만들고 있는지가 선명해진다. JEP 401은 그 큰 그림의 한 조각이고, 지금은 그 조각을 손으로 만져 볼 수 있는 첫 시점이다.
