
최근 우아한테크코스 프리코스를 진행하면서 많은 고민을 했습니다.
특히 비즈니스 로직에서 다루어야 하는 원시 타입의 값을 어떤 방식으로 제어하는 것이 가장 효율적인지 찾아보던 도중, value class라는 키워드를 접하게 되었습니다.
value class을 보기 전에 짚고 넘어갈 개념들이 있습니다.
바로 원시 타입과 참조 타입입니다.
원시 타입은 기본적인 데이터 타입으로, 단순한 형태의 데이터를 나타냅니다.
int a = 1;
boolean b = true;
위의 두 변수에는 1과 true라는 값이 직접 저장됩니다.
즉, 1과 true가 Stack 메모리에 저장됩니다.
특정 값이 직접 메모리에 저장되어 있기 때문에 한 번만 참조하면 됩니다.
참조 타입은 실제 데이터가 담긴 객체의 주소를 참조하는 타입입니다.
Heap 메모리에 실제 값이 저장되고, Stack 메모리에는 특정 값을 가리키는 주소가 저장됩니다.
참조 타입에는 배열, enum, class 등이 있습니다.
원시 타입은 제네릭으로 사용할 수 없습니다.
또한 null 값이 저장될 수 없죠.
때문에 원시 타입을 객체로 감싸주어야 합니다. (Boxing)
이 경우 래퍼 클래스를 사용하게 됩니다.

