Kotlin data class를 정의할 때 원한다면 var 프로퍼티를 사용해도 된다. 하지만 보통 모든 프로퍼티를 val을 사용하여 읽기 전용으로 만들어서 data class를 불변 클래스로 만들라고 권장한다. 왜 그런지 이유를 알아보자.
우선 var로 선언하면 객체 프로퍼티의 값을 쉽게 변경할 수 있기 때문에 프로그램에 대해 예측하는 것이 어렵다. 앱이 복잡해질수록 그리고 여러 개발자가 프로젝트에 참여할수록, 내가 사용하려는 객체의 프로퍼티가 현재 어떤 값을 가지고 있는지 예측하기가 어렵다는 의미이다.
멀티스레드 환경에서 불변 객체를 사용하면 한 스레드가 사용 중인 데이터를 다른 스레드가 변경할 수 없기 때문에 스레드를 동기화해야 할 필요가 줄어든다.
동기화란 여러 스레드가 동시에 접근하는 공유 자원을 일관성 있게 관리하기 위한 방법으로, 동일한 데이터를 동시에 읽거나 쓰는 상황에서 발생하는 문제를 방지하는 것이다.
data class Person(var name: String, var age: Int)
fun main() {
val p1 = Person("Alice", 25)
val hashMap: HashMap<Person, String> = hashMapOf()
hashMap.put(p1, "Engineer")
p1.age = 26
println(hashMap.get(p1)) // 출력 : null
}
위 코드에서 Engineer가 출력되는 것을 예상했는데 실제로는 null이 출력된다. p1.age = 26가 없으면 예상한대로 Engineer가 출력되지만 프로퍼티를 변경하는 과정이 있으면 null이 출력된다. HashMap에 데이터를 put할 때의 방식 때문이다.
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
put() 메서드를 보면 hash(key)라는 메서드가 존재한다.
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
객체 타입의 key가 null이 아니면 객체의 hashCode() 메서드를 사용하여 정수를 리턴하는 함수이다. put을 할 때 내부적으로 hashCode() 메서드가 사용되는 것이다. 그리고 data class는 코틀린 컴파일러가 자동으로 생성해준 hashCode() 메서드를 사용한다. Decompile하여 확인해보자.
public int hashCode() {
int result = this.name.hashCode();
result = result * 31 + Integer.hashCode(this.age);
return result;
}
프로퍼티를 이용해서 해시코드를 만드는 것을 알 수 있다. 하지만 예시에서 프로퍼티 age의 값을 변경해주었다. HashMap에서 get할 때도 내부적으로 hashCode()를 사용하여 get하는데, 이렇게 되면 put했을 때의 해시코드와 get했을 때의 해시코드가 달라지기 때문에 HashMap에서 데이터를 찾을 수가 없는 것이다. 그래서 null이 출력되는 것이다.
data class Person(var name: String, var age: Int)
fun main() {
val person = Person("Alice", 25)
val hashSet = HashSet<Person>()
hashSet.add(person)
person.age = 26
println(hashSet.contains(person)) // 출력 : false
}
HashSet을 사용할 때도 마찬가지이다. HashSet에 데이터를 add할 때와 데이터가 있는지 확인하는 contains()를 사용할 때도 내부적으로 hashCode()를 호출한다. add했을 때의 해시코드와 contains()를 호출했을 때의 해시코드가 달라지기 때문에 예시 코드에서 false가 출력되는 것이다. 내부적으로 사용되는 함수는 HashMap을 사용했을 때와 비슷하기 때문에 여기서 내부 코드는 생략하겠다.
코틀린 컴파일러는 data class 인스턴스를 불변 객체로 더 쉽게 활용할 수 있도록 한 가지 편의 메서드를 제공한다. 객체를 복사하여 프로퍼티를 변경가능하게 해주는 copy() 메서드이다.
data class Person(val name: String, val age: Int)
data class를 디컴파일 했을 때 copy() 메서드가 자동 생성된 것을 확인할 수 있다.
@NotNull
public final Person copy(@NotNull String name, int age) {
Intrinsics.checkNotNullParameter(name, "name");
return new Person(name, age);
}
기존에 있던 객체의 프로퍼티를 변경하는 것이 아니라, 변경된 프로퍼티를 기반으로 새로운 Person 객체를 리턴한다.
data class Person(val name: String, val age: Int)
fun main() {
val p1 = Person("Alice", 25)
val p2 = p1.copy(age = 26)
}
모든 프로퍼티를 변경할 필요는 없다. 위와 같이 일부 프로퍼티만 변경도 가능하고, p1.copy()만 작성하여 같은 프로퍼티 값을 가진 새로운 객체도 생성 가능하다. 복사본은 새로운 객체이기 때문에 변경하여도 원본 객체에 아무런 영향도 미치지 않는다.
data class를 사용했을 때 자동으로 제공되는 메서드 중 componentN()이라는 메서드도 존재한다. 이 메서드는 구조 분해 선언에서 사용된다. 구조 분해 선언은 객체를 분해하여 여러 변수를 한꺼번에 초기화 가능하게 해준다.
data class Person(val name: String, val age: Int)
fun main() {
val person = Person("Alice", 25)
val (name, age) = person
println("$name, $age") // 출력 : Alice, 25
}
보통 componentN() 함수를 직접 호출하지 않고 위와 같이 구조 분해 선언으로 사용한다.
@NotNull
public final String component1() {
return this.name;
}
public final int component2() {
return this.age;
}
// ...
public static final void main() {
Person person = new Person("Alice", 25);
String name = person.component1();
int age = person.component2();
String var3 = name + ", " + age;
System.out.println(var3);
}
디컴파일 해보면 component1(), component2() 메서드를 사용하고 있는 것을 알 수 있다.
fun main() {
val people = listOf(Person("Alice", 25), Person("Bob", 30), Person("Charlie", 28))
// 구조 분해 선언을 사용하여 람다 매개변수에서 데이터 클래스의 각 속성을 직접 받는다
people.forEach { (name, age) ->
println("$name is $age years old")
}
}
컬렉션과 함께 사용할 때도 위와 같이 구조 분해를 사용할 수 있다.
fun main() {
val pair: Pair<String, Int> = "Alice" to 25
val (name, age) = pair
val triple: Triple<Int, Int, Int> = Triple(1, 2, 3)
val (a, b, c) = triple
}
꼭 사용자 정의 data class를 사용해야만 구조 분해를 할 수 있는 것이 아니다라는 것을 보여주기 위해 Pair 객체와 Triple 객체를 구조분해하는 예시도 추가하였다. Pair와 Triple 클래스도 data class이기 때문에 내부적으로 componentN() 메서드를 호출한다.