오늘 알아볼 주제는 Kotlin의 Value Class와 Vlalue Class가 탄생하게 된 JVM의 Project Valhalla 입니다. 우테코 프리코스의 로또 과제를 진행하면서 다음과 같이 상태 관리를 위한 data Class를 만들었습니다. 이 과정에서 도메인에 특화된 타입을 만들어 Wrapper로 분리할지 아니면 Primitive Type 타입 그대로 사용할지 많은 고민을 했습니다.
미션 제출 당시엔 아래 코드와 같은 형태로 제출했지만 이후 리팩토링 과정에서 Value Class라는 좋은 기능이 있다는 것을 알게 되어 소개해보겠습니다. 이번 글은 "이펙티브 코틀린"의 45장 '불필요한 객체 생성을 피하라'와 47장 '인라인 클래스의 사용을 고려하라'에 대한 내용을 참고하였습니다.
data class PurchaseState(
val purchaseInfo: PurchaseInfo = PurchaseInfo(),
val winningNumber: List<Int> = emptyList(),
val bonusNumber: Int = 0,
val lotto: PurchasedLottoInfo = PurchasedLottoInfo(),
val winningResult: WinningResult = WinningResult(),
val rateOfReturn: String = "",
)
data class PurchaseInfo(
val purchaseLottoCount: Int = 0,
){
val guideMessage: String get() = formatPurchaseInfo(purchaseLottoCount)
}
data class PurchasedLottoInfo(
val pickedLotto: List<TreeSet<Int>> = emptyList(),
) {
val guideMessage: String get() = formatPurchasedLotto(pickedLotto)
}
data class WinningResult (
val winning: Map<Rank, Int> = mapOf(
Rank.FIFTH to 0,
Rank.FOURTH to 0,
Rank.THIRD to 0,
Rank.SECOND to 0,
Rank.FIRST to 0,
)
){
val guideMessage: String get() = formatWinningResult(winning)
}
Wrapper Class를 사용하는 이유
다음과 같이 위도 경도를 전달받아 특정 로직을 수행하는 함수가 있다고 가정합시다. 함수의 인자로 위도 경도를 전달하는 과정에서 사용자의 실수로 위도와 경도를 반대로 전달하는 실수를 저지를 수 있습니다. 이는 의도와는 다른 잘못된 결과를 반환하지만 컴파일러는 이에 대한 오류를 검출할 수 없습니다.
fun calculateDistance(latitude: Double longitude: Double): Double {
return something
}
fun main() {
val latitude = 37.5665
val longitude = 126.9780
// 올바른 호출
val distance = calculateDistance(latitude, longitude)
// 실수로 매개변수 순서가 뒤바뀐 경우
val incorrectDistance = calculateDistance(longitude, latitude)
}
이러한 실수를 방지하기 위해 위도와 경도를 래핑 하는 클래스를 정의하고 함수의 파라미터 타입을 래퍼 클래스로 지정함으로써 컴파일 타임에 잘못된 타입 사용을 방지할 수 있습니다. 이는 타입 안정성을 제공하여 런타임 오류를 줄여줍니다. 또한 도메인 모델을 명확하게 표현함으로써 가독성이 향상되고 도메인 로직을 캡슐화하여 유효성 검사나 비즈니스 로직을 클래스 내부에 포함시켜 단일 책임 원칙(SRP)을 준수할 수 있습니다.
// 위도와 경도를 나타내는 Wrapper 클래스 정의
data class Latitude(val value: Double){
// 유효성 검사
init {
require(value in -90.0..90.0) { "Latitude out of bounds" }
}
// 도메인 로직
fun doSomething(){}
}
data class Longitude(val value: Double){
// 유효성 검사
init {
require(value in -180.0..180.0) { "Longitude out of bounds" }
}
// 도메인로직
fun doSomething(){}
}
fun calculateDistance(latitude: Latitude, longitude: Longitude): Double {
return something
}
fun main() {
val latitude = Latitude(37.5665)
val longitude = Longitude(126.9780)
// 올바른 호출
val distance = calculateDistance(latitude, longitude)
// 잘못된 순서를 컴파일러가 감지
// ❌ 컴파일 오류 발생
// val incorrectDistance = calculateDistance(longitude, latitude)
}
Wrapper Class의 문제점
primitive Type은 런타임에 최적화 되어있습니다. 이러한 primitive Type 래핑 하는 것은 런타임 성능면에서 primitive Type을 사용하는 이점을 잃게 됩니다. 또한 Wrapper Class를 사용하기 위해서는 반드시 객체를 생성해야 하며, 이는 추가적인 비용을 수반합니다. 이펙티브 코틀린 45장에서 제시하는 객체 생성으로 인해 발생하는 비용은 다음과 같습니다:
- 객체는 더 많은 메모리 용량을 차지한다.
Wrapper Class는 Primary Type보다 더 많은 메모리를 필요로 합니다. 이는 객체의 메모리 오버헤드와 내부 필드의 메모리 사용량 때문입니다. - 요소가 캡슐화되어 있다면 추가적인 함수 호출이 필요하다.
Wrapper Class의 필드나 메서드에 접근하려면 캡슐화로 인해 게터(getter)나 세터(setter)를 호출해야 하며, 이는 기본 타입에 비해 연산 비용을 증가시킵니다. - 객체는 Heap 영역에 할당된다.
객체 생성 시 Heap 메모리에 할당되고, 이에 따라 객체를 참조하기 위한 레퍼런스도 추가로 생성됩니다. 이는 가비지 컬렉션(Garbage Collection)을 통해 관리되어야 하므로 추가적인 런타임 비용이 발생할 수 있습니다.
Project Valhalla
Valhalla Project는 2014년 JVM에 Flat한 데이터 타입을 만들자 라는 목표로 시작된 프로젝트입니다. Valhalla 프로젝트에 대한 원문이 궁금하시다면 이 링크를 참조해주세요. 해당 프로젝트에선 크게 3가지 개념을 새롭게 도입하고자 했습니다.
- value class
- primitive class
- specialized generics
Valhalla의 목표는 기존 타입 시스템을 개선하고자 함이었습니다. 기존 JVM 타입 시스템은 크게 3가지로 이루어져 있습니다.
- primitive (int, long…)
- object
- array
그리고 앞서 살펴본 것처럼 다양한 이유로 wrapper class를 사용합니다. 다음과 같이 변수 X, Y를 가진 Point라는 객체가 있을 때 Point 객체들을 원소로 가지는 배열(Point [])의 메모리 레이아웃을 간략하게 표현하면 왼쪽 그림과 같습니다.
각각의 요소가 heap에 할당되며 Object Header를 가집니다. Object Header는 객체의 식별 및 동기화에 필요한 정보를 담고 있습니다. 배열은 실제 데이터를 직접 담지 않고 힙에 위치한 객체를 가리키는 참조(reference)들로 구성됩니다.
Valhalla Project는 이와 같은 현재의 JVM 레이아웃을 개선하고자 했습니다. 간접 참조를 제거함으로써 오른쪽 그림처럼 평평하고(Flat) 밀접한(dense) 레이아웃으로 탈바꿈하려는 것이죠.
Point 배열의 메모리 레아이웃이 왼쪽 그림처럼 될 수밖에 없었던 이유는 자바의 모든 객체가 가져야만 하는 동일성(Identity) 때문입니다. Java 객체의 동일성(Identity)은 다음 두 가지 이유로 필수적입니다.
- 객체의 식별 가능성 : 각 객체는 메모리 주소를 통해 고유하게 식별
- 객체의 변경 가능성 : Java는 객체의 필드를 수정할 수 있으므로 객체를 명확히 식별할 수 있는 구조의 필요성
동일성을 보장하기 위해 JVM은 객체에 대한 참조를 사용하여 배열을 구성하며 결과적으로 배열은 메모리 상에서 평평하지 않고 간접적(indirect)으로 접근됩니다.
Valhalla 프로젝트의 핵심 기능 중 하나는 동일성의 포기 입니다.
그리고 동일성을 포기한 객체를 value object(class)라고 부릅니다. value class는 성능 향상을 가져오는 대신 유연성과 다형성을 잃습니다. 메소드, 생성자, 필드, 캡슐화, 인터페이스, 제네릭 등 객체 지향 프로그래밍의 대부분의 특징을 지원하며 Value class는 참조 대신 값 자체를 비교하는 방식으로 작동합니다.
Kotlin Value Class
코틀린 1.5 버전 이전엔 인라인 클래스를 사용해 앞서 말한 value class의 기능을 구현했습니다. 이펙티브 코틀린 47장 "인라인 클래스 사용을 고려하라" 에서 소개된 인라인 클래스는 다음과 같습니다.
inline class Name(private val value: String)
// 생성
val name: Name = Name("Marcin")
// 컴파일 때 다음과 같은 형태로 바뀐다.
val name: String = "Marcin"
@JvmInline
value class Latitude(val input: Double)
@JvmInline
value class Longitude(val input: Double)
코틀린은 버전 1.5부터 인라인 클래스 대신 value class를 사용할 수 있습니다. 기존 인라인 클래스와 기능적인 면에서 동일하며 value 키워드는 코틀린에서 인라인 클래스를 만드는 키워드입니다. 단 하나의 불변 필드만을 가지며 JVM 환경에서 컴파일 시 class를 제거한 후 내부 필드값으로 대체됩니다. @JvmInline 어노테이션은 인라인 클래스의 필드에 대한 접근이 메소드 호출이 아닌 필드에 직접 접근하는 방식으로 변경 됨으로써 오버헤드를 줄이고 성능을 향상할 수 있습니다.
data class와는 다르게 데이터 클래스는 equals(), toString(), hashCode(), copy(), componentN() 메소드를 오버라이드 할 수 있지만 value class는 equals(), toString(), hashCode() 메소드만 가능합니다.
생성 시 유효성 검증이 되는 객체 만들기
앞서 살펴본 로또 미션 상태 클래스 중 사용자가 입력한 구매 금액에 대한 유효성 검사 로직을 value class로 리팩토링 해보겠습니다.
data class PurchaseState(
val purchaseInfo: PurchaseInfo = PurchaseInfo(),
// ...
)
data class PurchaseInfo(
val purchaseLottoCount: Int = 0,
){
val guideMessage: String get() = formatPurchaseInfo(purchaseLottoCount)
}
class CheckPurchaseMoneyUseCase {
operator fun invoke(input: String): Int {
isEmpty(input)
val money = isNumeric(input)
isLowerThenLottoPurchaseUnit(money)
isThousandUnit(money)
isOverOneDayMax(money)
return money
}
private fun isEmpty(input: String) {
require(input.isNotBlank()) { Exception.EMPTY_INPUT }
}
private fun isNumeric(input: String): Int {
requireNotNull(input.toIntOrNull()) { Exception.ONLY_DIGIT }
return input.toInt()
}
private fun isLowerThenLottoPurchaseUnit(money: Int) {
require(money >= Constants.LOTTO_PURCHASE_UNIT.getValue()) {
Exception.LOWER_THAN_LOTTO_PRICE
}
}
private fun isThousandUnit(money: Int) {
require((money % Constants.LOTTO_PURCHASE_UNIT.getValue()) == 0) {
Exception.INVALID_PURCHASE_UNIT
}
}
private fun isOverOneDayMax(money: Int) {
require(money <= Constants.LOTTO_PURCHASE_MAX.getValue()) {
Exception.OVER_ONE_TIME_MAX
}
}
}
해당 클래스에는 companion object에 from( )이라는 메서드를 만들어 팩토리 메서드 패턴을 사용하며 유효성 검사에선 다음 과정을 거칩니다.
- isEmpty( ) : 입력값이 공백인지 검사합니다.
- isNumeric( ) : 입력값이 숫자인지 검사 후 Int 형으로 반환합니다.
- isLowerThenLottoPurchaseUnit( ) : 입력값이 최소 구입 금액인 1000원 이상인지 검사합니다.
- isThousandUnit( ) : 구입 금액이 천 원 단위인지 검사합니다.
- isOverOneDayMax( ) : 1회 최대 구입 금액인 10만 원 이하인지 검사합니다.
@JvmInline
value class PurchasePrice private constructor(
val value: Int
) {
init {
validate(value)
}
private fun validate(value: Int) {
isLowerThenLottoPurchaseUnit(value)
isThousandUnit(value)
isOverOneDayMax(value)
}
private fun isLowerThenLottoPurchaseUnit(money: Int) {
require(money >= Constants.LOTTO_PURCHASE_UNIT.getValue()) {
Exception.LOWER_THAN_LOTTO_PRICE
}
}
private fun isThousandUnit(money: Int) {
require((money % Constants.LOTTO_PURCHASE_UNIT.getValue()) == 0) {
Exception.INVALID_PURCHASE_UNIT
}
}
private fun isOverOneDayMax(money: Int) {
require(money <= Constants.LOTTO_PURCHASE_MAX.getValue()) {
Exception.OVER_ONE_TIME_MAX
}
}
companion object {
private fun validateUserInput(input: String): Int {
isEmpty(input)
isNumeric(input)
return input.toInt()
}
private fun isEmpty(input: String) {
require(input.isNotBlank()) { Exception.EMPTY_INPUT }
}
private fun isNumeric(input: String) {
require(input.toIntOrNull() != null) { Exception.ONLY_DIGIT }
}
fun from(input: String): PurchasePrice {
val value = validateUserInput(input)
return PurchasePrice(value)
}
operator fun invoke(input: String): PurchasePrice = from(input)
}
}
위 코드에선 기본 생성자를 private으로 만들어 팩토리 메소드를 사용하도록 강제했습니다. 팩토리 메소드에선 정수형에 대한 공통 유효서 검사를 수행합니다. 하지만 이 경우 팩토리 메서드의 존재를 모른다면 해당 객체를 생성할 수 없습니다. 때문에 연산자 오버로딩 함수를 사용해 invoke 함수가 팩토리 메서드 패턴을 사용해 인스턴스를 반환하도록 만들었습니다.
마무리
value class에 대해 구글링 하면서 공통으로 말하는 내용이 오버헤드에 관한 문제였습니다. 도대체 어떤 오버 헤드 문제가 있는지 파고 파다 보니 이해하기 어려운 내용도 꽤 많아 다른 블로그의 내용을 짜집기하는 것 밖에 안된 것 같아 많이 아쉬운 것 같습니다. 최근엔 이런 짜집기 형식의 글을 안 쓰려고 노력하고 있는데 처음 접하는 내용이다 보니 다소 부족한 글이 될 수밖에 없었던 것 같습니다. 참조 레퍼런스 중 프로젝트 발할라와 카카오페이 기술 블로그에 대한 내용은 정말 재미있으니 한 번쯤 읽어보시는 것을 추천드립니다.
Reperence
'KOTLIN' 카테고리의 다른 글
[Kotlin]Sealed Class란 무엇일까 ? (0) | 2024.03.22 |
---|---|
Coroutine SharedFlow (0) | 2024.03.09 |
StateFlow가 중복된 값을 반환하지 않는 이유(DistinctUntilChanged) (0) | 2024.03.08 |
[Kotlin] LiveData 대신 StateFlow 사용하기 (1) | 2024.01.15 |
[Kotlin] Coroutine Flow (0) | 2024.01.15 |