여기까지는 자바의 이야기입니다. 이제 코틀린에서 살펴볼까요?
In Kotlin, everything is an object in the sense that you can call member functions and properties on any variable. While certain types have an optimized internal representation as primitive values at runtime (such as numbers, characters, booleans and others), they appear and behave like regular classes to you.
- Kotlin에서는 모든 변수에 대해 멤버 함수와 속성을 호출할 수 있다는 점에서 모든 것이 객체입니다. 특정 유형은 런타임 시 기본 값(예: 숫자, 문자, 부울 및 기타)으로 최적화된 내부 표현을 갖지만 사용자에게는 일반 클래스처럼 나타나고 작동합니다.
코틀린의 공식 문서에 적혀 있는 내용입니다.
위에서 알 수 있듯이, 코틀린은 원시 타입과 참조 타입을 구분하지 않습니다.
val a: Int = 1
val b: Boolean = true
위 코드를 보면 Int, Boolean이 마치 원시 타입처럼 보입니다.
하지만 코드를 실행하면, 컴파일 시 JVM에서 최적화되어 자동으로 변환됩니다.
val a: Int? = 1
위 경우에는 원시 타입에 null값이 저장될 수 없기 때문에, 래퍼 클래스인 Integer를 통해 참조 타입으로 변환됩니다.
코틀린은 알아서 해준다.
위에서 본 것과 같이, 어떠한 상황이든 코틀린 컴파일러가 알아서 잘 처리해 줍니다.
근데 만약 비즈니스 로직에 포함되는 중요한 데이터를 원시 타입으로 다룬다면?
val id = readLine()
val password = readLine()
login(id, password)
로그인을 구현하다고 가정해봅시다.
위 코드에서 중요한 데이터, 즉 비즈니스에 종속적인 값은 당연하게도 id와 password겠죠?
하지만 위처럼 원시 타입으로 값을 다루게 되면 테스트 용이성도 떨어지고, 코드 가독성도 떨어지고, 타입 안정성이나 캡슐화 등.. 생각할 수 있는 문제가 정말 많아지네요.
그럼 이러한 방식은 어떨까요?
data class User(
val id: String,
val password: String
)
id와 password를 data class로 감쌌죠.
이전의 방식보다는 확실히 나아보이지만, 여전히 두 속성은 모두 String이기 때문에, 아이디와 비밀번호가 구분되지 않는 문제가 발생할 수도 있습니다.
코드를 조금 더 바꿔보겠습니다.
data class Id(val value: String)
data class Password(val value: String)
data class User(
val id: Id,
val password: Password
)
각 프로퍼티를 감싸는 클래스를 따로 선언했습니다.
이렇게 하면 아이디와 비밀번호를 구분할 수 있고, 비즈니스에 종속적인 객체를 생성할 수 있겠죠?
하지만 위와 같이 코드를 작성하게 되면 특정 데이터를 나타내는 프로퍼티에 대해 각각 객체를 생성해주어야 하고, 이는 자연스럽게 Heap 메모리 할당으로 이어집니다.
Effective Kotlin에서는 불필요한 객체 생성을 피하기 위해 기본 자료형을 사용할 것을 권장하기도 합니다.
Kotlin 공식 문서에서도 위의 문제에 대해서 언급하고 있습니다.
Sometimes it is useful to wrap a value in a class to create a more domain-specific type. However, it introduces runtime overhead due to additional heap allocations. Moreover, if the wrapped type is primitive, the performance hit is significant, because primitive types are usually heavily optimized by the runtime, while their wrappers don't get any special treatment.
- 때로는 추가 도메인별 유형을 생성하기 위해 클래스에 값을 래핑하는 것이 유용할 수 있습니다. 그러나 추가적인 힙 할당으로 인해 런타임 오버헤드가 발생합니다. 더욱이, 래핑된 유형이 기본 유형인 경우 성능 저하가 상당합니다. 기본 유형은 일반적으로 런타임에 의해 크게 최적화되는 반면 해당 래퍼는 특별한 처리를 받지 않기 때문입니다.
이전에 언급했듯이 코틀린의 기본 자료형은 런타임에서 Java의 원시 타입이나 래퍼 타입으로 변경되는데, 이를 클래스로 감쌀 경우 특별한 처리가 이루어지지 않아 문제가 발생할 수 있습니다.
이러한 문제를 해결하기 위해 value class가 등장합니다.
value class는 기본적으로 인라인으로 동작합니다. 컴파일 타임에는 단순 클래스로 처리되지만, 런타임에는 래핑을 제거하여 기본 자료형처럼 동작하여 성능 저하 없이 Value Object를 사용할 수 있습니다.
@JvmInline
value class Id(private val value: String)
@JvmInline
value class Password(private val value: String)
val id = Id("test")
val password = Password("123")
value class는 기본 생성자에서 단 하나의 프로퍼티만 가질 수 있습니다. (불변으로 선언해야 함)
또한 인라인으로 동작하는 것을 JVM에 알리기 위해 @JvmInline 어노테이션을 추가해야 합니다.
@JvmInline
value class Id(private val value: String) {
init {}
constructor {}
fun printId() {
println("id: $value")
}
}
일반 클래스와 같이 init 블록, 부 생성자, 추가 프로퍼티나 함수를 가질 수 있습니다.
또한 생성 시 equals(), toString(), hashCode() 가 자동으로 생성됩니다.
@JvmInline
value class LottoNumber(val number: Int) {
...
}
제가 미션에서 실제로 작성한 코드입니다.
value class를 자세하기 알기 위해 코드를 디컴파일 해보겠습니다.
public final class LottoNumber {
private final int number;
public static final int MIN_LOTTO_NUMBER = 1;
public static final int MAX_LOTTO_NUMBER = 45;
@NotNull
public static final Companion Companion = new Companion((DefaultConstructorMarker)null);
public final int getNumber() {
return this.number;
}
// $FF: synthetic method
private LottoNumber(int number) {
this.number = number;
}
public static int constructor_impl/* $FF was: constructor-impl*/(int number) {
validateLottoNumber-impl(number, number);
return number;
}
// $FF: synthetic method
public static final LottoNumber box_impl/* $FF was: box-impl*/(int v) {
return new LottoNumber(v);
}
public static String toString_impl/* $FF was: toString-impl*/(int var0) {
return "LottoNumber(number=" + var0 + ")";
}
public static int hashCode_impl/* $FF was: hashCode-impl*/(int var0) {
return Integer.hashCode(var0);
}
public static boolean equals_impl/* $FF was: equals-impl*/(int var0, Object var1) {
if (var1 instanceof LottoNumber) {
int var2 = ((LottoNumber)var1).unbox-impl();
if (var0 == var2) {
return true;
}
}
return false;
}
public static final boolean equals_impl0/* $FF was: equals-impl0*/(int p1, int p2) {
return p1 == p2;
}
// $FF: synthetic method
public final int unbox_impl/* $FF was: unbox-impl*/() {
return this.number;
}
public String toString() {
return toString-impl(this.number);
}
public int hashCode() {
return hashCode-impl(this.number);
}
public boolean equals(Object var1) {
return equals-impl(this.number, var1);
}
}
(불필요한 부분은 제거했습니다.)
먼저 LottoNumber 클래스는 final로 선언되어 있습니다.
즉 이 클래스는 상속할 수 없는 클래스라는 의미입니다.
It is forbidden for inline classes to participate in a class hierarchy. This means that inline classes cannot extend other classes and are always final.
- 인라인 클래스가 클래스 계층 구조에 참여하는 것은 금지되어 있습니다. 즉, 인라인 클래스는 다른 클래스를 확장할 수 없으며 항상 최종 클래스입니다.
반대로 인터페이스는 상속할 수 있습니다.
또한 equals(), toString(), hashCode()가 자동으로 생성되어 있는 것도 확인할 수 있습니다.
하지만 이것만 보면 value class의 특징을 살펴볼 수 없는 것 같네요.
실제로 위 클래스로 생성한 객체가 사용되는 부분을 살펴봅시다.
class Lotto(private val numbers: List<LottoNumber>) : List<LottoNumber> by numbers {
...
}
로또 번호 리스트를 위임받은 일급 컬렉션입니다.
이 코드를 디컴파일 해보겠습니다.
public final class Lotto implements List, KMappedMarker {
private final List numbers;
public Lotto(@NotNull List numbers) {
Intrinsics.checkNotNullParameter(numbers, "numbers");
super();
this.numbers = numbers;
this.validateLotto(this.numbers);
}
public int getSize() {
return this.numbers.size();
}
// $FF: bridge method
public final int size() {
return this.getSize();
}
public boolean contains_Q1rJwyA/* $FF was: contains-Q1rJwyA*/(int element) {
return this.numbers.contains(LottoNumber.box-impl(element));
}
// $FF: bridge method
public final boolean contains(Object var1) {
return !(var1 instanceof LottoNumber) ? false : this.contains-Q1rJwyA(((LottoNumber)var1).unbox-impl());
}
public boolean containsAll(@NotNull Collection elements) {
Intrinsics.checkNotNullParameter(elements, "elements");
return this.numbers.containsAll(elements);
}
public int get_wOXzKiA/* $FF was: get-wOXzKiA*/(int index) {
Object var10000 = this.numbers.get(index);
Intrinsics.checkNotNullExpressionValue(var10000, "get(...)");
return ((LottoNumber)var10000).unbox-impl();
}
// $FF: synthetic method
// $FF: bridge method
public Object get(int var1) {
return LottoNumber.box-impl(this.get-wOXzKiA(var1));
}
public int indexOf_Q1rJwyA/* $FF was: indexOf-Q1rJwyA*/(int element) {
return this.numbers.indexOf(LottoNumber.box-impl(element));
}
// $FF: bridge method
public final int indexOf(Object var1) {
return !(var1 instanceof LottoNumber) ? -1 : this.indexOf-Q1rJwyA(((LottoNumber)var1).unbox-impl());
}
public boolean isEmpty() {
return this.numbers.isEmpty();
}
@NotNull
public Iterator iterator() {
return this.numbers.iterator();
}
public int lastIndexOf_Q1rJwyA/* $FF was: lastIndexOf-Q1rJwyA*/(int element) {
return this.numbers.lastIndexOf(LottoNumber.box-impl(element));
}
// $FF: bridge method
public final int lastIndexOf(Object var1) {
return !(var1 instanceof LottoNumber) ? -1 : this.lastIndexOf-Q1rJwyA(((LottoNumber)var1).unbox-impl());
}
@NotNull
public ListIterator listIterator() {
return this.numbers.listIterator();
}
@NotNull
public ListIterator listIterator(int index) {
return this.numbers.listIterator(index);
}
@NotNull
public List subList(int fromIndex, int toIndex) {
return this.numbers.subList(fromIndex, toIndex);
}
public boolean add_Q1rJwyA/* $FF was: add-Q1rJwyA*/(int var1) {
throw new UnsupportedOperationException("Operation is not supported for read-only collection");
}
public void add_iekjRRU/* $FF was: add-iekjRRU*/(int var1, int var2) {
throw new UnsupportedOperationException("Operation is not supported for read-only collection");
}
}
래핑된 컬렉션이 List<LottoNumber>인 만큼, List의 메소드들이 제공되고 있습니다.
여기서 자세히 봐야 할 부분은 이겁니다.
public boolean contains_Q1rJwyA/* $FF was: contains-Q1rJwyA*/(int element) {
return this.numbers.contains(LottoNumber.box-impl(element));
}
public int indexOf_Q1rJwyA/* $FF was: indexOf-Q1rJwyA*/(int element) {
return this.numbers.indexOf(LottoNumber.box-impl(element));
}
위와 같이 contains나 indexOf는 리스트의 타입과 일치하는 변수를 인자로 받아 동작하게 됩니다.
위의 리스트는 LottoNumber 타입임에도 불구하고, 매개변수는 int 타입입니다.
이를 통해 value class로 생성된 객체는 런타임에서 원시 타입으로 최적화되는 것을 알 수 있습니다.
또한 메소드 이름 뒤에 알 수 없는 문자열이 추가된 것을 확인할 수 있는데, 이것은 맹글링이라고 하는 것입니다.
인라인 클래스(value class)는 기본 타입으로 변환되므로, 메소드의 이름 충돌을 방지하기 위해 메소드 이름에 해시 코드를 추가하여 이와 같은 문제를 방지하는 것입니다.
value class는 이렇게 비즈니스에 종속적이며, 메모리 누수도 방지하며 VO를 생성할 수 있습니다.
그렇다면 비즈니스 로직에서 무조건 value class를 사용하는게 좋을까요?
이 부분은 value class의 문제점에 대해 먼저 이야기해야 할 것 같습니다.
value class를 사용하는 의미가 무색해집니다.@JvmInline
value class Id(val value: String)
fun printAny(value: Any?) {
println(value)
}
fun main() {
val id = Id("test")
printAny(id) //객체로 처리됨
}
다수의 프로퍼티는 표현할 수 없음
표현하고자 하는 데이터의 양이 많다면, 클래스 파일의 개수는 그에 비례하게 됩니다. 이 경우 코드의 양이 급격하게 늘어나 코드 복잡도가 늘어날 수 있습니다.
메모리 성능
직접 테스트해보진 않았지만, 다른 분들이 value class의 성능을 테스트한 결과 기존의 방식과 큰 차이가 나지 않았다고 합니다. 성능을 올리기 위해 코드를 늘렸는데, 실제로 성능은 올라가지 않았으니 참 아이러니한 부분이죠.
이 외에도 여러 문제가 있을 거라고 생각합니다. 저도 처음 접하고 바로 코드에 적용을 해봤는데, 메리트를 느끼지는 못한 것 같습니다.
이번 글에서는 Kotlin의 value class에 대해서 알아보았습니다.
사용했을 때 얻을 수 있는 장점도 있고, 단점도 있기 때문에 본인의 상황에 따라 알맞게 사용하면 될 것 같습니다.