프로젝트 발할라와 함께 Java에서 도메인 프리미티브 재고하기
수년간 Java 개발자들은 타입 안전성과 성능 사이의 답답한 트레이드오프에 직면해 왔습니다. 한편으로는 도메인 프리미티브—비즈니스 제약을 인코딩하는 타입(예: 원시 int 대신 PositiveInt)—를 사용하면 컴파일러가 잘못된 상태를 거부하고 버그를 방지할 수 있습니다. 다른 한편으로는 원시 타입을 클래스로 감싸면 헤더와 포인터를 가진 힙 객체가 생성되어 메모리 오버헤드가 크게 증가하고, 성능이 중요한 “핫 루프”에서 캐시 미스가 발생합니다.
전통적으로는 시스템 경계에서 타입을 정제하고, 핵심 로직에서는 원시 프리미티브로 되돌아가 속도를 유지하는 것이 일반적인 규칙이었습니다. 프로젝트 발할라는 value 클래스를 도입함으로써 이 방정식을 바꾸어, JVM이 래퍼를 레지스터, 포함 객체, 혹은 배열 슬롯에 직접 평탄화(inline)할 수 있게 합니다.
래퍼 클래스의 성능 세금
Valhalla가 왜 필요한지 이해하려면 표준 Java 래퍼의 메모리 레이아웃을 살펴봐야 합니다. 간단한 class PositiveInt { final int v; }는 HotSpot에서 일반적으로 16바이트를 차지합니다(12바이트 헤더와 4바이트 정수). 또한 이러한 객체들의 배열은 실제 값을 저장하지 않고, 힙에 흩어져 있는 객체들을 가리키는 4바이트 레퍼런스를 저장합니다.
수백만 개의 이벤트를 처리하는 스트림 프로세서에 이 구조를 적용하면 재앙과도 같습니다. 래퍼는 원시 int보다 네 배의 메모리를 차지하고, 모든 접근은 “포인터 체이싱”을 필요로 하며, 이는 종종 캐시 라인 로드와 잠재적인 캐시 미스로 이어집니다. 이러한 오버헤드 때문에 개발자들은 전통적으로 핫 경로에서 정제된 타입을 피해 왔습니다.
정제 타입 구현
Scala와 같은 언어에서는 정제 타입을 컴파일 타임에 프레디케이트로 좁힐 수 있습니다. Java는 이러한 네이티브 메커니즘이 없으므로 제약 조건을 생성 시점에 검사해야 합니다. 일반적인 구현은 기본 Refined<T> 클래스와 구체적인 확장을 포함합니다:
private static class PositiveInt extends Refined<Integer> {
public PositiveInt(int value) {
super(i -> i > 0, value);
}
}
이렇게 하면 PositiveInt 인스턴스가 유효하다는 정적 보장을 제공하지만, 원시 타입의 박싱은 여전히 병목 현상이 됩니다. 여기서 프로젝트 발할라의 value 키워드가 등장합니다.
프로젝트 발할라와 Value 클래스 도입
Value 클래스는 정체성이 없는 객체입니다. 행동과 불변 조건을 가지고 있지만 원시 타입처럼 저장됩니다. value 키워드를 사용하면 개발자는 JVM에 해당 타입에 정체성이 없음을 알리며, JVM은 객체가 나타나는 모든 위치에 필드를 인라인할 수 있는 권한을 부여받습니다.
다음은 Valhalla 프리뷰(JEP 401)를 사용한 도메인 프리미티브 구현 예시입니다:
public value class PositiveInt implements RefinedInt<PositiveInt> {
private final int value;
public PositiveInt(int value) {
if (value <= 0) {
throw new IllegalArgumentException("must be positive: " + value);
}
this.value = value;
}
@Override public int value() { return value; }
}
Refined<T>와 같은 일반 인터페이스 대신 RefinedInt와 같은 특화된 인터페이스를 사용함으로써 박싱을 피할 수 있습니다. 결과적으로 클래스와 동일한 안전성을 제공하면서 원시 타입 수준의 성능을 얻을 수 있습니다.
다중 필드 타입으로 확장
이 패턴은 단일 값 래퍼를 넘어 확장됩니다. Latitude와 Longitude(두 모두 double 기반 value 클래스)를 포함하는 Coordinate value 클래스는 JVM이 슬롯당 연속된 16바이트의 double을 저장하도록 합니다. 반면, 정체성 클래스로 구현하면 각각 헤더를 가진 흩어진 힙 객체에 대한 레퍼런스를 저장하게 됩니다.
수치로 보는 영향
압축 oops를 사용한 64비트 HotSpot에서의 벤치마크는 10개 요소 배열에 대한 메모리 사용량 차이를 극명하게 보여줍니다:
int[10]: 56 bytes (기본 프리미티브)PositiveInt[10](Identity Class): 216 bytes (도메인 인식하지만 비용이 많이 듦)PositiveInt[10](Value Class): 56 bytes (저렴하고 도메인 인식)
Value 클래스는 기본 프리미티브와 정확히 동일한 메모리 레이아웃과 캐시 동작을 구현하여, 도메인 모델링에 대한 “성능 세금”을 사실상 없애줍니다.
도입 시 주요 고려 사항
Value 클래스는 강력한 도구이지만, 개발자가 고려해야 할 여러 의미론적 변화가 있습니다:
1. Equality Semantics
Value 클래스는 값으로 비교하며, 포인터로 비교하지 않습니다. value 클래스에 대한 == 연산자는 필드 단위 대체 가능성을 테스트하므로, 잘 구현된 equals() 메서드와 동등합니다. 기존 정체성 클래스를 value 클래스로 마이그레이션하면, 레퍼런스 동등성에 의존하던 코드의 동작이 조용히 변경됩니다.
2. Nullability
Value 클래스는 null이 될 수 없습니다. 프로젝트 발할라는 null 제한 레퍼런스(e.g., PositiveInt!는 non-null, PositiveInt?는 nullable)를 도입하고 있어, 컴파일러가 핫 경로에서 null 체크를 제거할 수 있게 합니다.
3. The Generics Gap
현재 List<PositiveInt>와 Optional<PositiveInt>는 타입 소거 때문에 여전히 박싱을 발생시킵니다. 제네릭 특화가 아직 완전히 제공되지 않았으므로, 중요한 경로에서 최대 성능을 원한다면 컬렉션 대신 타입 배열(PositiveInt[])을 사용하는 것이 좋습니다.
4. Framework Integration
대부분의 Java 프레임워크(Jackson, JPA, Bean Validation)는 프리미티브나 String을 기대합니다. Value 타입은 얇은 어댑터가 필요합니다. 예를 들어, PositiveInt에 대한 Jackson 디시리얼라이저는 p.getIntValue() 호출을 새로운 PositiveInt 생성자로 감싸기만 하면 됩니다.
결론
프로젝트 발할라는 고수준 도메인 모델링과 저수준 성능 사이의 격차를 메우고 있습니다. 타입을 “클래스처럼 코딩하지만 int처럼 동작”하도록 허용함으로써, Java는 전체 애플리케이션에 강력한 도메인 프리미티브를 사용하는 마지막 장벽을 제거하고 있습니다. 아직 프리뷰 단계이지만, value 클래스는 타입 시스템에 인코딩된 보안과 정확성이 성능 페널티 없이 제공되는 미래를 약속합니다.