Kotlin에서 data class는 데이터를 저장하기 위한 목적으로 사용되는 클래스이다. 이 글에서는 왜 data class를 사용하는 것이 좋은지 이유를 알아보기 위해서, 데이터를 저장하기 위한 목적의 일반 클래스를 정의하여 알아보겠다.
// data class가 아닌 일반 class
class Person(val name: String, val age: Int)
데이터 저장 목적의 일반 클래스를 정의할 때, 보통 toString(), equals(), hashCode() 메서드를 override하여 재정의한다. Java에서부터 이어져온 유구한 전통이다. 왜 그런지 하나씩 이유를 살펴보자.
fun main() {
val p1 = Person("Michael", 29)
println(p1.toString()) // 출력 : com.hongstudio.test.Person@19dfb72a
}
위와 같이 toString() 메서드를 재정의하지 않고 사용하면 아무 의미없는 문자열만 출력된다. Decompile하여 이유를 알아보자.
public final class TestKt {
public static final void main() {
Person p1 = new Person("Michael", 29);
String var1 = p1.toString();
System.out.println(var1);
}
// ...
}
Decompile한 코드에서 toString() 메서드가 정의된 곳을 확인해보자.
public String toString() {
return this.getClass().getName() + "@" + Integer.toHexString(this.hashCode());
}
class의 이름 + @ + 객체 해시코드를 16진수로 표현한 String
기본적으로 이 방식으로 메서드가 정의되어 있다. 하지만 아까도 말했듯이 이 문자열은 사실 개발하는데 큰 의미가 없는 문자열이다. 그래서 toString() 메서드를 좀 더 의미있는 문자열을 리턴하도록 override해야 한다.
class Person(val name: String, val age: Int) {
override fun toString(): String {
return "Person(name=$name, age=$age)"
}
}
fun main() {
val p1 = Person("Michael", 29)
println(p1.toString()) // 출력 : Person(name=Michael, age=29)
}
fun main() {
val p1 = Person("Michael", 29)
val p2 = Person("Michael", 29)
println(p1 == p2) // 출력 : false
}
p1과 p2 인스턴스는 둘다 똑같은 값을 가진 Person 객체를 참조하고 있다. 그런데 p1과 p2의 동등성 비교를 해보니 false가 출력되었다. 같은 내용의 객체인데 왜 true가 아니고 false가 나올까? 아까처럼 Decompile해보자.
public final class TestKt {
public static final void main() {
Person p1 = new Person("Michael", 29);
Person p2 = new Person("Michael", 29);
boolean var2 = Intrinsics.areEqual(p1, p2);
System.out.println(var2);
}
// ...
}
areEqual() 메서드가 정의된 곳을 확인해보자.
public static boolean areEqual(Object first, Object second) {
return first == null ? second == null : first.equals(second);
}
first(여기서는 p1)이 null인지 확인하여 null이 아니면 first.equals(second)을 호출한다. 현재 예시에서 p1은 null이 아니므로 first.equals(second)이 호출된다.
public boolean equals(Object var1) {
return this == var1;
}
equals()가 정의된 곳을 확인해보면 두 객체를 동일성 비교하고 있는 것을 확인할 수 있다. Java 문법에서 ==는 동일성 비교 연산자이다. 그래서 p1과 p2는 같은 값을 가지고 있지만 실제로는 다른 객체이기 때문에 false가 출력되었던 것이다.
하지만 equals() 라는 이름에서 표현하듯이 동일성 비교를 하는 메서드가 아니라 동등성 비교를 하는 메서드가 되어야 한다.
class Person(val name: String, val age: Int) {
// ...
override fun equals(other: Any?): Boolean {
return if (this === other) {
true
} else if (other !is Person) {
false
} else {
name == other.name && age == other.age
}
}
}
fun main() {
val p1 = Person("Michael", 29)
val p2 = Person("Michael", 29)
println(p1 == p2) // 출력 : true
}
위와 같이 equals()를 override하였고, 그 결과 동등성 비교가 정상적으로 동작하여 true가 출력된다.
fun main() {
val people = hashSetOf(Person("Michael", 29))
println(people.contains(Person("Michael", 29))) // 출력 : false
}
위 코드에서 두 개의 Person 객체는 프로퍼티가 모두 일치하므로 true가 출력되기를 예상한다. 하지만 실제로는 false가 출력된다. equals()도 재정의 해주었는데 왜 이런 일이 발생할까?
이는 Person 클래스가 hashCode() 메서드를 재정의하지 않았기 때문이다. hashCode() 메서드는 객체의 해시코드를 리턴하는 함수이다. 해시코드란 객체를 식별하는 고유한 정수값이다.
Decompile을 해서 hashCode() 메서드가 정의된 곳을 찾고 싶었는데 실패했다. 하지만 Java의 Object 명세를 따르고 있으므로 hashCode()가 정의된 곳을 찾고 싶으면 Object.java 파일에서 찾아보면 된다. 이 글에서 hashCode() 메서드 선언 코드는 생략했다.
HashSet을 사용할 때 동등성 비교가 제대로 이루어지지 않는 이유는, HashSet은 원소를 비교할 때 비용을 줄이기 위해 먼저 객체의 해시코드를 비교하고, 해시코드가 같은 경우에만 실제 값을 비교하기 때문이다. HashMap의 동등성 비교도 마찬가지다.
예시에서 contains() 메서드를 따라가보면 getNode()라는 메서드로 이어지는데 여기서 그에 대한 힌트를 얻을 수 있다.
final Node<K,V> getNode(Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & (hash = hash(key))]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
사실 코드가 너무 복잡해서 완벽하게 이해하기는 힘들었다. 하지만 if의 조건문들을 살펴보면 first.hash == hash라는 조건이 true여야만 이후에 key.equals(k)로 동등성을 비교하고, e.hash == hash라는 조건이 true여야만 이후에 key.equals(k)로 동등성을 비교하는 것을 알 수 있다. 사실 내가 분석한 게 맞는지 확신은 없지만, 확실한 건 hash를 먼저 비교한다는 것이다.
그래서 equals()를 재정의했다면 hashCode()도 재정의해주어야 한다. 재정의하지 않으면 Hash 기반의 HashSet, HashMap을 사용할 때 문제가 발생할 수 있다.
class Person(val name: String, val age: Int) {
// ...
override fun hashCode(): Int {
return name.hashCode() * 31 + Integer.hashCode(age)
}
}
fun main() {
val people = hashSetOf(Person("Michael", 29))
println(people.contains(Person("Michael", 29))) // 출력 : true
}
hashCode()를 override하여 의도한 대로 true가 출력되었다.
Java에서도 이렇게 toString(), equals(), hashCode()를 override해주어야 하고, Kotlin에서도 일반 클래스를 통해 구현한다면 지금까지 봐온 것과 같이 override를 해주어야 한다. 하지만 앱이 커지고 복잡해질수록 데이터를 저장하기 위한 목적으로 사용되는 클래스가 많아질텐데, 이런 식으로 모든 클래스에서 메서드를 재정의해준다면 코드가 너무 번잡해질 것이다.
하지만 data class가 이 모든 걸 한방에 해결해준다. data class를 사용하면 코틀린 컴파일러가 이 모든 메서드를 자동으로 생성해주기 때문이다.
data class Person(val name: String, val age: Int)
data class로 선언된 Person 클래스 코드를 Decompile해보자.
public final class Person {
//...
@NotNull
public String toString() {
return "Person(name=" + this.name + ", age=" + this.age + ')';
}
public int hashCode() {
int result = this.name.hashCode();
result = result * 31 + Integer.hashCode(this.age);
return result;
}
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
} else if (!(other instanceof Person)) {
return false;
} else {
Person var2 = (Person)other;
if (!Intrinsics.areEqual(this.name, var2.name)) {
return false;
} else {
return this.age == var2.age;
}
}
}
}
위와 같이 모든 메서드가 자동 생성된 것을 볼 수 있다. data class를 사용함으로써 불필요한 보일러 플레이트 코드를 작성할 필요가 없어지는 것이다. 또한 data class를 사용함으로써 copy(), componentN() 함수도 제공해주는데, 이에 대한 것은 다른 글에서 알아보겠다.